928 lines
33 KiB
TypeScript
928 lines
33 KiB
TypeScript
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
|
|
import { Check, Palette, Plus, Trash2, ChevronDown, ChevronLeft, ChevronRight as ChevronRightIcon } from 'lucide-react';
|
|
import { type Theme } from '@/contexts/AppContext';
|
|
import { useTheme } from '@/hooks/useTheme';
|
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
|
import { usePublishTheme } from '@/hooks/usePublishTheme';
|
|
import { useUserThemes } from '@/hooks/useUserThemes';
|
|
import { useToast } from '@/hooks/useToast';
|
|
import type { ThemeDefinition } from '@/lib/themeEvent';
|
|
import { themePresets, coreToTokens, resolveTheme, resolveThemeConfig, type CoreThemeColors, type ThemeTokens, type ThemeConfig, type ThemesConfig } from '@/themes';
|
|
import { hslStringToHex, hexToHslString } from '@/lib/colorUtils';
|
|
import { ColorPicker } from '@/components/ui/color-picker';
|
|
import { FontPicker } from '@/components/FontPicker';
|
|
import { BackgroundPicker } from '@/components/BackgroundPicker';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Textarea } from '@/components/ui/textarea';
|
|
import { Switch } from '@/components/ui/switch';
|
|
import { Label } from '@/components/ui/label';
|
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
|
|
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '@/components/ui/collapsible';
|
|
import { cn } from '@/lib/utils';
|
|
|
|
/** Extracts HSL color string from a theme token value like "258 70% 55%" */
|
|
function hsl(value: string): string {
|
|
return `hsl(${value})`;
|
|
}
|
|
|
|
/** Core color keys exposed in the editor, in display order */
|
|
const CORE_KEYS: (keyof CoreThemeColors)[] = ['primary', 'text', 'background'];
|
|
|
|
/** Human-readable labels for core color keys */
|
|
const COLOR_LABELS: Record<keyof CoreThemeColors, string> = {
|
|
primary: 'Primary',
|
|
text: 'Text',
|
|
background: 'Background',
|
|
};
|
|
|
|
/** Get the effective CoreThemeColors for the current theme */
|
|
function getEffectiveColors(theme: Theme, customTheme?: ThemeConfig, themes?: ThemesConfig): CoreThemeColors {
|
|
if (theme === 'custom' && customTheme) return customTheme.colors;
|
|
const resolved = resolveTheme(theme);
|
|
if (resolved === 'custom') return customTheme?.colors ?? resolveThemeConfig('dark', themes).colors;
|
|
return resolveThemeConfig(resolved, themes).colors;
|
|
}
|
|
|
|
/** Mini preview card for a theme with known tokens */
|
|
function ThemePreviewCard({
|
|
colors,
|
|
isActive,
|
|
backgroundUrl,
|
|
children,
|
|
}: {
|
|
colors: CoreThemeColors;
|
|
isActive: boolean;
|
|
backgroundUrl?: string;
|
|
children?: React.ReactNode;
|
|
}) {
|
|
const tokens = useMemo(() => coreToTokens(colors), [colors]);
|
|
|
|
return (
|
|
<>
|
|
{/* Mini preview */}
|
|
<div
|
|
className="aspect-[4/3] rounded-lg overflow-hidden relative"
|
|
style={{ backgroundColor: hsl(tokens.background) }}
|
|
>
|
|
{/* Background image layer */}
|
|
{backgroundUrl && (
|
|
<img
|
|
src={backgroundUrl}
|
|
alt=""
|
|
className="absolute inset-0 w-full h-full object-cover opacity-40"
|
|
/>
|
|
)}
|
|
{/* Simulated header bar */}
|
|
<div
|
|
className="h-2.5 w-full relative"
|
|
style={{ backgroundColor: hsl(tokens.card) }}
|
|
/>
|
|
{/* Content preview area */}
|
|
<div className="p-1.5 space-y-1 relative">
|
|
{/* Simulated text lines */}
|
|
<div
|
|
className="h-1 w-3/4 rounded-full"
|
|
style={{ backgroundColor: hsl(tokens.foreground), opacity: 0.6 }}
|
|
/>
|
|
<div
|
|
className="h-1 w-1/2 rounded-full"
|
|
style={{ backgroundColor: hsl(tokens.mutedForeground), opacity: 0.4 }}
|
|
/>
|
|
{/* Simulated button */}
|
|
<div className="pt-0.5">
|
|
<div
|
|
className="h-2 w-8 rounded-sm"
|
|
style={{ backgroundColor: hsl(tokens.primary) }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
{/* Simulated sidebar strip */}
|
|
<div
|
|
className="absolute right-0 top-0 bottom-0 w-4"
|
|
style={{ backgroundColor: hsl(tokens.secondary) }}
|
|
/>
|
|
|
|
{/* Active check mark */}
|
|
{isActive && (
|
|
<div className="absolute top-1 left-1 size-4 rounded-full flex items-center justify-center"
|
|
style={{ backgroundColor: hsl(tokens.primary) }}
|
|
>
|
|
<Check className="size-2.5" style={{ color: hsl(tokens.primaryForeground) }} />
|
|
</div>
|
|
)}
|
|
|
|
{children}
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
/** A single theme button used inside ThemeGrid */
|
|
function ThemeButton({
|
|
isActive,
|
|
label,
|
|
truncate = false,
|
|
scroll = false,
|
|
carousel = false,
|
|
onClick,
|
|
children,
|
|
}: {
|
|
isActive: boolean;
|
|
label: string;
|
|
truncate?: boolean;
|
|
scroll?: boolean;
|
|
carousel?: boolean;
|
|
onClick: () => void;
|
|
children: React.ReactNode;
|
|
}) {
|
|
return (
|
|
<button
|
|
className={cn(
|
|
'relative group rounded-xl border-2 p-1 transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
|
isActive ? 'border-primary shadow-sm' : 'border-border hover:border-primary/40',
|
|
scroll && 'flex-1',
|
|
carousel && 'w-full',
|
|
)}
|
|
onClick={onClick}
|
|
>
|
|
{children}
|
|
<p className={cn(
|
|
'mt-1.5 text-xs font-medium text-center transition-colors',
|
|
isActive ? 'text-foreground' : 'text-muted-foreground',
|
|
truncate && 'truncate',
|
|
)}>
|
|
{label}
|
|
</p>
|
|
</button>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Renders the grid of theme options (builtins + presets + user themes).
|
|
* Applies selection immediately via `useTheme`. Calls `onSelect` after
|
|
* each pick so callers (e.g. onboarding) can react without duplicating logic.
|
|
*/
|
|
export function ThemeGrid({
|
|
onSelect,
|
|
editingTheme,
|
|
onEditingThemeChange,
|
|
columns = 'responsive',
|
|
}: {
|
|
/** Called after any theme is selected. */
|
|
onSelect?: () => void;
|
|
/** Currently-editing user theme (for active state). Pass null if not in editor mode. */
|
|
editingTheme?: ThemeDefinition | null;
|
|
/** Callback to update editing theme state in the parent. */
|
|
onEditingThemeChange?: (def: ThemeDefinition | null) => void;
|
|
/**
|
|
* Layout mode:
|
|
* - 'responsive': 2-col grid on mobile, 3-col at sidebar breakpoint (900px)
|
|
* - 'sm': 2-col grid on mobile, 3-col at sm (640px)
|
|
* - '2': always 2-col grid
|
|
* - 'scroll': horizontal scrolling strip on mobile, 3-col grid at sm+
|
|
*/
|
|
columns?: 'responsive' | 'sm' | '2' | 'scroll';
|
|
}) {
|
|
const { theme, customTheme, themes, setTheme, applyCustomTheme } = useTheme();
|
|
const { user } = useCurrentUser();
|
|
const userThemes = useUserThemes(user?.pubkey);
|
|
|
|
const builtinOptions: { id: Theme; label: string }[] = [
|
|
{ id: 'system', label: 'System' },
|
|
{ id: 'light', label: 'Light' },
|
|
{ id: 'dark', label: 'Dark' },
|
|
];
|
|
|
|
const presetOptions = Object.entries(themePresets).map(([id, preset]) => ({
|
|
id,
|
|
label: preset.label,
|
|
colors: preset.colors,
|
|
font: preset.font,
|
|
background: preset.background,
|
|
}));
|
|
|
|
const isPresetActive = (presetColors: CoreThemeColors): boolean => {
|
|
if (theme !== 'custom' || !customTheme) return false;
|
|
return JSON.stringify(customTheme.colors) === JSON.stringify(presetColors);
|
|
};
|
|
|
|
const isUserThemeActive = (def: ThemeDefinition): boolean => {
|
|
return editingTheme?.identifier === def.identifier && theme === 'custom';
|
|
};
|
|
|
|
const handleSelectBuiltin = useCallback((id: Theme) => {
|
|
onEditingThemeChange?.(null);
|
|
setTheme(id);
|
|
onSelect?.();
|
|
}, [setTheme, onSelect, onEditingThemeChange]);
|
|
|
|
const handleSelectPreset = useCallback((preset: { colors: CoreThemeColors; font?: ThemeConfig['font']; background?: ThemeConfig['background'] }) => {
|
|
onEditingThemeChange?.(null);
|
|
applyCustomTheme({ colors: preset.colors, font: preset.font, background: preset.background });
|
|
onSelect?.();
|
|
}, [applyCustomTheme, onSelect, onEditingThemeChange]);
|
|
|
|
const handleSelectUserTheme = useCallback((def: ThemeDefinition) => {
|
|
onEditingThemeChange?.(def);
|
|
applyCustomTheme({ colors: def.colors, font: def.font, background: def.background, title: def.title });
|
|
onSelect?.();
|
|
}, [applyCustomTheme, onSelect, onEditingThemeChange]);
|
|
|
|
const isScroll = columns === 'scroll';
|
|
|
|
// Carousel state for scroll mode (mobile only)
|
|
const scrollRef = useRef<HTMLDivElement>(null);
|
|
const [activePage, setActivePage] = useState(0);
|
|
|
|
// Build a flat list of item descriptors (needed for carousel auto-select)
|
|
type ItemDescriptor = {
|
|
key: string;
|
|
label: string;
|
|
truncate: boolean;
|
|
onSelect: () => void;
|
|
preview: React.ReactNode;
|
|
isActive: boolean;
|
|
};
|
|
|
|
const buildItems = (): ItemDescriptor[] => {
|
|
const items: ItemDescriptor[] = [];
|
|
|
|
for (const option of builtinOptions) {
|
|
if (option.id === 'system') {
|
|
const isActive = theme === 'system';
|
|
const lightTokens = coreToTokens(resolveThemeConfig('light', themes).colors);
|
|
const darkTokens = coreToTokens(resolveThemeConfig('dark', themes).colors);
|
|
items.push({
|
|
key: 'system',
|
|
label: option.label,
|
|
truncate: false,
|
|
onSelect: () => handleSelectBuiltin('system'),
|
|
isActive,
|
|
preview: (
|
|
<div className="aspect-[4/3] rounded-lg overflow-hidden relative">
|
|
<SystemHalf tokens={lightTokens} side="left" />
|
|
<SystemHalf tokens={darkTokens} side="right" />
|
|
{isActive && (
|
|
<div className="absolute top-1 left-1 size-4 rounded-full flex items-center justify-center"
|
|
style={{ backgroundColor: hsl(lightTokens.primary) }}
|
|
>
|
|
<Check className="size-2.5" style={{ color: hsl(lightTokens.primaryForeground) }} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
),
|
|
});
|
|
} else {
|
|
const colors = resolveThemeConfig(option.id as 'light' | 'dark', themes).colors;
|
|
const isActive = theme === option.id;
|
|
items.push({
|
|
key: option.id,
|
|
label: option.label,
|
|
truncate: false,
|
|
onSelect: () => handleSelectBuiltin(option.id),
|
|
isActive,
|
|
preview: <ThemePreviewCard colors={colors} isActive={isActive} />,
|
|
});
|
|
}
|
|
}
|
|
|
|
for (const preset of presetOptions) {
|
|
const isActive = isPresetActive(preset.colors);
|
|
items.push({
|
|
key: preset.id,
|
|
label: preset.label,
|
|
truncate: false,
|
|
onSelect: () => handleSelectPreset(preset),
|
|
isActive,
|
|
preview: <ThemePreviewCard colors={preset.colors} isActive={isActive} backgroundUrl={preset.background?.url} />,
|
|
});
|
|
}
|
|
|
|
for (const def of (userThemes.data ?? [])) {
|
|
const isActive = isUserThemeActive(def);
|
|
items.push({
|
|
key: `user:${def.identifier}`,
|
|
label: def.title,
|
|
truncate: true,
|
|
onSelect: () => handleSelectUserTheme(def),
|
|
isActive,
|
|
preview: <ThemePreviewCard colors={def.colors} isActive={isActive} backgroundUrl={def.background?.url} />,
|
|
});
|
|
}
|
|
|
|
return items;
|
|
};
|
|
|
|
const allItems = buildItems();
|
|
|
|
if (isScroll) {
|
|
// Auto-select the theme that scrolls into view
|
|
const handleScroll = () => {
|
|
const el = scrollRef.current;
|
|
if (!el) return;
|
|
const page = Math.round(el.scrollLeft / el.clientWidth);
|
|
if (page !== activePage) {
|
|
setActivePage(page);
|
|
allItems[page]?.onSelect();
|
|
}
|
|
};
|
|
|
|
const goToPage = (page: number) => {
|
|
const el = scrollRef.current;
|
|
if (!el) return;
|
|
el.scrollTo({ left: page * el.clientWidth, behavior: 'smooth' });
|
|
};
|
|
|
|
const canPrev = activePage > 0;
|
|
const canNext = activePage < allItems.length - 1;
|
|
|
|
return (
|
|
<>
|
|
{/* Mobile carousel */}
|
|
<div className="sm:hidden space-y-3">
|
|
<div className="flex items-center gap-2">
|
|
{/* Left arrow */}
|
|
<button
|
|
onClick={() => goToPage(activePage - 1)}
|
|
disabled={!canPrev}
|
|
className={cn(
|
|
'shrink-0 size-8 flex items-center justify-center rounded-full',
|
|
'bg-muted border border-border shadow-sm',
|
|
'transition-opacity duration-200',
|
|
canPrev ? 'opacity-100' : 'opacity-0 pointer-events-none',
|
|
)}
|
|
>
|
|
<ChevronLeft className="size-4" />
|
|
</button>
|
|
|
|
{/* Scroll container — 1 card per page, swipeable */}
|
|
<div
|
|
ref={scrollRef}
|
|
onScroll={handleScroll}
|
|
className="flex flex-1 overflow-x-auto snap-x snap-mandatory"
|
|
style={{ scrollbarWidth: 'none', WebkitOverflowScrolling: 'touch' } as React.CSSProperties}
|
|
>
|
|
{allItems.map((item) => (
|
|
<div key={item.key} className="shrink-0 w-full snap-start">
|
|
<ThemeButton
|
|
isActive={item.isActive}
|
|
label={item.label}
|
|
truncate={item.truncate}
|
|
onClick={item.onSelect}
|
|
carousel
|
|
>
|
|
{item.preview}
|
|
</ThemeButton>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Right arrow */}
|
|
<button
|
|
onClick={() => goToPage(activePage + 1)}
|
|
disabled={!canNext}
|
|
className={cn(
|
|
'shrink-0 size-8 flex items-center justify-center rounded-full',
|
|
'bg-muted border border-border shadow-sm',
|
|
'transition-opacity duration-200',
|
|
canNext ? 'opacity-100' : 'opacity-0 pointer-events-none',
|
|
)}
|
|
>
|
|
<ChevronRightIcon className="size-4" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Dot indicators */}
|
|
<div className="flex justify-center gap-1.5">
|
|
{allItems.map((_, i) => (
|
|
<button
|
|
key={i}
|
|
onClick={() => goToPage(i)}
|
|
className={cn(
|
|
'rounded-full transition-all duration-200',
|
|
i === activePage
|
|
? 'w-4 h-1.5 bg-primary'
|
|
: 'w-1.5 h-1.5 bg-muted-foreground/30 hover:bg-muted-foreground/60',
|
|
)}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Desktop grid */}
|
|
<div className="hidden sm:grid sm:grid-cols-3 gap-3">
|
|
{allItems.map((item) => (
|
|
<ThemeButton
|
|
key={item.key}
|
|
isActive={item.isActive}
|
|
label={item.label}
|
|
truncate={item.truncate}
|
|
onClick={item.onSelect}
|
|
>
|
|
{item.preview}
|
|
</ThemeButton>
|
|
))}
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
const gridClass =
|
|
columns === '2' ? 'grid grid-cols-2 gap-3'
|
|
: columns === 'sm' ? 'grid grid-cols-2 sm:grid-cols-3 gap-3'
|
|
: 'grid grid-cols-2 sidebar:grid-cols-3 gap-3';
|
|
|
|
return (
|
|
<div className={gridClass}>
|
|
{allItems.map((item) => (
|
|
<ThemeButton
|
|
key={item.key}
|
|
isActive={item.isActive}
|
|
label={item.label}
|
|
truncate={item.truncate}
|
|
onClick={item.onSelect}
|
|
>
|
|
{item.preview}
|
|
</ThemeButton>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface ThemeSelectorProps {
|
|
/** Controls the builder dialog from the parent (e.g., FAB or header button). */
|
|
builderOpen?: boolean;
|
|
/** Callback when the builder dialog open state changes. */
|
|
onBuilderOpenChange?: (open: boolean) => void;
|
|
/** Whether the builder should open in 'new' or 'edit' mode. */
|
|
builderMode?: 'new' | 'edit';
|
|
}
|
|
|
|
export function ThemeSelector({ builderOpen, onBuilderOpenChange, builderMode }: ThemeSelectorProps = {}) {
|
|
const { theme, customTheme, themes, autoShareTheme, setTheme, applyCustomTheme, setAutoShareTheme } = useTheme();
|
|
const { user } = useCurrentUser();
|
|
const { publishTheme, deleteTheme, isPending: isPublishing } = usePublishTheme();
|
|
const { toast } = useToast();
|
|
|
|
// Editor mode: which user theme is being edited
|
|
const [editingTheme, setEditingTheme] = useState<ThemeDefinition | null>(null);
|
|
|
|
// Publish dialog state
|
|
const [publishDialogOpen, setPublishDialogOpen] = useState(false);
|
|
const [publishTitle, setPublishTitle] = useState('');
|
|
const [publishDescription, setPublishDescription] = useState('');
|
|
|
|
// Clear editingTheme when builder opens in 'new' mode
|
|
useEffect(() => {
|
|
if (builderOpen && builderMode === 'new') {
|
|
setEditingTheme(null);
|
|
}
|
|
}, [builderOpen, builderMode]);
|
|
|
|
/** The effective colors for the current theme (used in the color editor) */
|
|
const effectiveColors = getEffectiveColors(theme, customTheme, themes);
|
|
|
|
/** Handle a color change from the inline editor */
|
|
const handleColorChange = useCallback((key: keyof CoreThemeColors, hex: string) => {
|
|
const hslValue = hexToHslString(hex);
|
|
const newColors = { ...effectiveColors, [key]: hslValue };
|
|
applyCustomTheme({ ...customTheme, colors: newColors });
|
|
}, [effectiveColors, applyCustomTheme, customTheme]);
|
|
|
|
/** Open the publish dialog for a new theme */
|
|
const handlePublishNew = useCallback(() => {
|
|
setPublishTitle('');
|
|
setPublishDescription('');
|
|
setPublishDialogOpen(true);
|
|
}, []);
|
|
|
|
/** Update the currently-editing theme */
|
|
const handleUpdateTheme = useCallback(async () => {
|
|
if (!editingTheme) return;
|
|
try {
|
|
const themeConfig: ThemeConfig = { ...customTheme, colors: effectiveColors };
|
|
await publishTheme({
|
|
themeConfig,
|
|
title: editingTheme.title,
|
|
description: editingTheme.description,
|
|
identifier: editingTheme.identifier,
|
|
});
|
|
setEditingTheme({
|
|
...editingTheme,
|
|
colors: effectiveColors,
|
|
font: customTheme?.font,
|
|
background: customTheme?.background,
|
|
});
|
|
toast({ title: `"${editingTheme.title}" updated`, description: 'Your theme has been saved and republished.' });
|
|
} catch (error) {
|
|
console.error('Failed to update theme:', error);
|
|
toast({ title: 'Update failed', description: 'Could not update your theme.', variant: 'destructive' });
|
|
}
|
|
}, [editingTheme, customTheme, effectiveColors, publishTheme, toast]);
|
|
|
|
/** Submit the publish dialog */
|
|
const handlePublishSubmit = useCallback(async () => {
|
|
if (!publishTitle.trim()) {
|
|
toast({ title: 'Title required', description: 'Give your theme a name.', variant: 'destructive' });
|
|
return;
|
|
}
|
|
try {
|
|
const themeConfig: ThemeConfig = { ...customTheme, colors: effectiveColors };
|
|
const identifier = await publishTheme({
|
|
themeConfig,
|
|
title: publishTitle.trim(),
|
|
description: publishDescription.trim() || undefined,
|
|
});
|
|
setPublishDialogOpen(false);
|
|
setEditingTheme({
|
|
identifier,
|
|
title: publishTitle.trim(),
|
|
description: publishDescription.trim() || undefined,
|
|
colors: effectiveColors,
|
|
font: customTheme?.font,
|
|
background: customTheme?.background,
|
|
event: {} as ThemeDefinition['event'],
|
|
});
|
|
toast({ title: 'Theme published!', description: `"${publishTitle.trim()}" is now available on Nostr.` });
|
|
} catch (error) {
|
|
console.error('Failed to publish theme:', error);
|
|
toast({ title: 'Publish failed', description: 'Could not publish your theme.', variant: 'destructive' });
|
|
}
|
|
}, [publishTitle, publishDescription, customTheme, effectiveColors, publishTheme, toast]);
|
|
|
|
/** Delete the currently-editing theme */
|
|
const handleDeleteTheme = useCallback(async () => {
|
|
if (!editingTheme) return;
|
|
try {
|
|
await deleteTheme(editingTheme);
|
|
toast({ title: 'Theme deleted', description: `"${editingTheme.title}" has been removed.` });
|
|
setEditingTheme(null);
|
|
setTheme('system');
|
|
onBuilderOpenChange?.(false);
|
|
} catch (error) {
|
|
console.error('Failed to delete theme:', error);
|
|
toast({ title: 'Delete failed', description: 'Could not delete your theme.', variant: 'destructive' });
|
|
}
|
|
}, [editingTheme, deleteTheme, toast, setTheme, onBuilderOpenChange]);
|
|
|
|
// ── Build sectioned item lists ──
|
|
const userThemes = useUserThemes(user?.pubkey);
|
|
|
|
const builtinOptions: { id: Theme; label: string }[] = [
|
|
{ id: 'system', label: 'System' },
|
|
{ id: 'light', label: 'Light' },
|
|
{ id: 'dark', label: 'Dark' },
|
|
];
|
|
|
|
const presetOptions = Object.entries(themePresets).map(([id, preset]) => ({
|
|
id,
|
|
label: preset.label,
|
|
colors: preset.colors,
|
|
font: preset.font,
|
|
background: preset.background,
|
|
}));
|
|
|
|
const isPresetActive = (presetColors: CoreThemeColors): boolean => {
|
|
if (theme !== 'custom' || !customTheme) return false;
|
|
return JSON.stringify(customTheme.colors) === JSON.stringify(presetColors);
|
|
};
|
|
|
|
const isUserThemeActive = (def: ThemeDefinition): boolean => {
|
|
return editingTheme?.identifier === def.identifier && theme === 'custom';
|
|
};
|
|
|
|
const handleSelectBuiltin = useCallback((id: Theme) => {
|
|
setEditingTheme(null);
|
|
setTheme(id);
|
|
}, [setTheme]);
|
|
|
|
const handleSelectPreset = useCallback((preset: { colors: CoreThemeColors; font?: ThemeConfig['font']; background?: ThemeConfig['background'] }) => {
|
|
setEditingTheme(null);
|
|
applyCustomTheme({ colors: preset.colors, font: preset.font, background: preset.background });
|
|
}, [applyCustomTheme]);
|
|
|
|
const handleSelectUserTheme = useCallback((def: ThemeDefinition) => {
|
|
setEditingTheme(def);
|
|
applyCustomTheme({ colors: def.colors, font: def.font, background: def.background, title: def.title });
|
|
}, [applyCustomTheme]);
|
|
|
|
type SectionItem = {
|
|
key: string;
|
|
label: string;
|
|
truncate: boolean;
|
|
onSelect: () => void;
|
|
preview: React.ReactNode;
|
|
isActive: boolean;
|
|
};
|
|
|
|
// Build items by category
|
|
const buildBuiltinItems = (): SectionItem[] => {
|
|
const items: SectionItem[] = [];
|
|
for (const option of builtinOptions) {
|
|
if (option.id === 'system') {
|
|
const isActive = theme === 'system';
|
|
const lightTokens = coreToTokens(resolveThemeConfig('light', themes).colors);
|
|
const darkTokens = coreToTokens(resolveThemeConfig('dark', themes).colors);
|
|
items.push({
|
|
key: 'system',
|
|
label: option.label,
|
|
truncate: false,
|
|
onSelect: () => handleSelectBuiltin('system'),
|
|
isActive,
|
|
preview: (
|
|
<div className="aspect-[4/3] rounded-lg overflow-hidden relative">
|
|
<SystemHalf tokens={lightTokens} side="left" />
|
|
<SystemHalf tokens={darkTokens} side="right" />
|
|
{isActive && (
|
|
<div className="absolute top-1 left-1 size-4 rounded-full flex items-center justify-center"
|
|
style={{ backgroundColor: hsl(lightTokens.primary) }}
|
|
>
|
|
<Check className="size-2.5" style={{ color: hsl(lightTokens.primaryForeground) }} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
),
|
|
});
|
|
} else {
|
|
const colors = resolveThemeConfig(option.id as 'light' | 'dark', themes).colors;
|
|
const isActive = theme === option.id;
|
|
items.push({
|
|
key: option.id,
|
|
label: option.label,
|
|
truncate: false,
|
|
onSelect: () => handleSelectBuiltin(option.id),
|
|
isActive,
|
|
preview: <ThemePreviewCard colors={colors} isActive={isActive} />,
|
|
});
|
|
}
|
|
}
|
|
return items;
|
|
};
|
|
|
|
const buildPresetItems = (): SectionItem[] =>
|
|
presetOptions.map((preset) => {
|
|
const isActive = isPresetActive(preset.colors);
|
|
return {
|
|
key: preset.id,
|
|
label: preset.label,
|
|
truncate: false,
|
|
onSelect: () => handleSelectPreset(preset),
|
|
isActive,
|
|
preview: <ThemePreviewCard colors={preset.colors} isActive={isActive} backgroundUrl={preset.background?.url} />,
|
|
};
|
|
});
|
|
|
|
const buildUserThemeItems = (): SectionItem[] =>
|
|
(userThemes.data ?? []).map((def) => {
|
|
const isActive = isUserThemeActive(def);
|
|
return {
|
|
key: `user:${def.identifier}`,
|
|
label: def.title,
|
|
truncate: true,
|
|
onSelect: () => handleSelectUserTheme(def),
|
|
isActive,
|
|
preview: <ThemePreviewCard colors={def.colors} isActive={isActive} backgroundUrl={def.background?.url} />,
|
|
};
|
|
});
|
|
|
|
const builtinItems = buildBuiltinItems();
|
|
const presetItems = buildPresetItems();
|
|
const userThemeItems = buildUserThemeItems();
|
|
|
|
const gridClass = 'grid grid-cols-2 sidebar:grid-cols-3 gap-3';
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* ── My Themes ── */}
|
|
<div className="space-y-2">
|
|
<h3 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground/70">
|
|
My Themes
|
|
</h3>
|
|
{userThemeItems.length > 0 ? (
|
|
<div className={gridClass}>
|
|
{userThemeItems.map((item) => (
|
|
<ThemeButton
|
|
key={item.key}
|
|
isActive={item.isActive}
|
|
label={item.label}
|
|
truncate={item.truncate}
|
|
onClick={item.onSelect}
|
|
>
|
|
{item.preview}
|
|
</ThemeButton>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<button
|
|
onClick={() => onBuilderOpenChange?.(true)}
|
|
className="w-full rounded-xl border-2 border-dashed border-border hover:border-primary/40 p-6 flex flex-col items-center gap-3 text-center transition-colors group"
|
|
>
|
|
<div className="size-10 rounded-full bg-muted flex items-center justify-center group-hover:bg-primary/10 transition-colors">
|
|
<Plus className="size-5 text-muted-foreground group-hover:text-primary transition-colors" />
|
|
</div>
|
|
<p className="text-sm text-muted-foreground">
|
|
Create and share your first custom theme
|
|
</p>
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* ── Presets ── */}
|
|
<div className="space-y-2">
|
|
<h3 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground/70">
|
|
Presets
|
|
</h3>
|
|
<div className={gridClass}>
|
|
{[...builtinItems, ...presetItems].map((item) => (
|
|
<ThemeButton
|
|
key={item.key}
|
|
isActive={item.isActive}
|
|
label={item.label}
|
|
truncate={item.truncate}
|
|
onClick={item.onSelect}
|
|
>
|
|
{item.preview}
|
|
</ThemeButton>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* ── Builder Dialog ── */}
|
|
<Dialog open={builderOpen ?? false} onOpenChange={(open) => onBuilderOpenChange?.(open)}>
|
|
<DialogContent className="w-[calc(100%-2rem)] max-w-md max-h-[85vh] overflow-y-auto rounded-lg">
|
|
<DialogHeader>
|
|
<DialogTitle>{editingTheme ? 'Edit Theme' : 'New Theme'}</DialogTitle>
|
|
<DialogDescription>
|
|
{editingTheme
|
|
? `Editing "${editingTheme.title}"`
|
|
: 'Customize colors, font, and background for your theme'}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="space-y-4 py-2">
|
|
{/* Colors */}
|
|
<div className="flex items-start justify-center gap-6">
|
|
{CORE_KEYS.map((key) => (
|
|
<ColorPicker
|
|
key={key}
|
|
label={COLOR_LABELS[key]}
|
|
value={hslStringToHex(effectiveColors[key])}
|
|
onChange={(hex) => handleColorChange(key, hex)}
|
|
/>
|
|
))}
|
|
</div>
|
|
|
|
{/* Font */}
|
|
<FontPicker />
|
|
|
|
{/* Background */}
|
|
<BackgroundPicker />
|
|
|
|
{/* Auto-share toggle */}
|
|
{user && (
|
|
<div className="rounded-xl border border-border bg-card p-4">
|
|
<div className="flex items-center justify-between gap-4">
|
|
<Label htmlFor="auto-share-theme-dialog" className="flex flex-col gap-1 cursor-pointer">
|
|
<span className="text-sm font-medium">Sync app theme with your profile theme</span>
|
|
<span className="text-xs text-muted-foreground font-normal">
|
|
Turn this off if you want to display a different theme on your profile page than you use in the rest of the app.
|
|
</span>
|
|
</Label>
|
|
<Switch
|
|
id="auto-share-theme-dialog"
|
|
checked={autoShareTheme}
|
|
onCheckedChange={setAutoShareTheme}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Action buttons */}
|
|
{user && (
|
|
<div className="space-y-3 pt-2">
|
|
<div className="flex flex-col gap-2">
|
|
{editingTheme && (
|
|
<Button
|
|
variant="secondary"
|
|
size="sm"
|
|
className="w-full"
|
|
onClick={handleUpdateTheme}
|
|
disabled={isPublishing}
|
|
>
|
|
<Palette className="size-3.5 mr-1.5" />
|
|
{isPublishing ? 'Updating...' : `Update "${editingTheme.title}"`}
|
|
</Button>
|
|
)}
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="w-full"
|
|
onClick={handlePublishNew}
|
|
>
|
|
<Palette className="size-3.5 mr-1.5" />
|
|
Publish New Theme
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Advanced section with delete */}
|
|
{editingTheme && (
|
|
<Collapsible>
|
|
<CollapsibleTrigger className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors group">
|
|
<ChevronDown className="size-3.5 transition-transform group-data-[state=open]:rotate-180" />
|
|
Advanced
|
|
</CollapsibleTrigger>
|
|
<CollapsibleContent>
|
|
<div className="pt-3">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={handleDeleteTheme}
|
|
disabled={isPublishing}
|
|
className="text-destructive hover:text-destructive hover:bg-destructive/10"
|
|
>
|
|
<Trash2 className="size-3.5 mr-1.5" />
|
|
Delete Theme
|
|
</Button>
|
|
</div>
|
|
</CollapsibleContent>
|
|
</Collapsible>
|
|
)}
|
|
</div>
|
|
)}
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* ── Publish Dialog ── */}
|
|
<Dialog open={publishDialogOpen} onOpenChange={setPublishDialogOpen}>
|
|
<DialogContent className="sm:max-w-md">
|
|
<DialogHeader>
|
|
<DialogTitle>Publish Theme</DialogTitle>
|
|
<DialogDescription>
|
|
Share your theme on Nostr. Others can browse, preview, and use it.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="space-y-4 py-2">
|
|
<div className="flex rounded-lg overflow-hidden h-8">
|
|
{CORE_KEYS.map((key) => (
|
|
<div
|
|
key={key}
|
|
className="flex-1"
|
|
style={{ backgroundColor: hslStringToHex(effectiveColors[key]) }}
|
|
/>
|
|
))}
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="theme-title">Title</Label>
|
|
<Input
|
|
id="theme-title"
|
|
value={publishTitle}
|
|
onChange={(e) => setPublishTitle(e.target.value)}
|
|
placeholder="e.g. My Dark Theme"
|
|
maxLength={80}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="theme-description">Description (optional)</Label>
|
|
<Textarea
|
|
id="theme-description"
|
|
value={publishDescription}
|
|
onChange={(e) => setPublishDescription(e.target.value)}
|
|
placeholder="A sleek dark theme with purple accents..."
|
|
maxLength={200}
|
|
rows={2}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button onClick={handlePublishSubmit} disabled={isPublishing || !publishTitle.trim()}>
|
|
<Palette className="size-4 mr-1.5" />
|
|
{isPublishing ? 'Publishing...' : 'Publish'}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** Half of the system theme preview (light or dark side) */
|
|
function SystemHalf({ tokens, side }: { tokens: ThemeTokens; side: 'left' | 'right' }) {
|
|
return (
|
|
<div
|
|
className={cn('absolute inset-0', side === 'right' && 'left-1/2')}
|
|
style={{ backgroundColor: hsl(tokens.background), ...(side === 'left' ? { width: '50%' } : {}) }}
|
|
>
|
|
<div className="h-2.5 w-full" style={{ backgroundColor: hsl(tokens.card) }} />
|
|
<div className="p-1.5 space-y-1">
|
|
<div className="h-1 w-3/4 rounded-full" style={{ backgroundColor: hsl(tokens.foreground), opacity: 0.6 }} />
|
|
<div className="h-1 w-1/2 rounded-full" style={{ backgroundColor: hsl(tokens.mutedForeground), opacity: 0.4 }} />
|
|
<div className="pt-0.5">
|
|
<div className="h-2 w-8 rounded-sm" style={{ backgroundColor: hsl(tokens.primary) }} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|