Add theme publish dialog, multi-theme management, and user themes in all pickers
- First save prompts 'Publish on Nostr?' with title/description form - My Themes section in builder: list, edit, set active, delete published themes - ThemeSelector grid shows user's published themes alongside presets - Sidebar dropdown shows user themes under 'My Themes' section - Mobile drawer cycles through user's published themes
This commit is contained in:
@@ -33,6 +33,7 @@ import { useLoginActions } from '@/hooks/useLoginActions';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import { useFeedSettings, isBuiltinItem, getBuiltinItem } from '@/hooks/useFeedSettings';
|
||||
import { useHasUnreadNotifications } from '@/hooks/useHasUnreadNotifications';
|
||||
import { useUserThemes } from '@/hooks/useUserThemes';
|
||||
import { EXTRA_KINDS } from '@/lib/extraKinds';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { VerifiedNip05Text } from '@/components/Nip05Badge';
|
||||
@@ -304,11 +305,21 @@ export function LeftSidebar() {
|
||||
? Object.entries(themePresets).find(([, p]) => JSON.stringify(p.tokens) === JSON.stringify(customTheme))
|
||||
: undefined;
|
||||
|
||||
// User's published themes for the dropdown
|
||||
const sidebarUserThemes = useUserThemes(user?.pubkey);
|
||||
|
||||
// Check if current custom theme matches a user-published theme
|
||||
const activeUserTheme = theme === 'custom' && customTheme && !activePreset
|
||||
? sidebarUserThemes.data?.find(t => JSON.stringify(t.tokens) === JSON.stringify(customTheme))
|
||||
: undefined;
|
||||
|
||||
const currentThemeLabel = (() => {
|
||||
if (theme !== 'custom') {
|
||||
return builtinThemeOptions.find(t => t.value === theme)?.label ?? theme;
|
||||
}
|
||||
return activePreset ? activePreset[1].label : 'Custom';
|
||||
if (activePreset) return activePreset[1].label;
|
||||
if (activeUserTheme) return activeUserTheme.title;
|
||||
return 'Custom';
|
||||
})();
|
||||
|
||||
const currentThemeIcon = (() => {
|
||||
@@ -606,8 +617,33 @@ export function LeftSidebar() {
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
{/* Custom theme item — shown when user has a saved custom theme */}
|
||||
{customTheme && !activePreset && (
|
||||
{/* User's published themes */}
|
||||
{sidebarUserThemes.data && sidebarUserThemes.data.length > 0 && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel className="text-[10px] uppercase tracking-wider text-accent/70">My Themes</DropdownMenuLabel>
|
||||
{sidebarUserThemes.data.map((ut) => {
|
||||
const isActive = theme === 'custom' && customTheme && JSON.stringify(customTheme) === JSON.stringify(ut.tokens);
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={ut.identifier}
|
||||
onClick={() => applyCustomTheme(ut.tokens)}
|
||||
className="flex items-center justify-between cursor-pointer"
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Palette className="size-3.5 text-accent shrink-0" />
|
||||
<span className="truncate">{ut.title}</span>
|
||||
</div>
|
||||
{isActive && (
|
||||
<Check className="size-4 text-primary shrink-0" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
{/* Custom theme item — shown when user has a non-preset, non-published custom theme */}
|
||||
{customTheme && !activePreset && !activeUserTheme && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setAccountPopoverOpen(false);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useUserThemes } from '@/hooks/useUserThemes';
|
||||
import { Home, TrendingUp, Bookmark, Settings, LogOut, ChevronDown, ChevronUp, Sun, Moon, Monitor, Clapperboard, BarChart3, Palette, PartyPopper, Radio, FileText, Pencil, GripVertical, X, Plus } from 'lucide-react';
|
||||
import { DndContext, closestCenter, TouchSensor, PointerSensor, useSensor, useSensors, type DragEndEvent } from '@dnd-kit/core';
|
||||
import { SortableContext, verticalListSortingStrategy, useSortable, arrayMove } from '@dnd-kit/sortable';
|
||||
@@ -19,7 +20,7 @@ import { LoginArea } from '@/components/auth/LoginArea';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import type { Theme } from '@/contexts/AppContext';
|
||||
import { themePresets } from '@/themes';
|
||||
import { themePresets, type ThemeTokens } from '@/themes';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/** Map item ID to icon for drawer items. Covers both built-ins and extra-kind routes. */
|
||||
@@ -199,27 +200,41 @@ export function MobileDrawer({ open, onOpenChange }: MobileDrawerProps) {
|
||||
icon: <span className="text-base leading-none">{preset.emoji}</span>,
|
||||
}));
|
||||
|
||||
// Include "Custom" in the cycle if user has a non-preset custom theme
|
||||
// User's published themes for cycling
|
||||
const drawerUserThemes = useUserThemes(user?.pubkey);
|
||||
const userThemeCycle = (drawerUserThemes.data ?? []).map((t) => ({
|
||||
id: `user:${t.identifier}`,
|
||||
label: t.title,
|
||||
icon: <Palette className="size-5" />,
|
||||
tokens: t.tokens,
|
||||
}));
|
||||
|
||||
// Include "Custom" in the cycle if user has a non-preset, non-published custom theme
|
||||
const isCustomNonPreset = theme === 'custom' && customTheme &&
|
||||
!Object.entries(themePresets).some(([, p]) => JSON.stringify(p.tokens) === JSON.stringify(customTheme));
|
||||
!Object.entries(themePresets).some(([, p]) => JSON.stringify(p.tokens) === JSON.stringify(customTheme)) &&
|
||||
!(drawerUserThemes.data ?? []).some(t => JSON.stringify(t.tokens) === JSON.stringify(customTheme));
|
||||
const customCycleEntry = customTheme && isCustomNonPreset
|
||||
? [{ id: 'custom', label: 'Custom', icon: <Palette className="size-5" /> }]
|
||||
? [{ id: 'custom', label: 'Custom', icon: <Palette className="size-5" />, tokens: undefined as ThemeTokens | undefined }]
|
||||
: [];
|
||||
|
||||
const allThemeCycle = [...builtinCycle, ...presetCycle, ...customCycleEntry];
|
||||
const allThemeCycle = [...builtinCycle.map(b => ({ ...b, tokens: undefined as ThemeTokens | undefined })), ...presetCycle.map(p => ({ ...p, tokens: undefined as ThemeTokens | undefined })), ...userThemeCycle, ...customCycleEntry];
|
||||
|
||||
const currentThemeInfo = (() => {
|
||||
if (theme !== 'custom') {
|
||||
return builtinCycle.find(t => t.id === theme) ?? builtinCycle[0];
|
||||
}
|
||||
if (customTheme) {
|
||||
const allMatch = Object.entries(themePresets).find(([, p]) => JSON.stringify(p.tokens) === JSON.stringify(customTheme));
|
||||
if (allMatch) {
|
||||
const [id, preset] = allMatch;
|
||||
// Check presets
|
||||
const presetMatch = Object.entries(themePresets).find(([, p]) => JSON.stringify(p.tokens) === JSON.stringify(customTheme));
|
||||
if (presetMatch) {
|
||||
const [id, preset] = presetMatch;
|
||||
const cycleEntry = presetCycle.find(p => p.id === id);
|
||||
if (cycleEntry) return cycleEntry;
|
||||
return { id, label: preset.label, icon: <span className="text-base leading-none">{preset.emoji}</span> };
|
||||
}
|
||||
// Check user's published themes
|
||||
const userMatch = userThemeCycle.find(t => JSON.stringify(t.tokens) === JSON.stringify(customTheme));
|
||||
if (userMatch) return userMatch;
|
||||
}
|
||||
return { id: 'custom', label: 'Custom', icon: <Palette className="size-5" /> };
|
||||
})();
|
||||
@@ -239,11 +254,14 @@ export function MobileDrawer({ open, onOpenChange }: MobileDrawerProps) {
|
||||
|
||||
if (next.id === 'custom' && customTheme) {
|
||||
applyCustomTheme(customTheme);
|
||||
} else if (next.tokens) {
|
||||
// User-published theme
|
||||
applyCustomTheme(next.tokens);
|
||||
} else {
|
||||
const builtin = builtinCycle.find(b => b.id === next.id);
|
||||
if (builtin) {
|
||||
setTheme(builtin.id);
|
||||
} else {
|
||||
} else if (themePresets[next.id]) {
|
||||
applyCustomTheme(themePresets[next.id].tokens);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ import { Check, Paintbrush, Globe } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { type Theme } from '@/contexts/AppContext';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useUserThemes } from '@/hooks/useUserThemes';
|
||||
import { builtinThemes, themePresets, type ThemeTokens } from '@/themes';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
@@ -74,6 +76,8 @@ function ThemePreviewCard({
|
||||
|
||||
export function ThemeSelector() {
|
||||
const { theme, customTheme, setTheme, applyCustomTheme } = useTheme();
|
||||
const { user } = useCurrentUser();
|
||||
const userThemesQuery = useUserThemes(user?.pubkey);
|
||||
|
||||
const builtinOptions: { id: Theme; label: string }[] = [
|
||||
{ id: 'system', label: 'System' },
|
||||
@@ -230,6 +234,32 @@ export function ThemeSelector() {
|
||||
);
|
||||
})}
|
||||
|
||||
{/* User's published themes */}
|
||||
{userThemesQuery.data?.map((userTheme) => {
|
||||
const isActive = theme === 'custom' && customTheme && JSON.stringify(customTheme) === JSON.stringify(userTheme.tokens);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={`user-${userTheme.identifier}`}
|
||||
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',
|
||||
)}
|
||||
onClick={() => applyCustomTheme(userTheme.tokens)}
|
||||
>
|
||||
<ThemePreviewCard tokens={userTheme.tokens} isActive={!!isActive} />
|
||||
<p className={cn(
|
||||
'mt-1.5 text-xs font-medium text-center transition-colors truncate',
|
||||
isActive ? 'text-foreground' : 'text-muted-foreground',
|
||||
)}>
|
||||
{userTheme.title}
|
||||
</p>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Browse public themes */}
|
||||
<Link
|
||||
to="/themes"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useCallback, useMemo, useEffect } from 'react';
|
||||
import { useSeoMeta } from '@unhead/react';
|
||||
import { ArrowLeft, RotateCcw, Wand2, Download, Upload, Save, Eye, ChevronDown, AlertTriangle, Check, Heart, MessageCircle, Repeat2, Zap, Globe, Users, Flame, MoreHorizontal } from 'lucide-react';
|
||||
import { ArrowLeft, RotateCcw, Wand2, Download, Upload, Save, Eye, ChevronDown, AlertTriangle, Check, Heart, MessageCircle, Repeat2, Zap, Globe, Users, Flame, MoreHorizontal, Pencil, Trash2, Palette, Star } from 'lucide-react';
|
||||
import { Link, useSearchParams } from 'react-router-dom';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
|
||||
@@ -11,6 +11,9 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/component
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from '@/components/ui/dialog';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { ColorPicker } from '@/components/ui/color-picker';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
@@ -19,6 +22,7 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useActiveProfileTheme } from '@/hooks/useActiveProfileTheme';
|
||||
import { usePublishTheme } from '@/hooks/usePublishTheme';
|
||||
import { useUserThemes } from '@/hooks/useUserThemes';
|
||||
import type { ThemeDefinition } from '@/lib/themeEvent';
|
||||
import { builtinThemes, themePresets, type ThemeTokens } from '@/themes';
|
||||
import { hslStringToHex, hexToHslString, deriveTokensFromCore, getContrastRatioHsl } from '@/lib/colorUtils';
|
||||
import { cn, STICKY_HEADER_CLASS } from '@/lib/utils';
|
||||
@@ -89,7 +93,7 @@ export function ThemeBuilderPage() {
|
||||
const { toast } = useToast();
|
||||
const { theme: currentTheme, customTheme: savedCustomTheme, applyCustomTheme } = useTheme();
|
||||
const { user } = useCurrentUser();
|
||||
const { setActiveTheme, isPending: isPublishing } = usePublishTheme();
|
||||
const { publishTheme, setActiveTheme, deleteTheme, isPending: isPublishing } = usePublishTheme();
|
||||
|
||||
// Check if we're importing from a profile
|
||||
const importPubkey = searchParams.get('import');
|
||||
@@ -202,12 +206,18 @@ export function ThemeBuilderPage() {
|
||||
}
|
||||
}, [previewing, currentTheme, savedCustomTheme, tokens, applyCustomTheme]);
|
||||
|
||||
// Save & optionally re-publish active profile theme
|
||||
// Publish dialog state
|
||||
const [publishDialogOpen, setPublishDialogOpen] = useState(false);
|
||||
const [publishTitle, setPublishTitle] = useState('');
|
||||
const [publishDescription, setPublishDescription] = useState('');
|
||||
const [editingTheme, setEditingTheme] = useState<ThemeDefinition | null>(null);
|
||||
|
||||
// Save: apply locally, then prompt to publish if not published yet
|
||||
const handleSave = useCallback(async () => {
|
||||
applyCustomTheme(tokens);
|
||||
setPreviewing(false);
|
||||
|
||||
// If user has a published active profile theme, auto-update it
|
||||
// If user has an active profile theme, auto-update it
|
||||
if (user && hasPublishedTheme) {
|
||||
try {
|
||||
await setActiveTheme({ tokens });
|
||||
@@ -216,11 +226,52 @@ export function ThemeBuilderPage() {
|
||||
console.error('Failed to update active theme:', error);
|
||||
toast({ title: 'Theme saved locally', description: 'Saved but failed to update your profile theme.', variant: 'destructive' });
|
||||
}
|
||||
} else if (user) {
|
||||
// First save: ask if they want to publish
|
||||
setPublishTitle('');
|
||||
setPublishDescription('');
|
||||
setEditingTheme(null);
|
||||
setPublishDialogOpen(true);
|
||||
} else {
|
||||
toast({ title: 'Theme saved', description: 'Your custom theme is now active.' });
|
||||
}
|
||||
}, [tokens, user, hasPublishedTheme, applyCustomTheme, setActiveTheme, toast]);
|
||||
|
||||
// Publish theme as kind 33891
|
||||
const handlePublish = useCallback(async () => {
|
||||
if (!publishTitle.trim()) {
|
||||
toast({ title: 'Title required', description: 'Give your theme a name.', variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const identifier = await publishTheme({
|
||||
tokens,
|
||||
title: publishTitle.trim(),
|
||||
description: publishDescription.trim() || undefined,
|
||||
identifier: editingTheme?.identifier,
|
||||
});
|
||||
setPublishDialogOpen(false);
|
||||
|
||||
// Also set as active profile theme
|
||||
await setActiveTheme({
|
||||
tokens,
|
||||
sourceAuthor: user?.pubkey,
|
||||
sourceIdentifier: identifier,
|
||||
});
|
||||
|
||||
toast({ title: 'Theme published!', description: `"${publishTitle.trim()}" is now live on your profile and in the public feed.` });
|
||||
} catch (error) {
|
||||
console.error('Failed to publish theme:', error);
|
||||
toast({ title: 'Publish failed', description: 'Could not publish your theme.', variant: 'destructive' });
|
||||
}
|
||||
}, [publishTitle, publishDescription, tokens, editingTheme, user, publishTheme, setActiveTheme, toast]);
|
||||
|
||||
// Skip publish — just save locally
|
||||
const handleSkipPublish = useCallback(() => {
|
||||
setPublishDialogOpen(false);
|
||||
toast({ title: 'Theme saved', description: 'Your custom theme is now active locally.' });
|
||||
}, [toast]);
|
||||
|
||||
// Export/import JSON
|
||||
const handleExport = useCallback(() => {
|
||||
const json = JSON.stringify(tokens, null, 2);
|
||||
@@ -419,23 +470,25 @@ export function ThemeBuilderPage() {
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Profile Publishing Status */}
|
||||
{/* My Themes */}
|
||||
{user && (
|
||||
<>
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wider">Profile Sharing</h2>
|
||||
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wider">My Themes</h2>
|
||||
|
||||
{/* Active profile theme status */}
|
||||
<div className="flex items-center justify-between rounded-lg border p-3">
|
||||
<div className="space-y-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe className="size-4 text-primary" />
|
||||
<span className="text-sm font-medium">
|
||||
{hasPublishedTheme ? 'Published to profile' : 'Not shared'}
|
||||
{hasPublishedTheme ? 'Profile theme active' : 'No profile theme'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{hasPublishedTheme
|
||||
? 'Your theme is visible when others visit your profile. Saving will auto-update it.'
|
||||
: 'Enable sharing in Edit Profile to display your theme on your profile.'}
|
||||
? 'Visitors see your theme on your profile. Saving auto-updates it.'
|
||||
: 'Publish a theme to display it on your profile.'}
|
||||
</p>
|
||||
</div>
|
||||
{hasPublishedTheme && (
|
||||
@@ -444,6 +497,57 @@ export function ThemeBuilderPage() {
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Published theme list */}
|
||||
{_userThemes.data && _userThemes.data.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{_userThemes.data.map((theme) => (
|
||||
<ThemeCard
|
||||
key={theme.identifier}
|
||||
theme={theme}
|
||||
isActive={ownActiveTheme.data?.sourceRef?.endsWith(`:${theme.identifier}`) ?? false}
|
||||
onLoadIntoEditor={() => {
|
||||
setTokens(theme.tokens);
|
||||
setAutoDerive(false);
|
||||
toast({ title: 'Theme loaded', description: `"${theme.title}" loaded into the editor.` });
|
||||
}}
|
||||
onSetActive={async () => {
|
||||
try {
|
||||
await setActiveTheme({
|
||||
tokens: theme.tokens,
|
||||
sourceAuthor: user.pubkey,
|
||||
sourceIdentifier: theme.identifier,
|
||||
});
|
||||
applyCustomTheme(theme.tokens);
|
||||
toast({ title: 'Theme activated', description: `"${theme.title}" is now your profile theme.` });
|
||||
} catch {
|
||||
toast({ title: 'Failed', description: 'Could not set as active theme.', variant: 'destructive' });
|
||||
}
|
||||
}}
|
||||
onEditMetadata={() => {
|
||||
setEditingTheme(theme);
|
||||
setPublishTitle(theme.title);
|
||||
setPublishDescription(theme.description || '');
|
||||
setPublishDialogOpen(true);
|
||||
}}
|
||||
onDelete={async () => {
|
||||
try {
|
||||
await deleteTheme(theme.identifier);
|
||||
toast({ title: 'Theme deleted', description: `"${theme.title}" has been removed.` });
|
||||
} catch {
|
||||
toast({ title: 'Failed', description: 'Could not delete theme.', variant: 'destructive' });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{_userThemes.data?.length === 0 && (
|
||||
<p className="text-xs text-muted-foreground italic">
|
||||
No published themes yet. Save a theme above to publish it.
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<Separator />
|
||||
@@ -482,10 +586,159 @@ export function ThemeBuilderPage() {
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* ── Publish Dialog ── */}
|
||||
<Dialog open={publishDialogOpen} onOpenChange={setPublishDialogOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{editingTheme ? 'Edit Theme Details' : 'Publish Theme on Nostr?'}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{editingTheme
|
||||
? 'Update your theme\'s title and description.'
|
||||
: 'Share your theme with the Nostr community. Others can browse, preview, and use it.'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
{/* Color swatch preview */}
|
||||
<div className="flex rounded-lg overflow-hidden h-8">
|
||||
{(['background', 'foreground', 'primary', 'accent'] as const).map((key) => (
|
||||
<div
|
||||
key={key}
|
||||
className="flex-1"
|
||||
style={{ backgroundColor: hexTokens[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 className="flex-col gap-2 sm:flex-row">
|
||||
{!editingTheme && (
|
||||
<Button variant="ghost" onClick={handleSkipPublish} className="sm:mr-auto">
|
||||
Skip
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={handlePublish} disabled={isPublishing || !publishTitle.trim()}>
|
||||
<Palette className="size-4 mr-1.5" />
|
||||
{isPublishing ? 'Publishing...' : editingTheme ? 'Update' : 'Publish'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Theme Card (My Themes list item) ─────────────────────────────────
|
||||
|
||||
interface ThemeCardProps {
|
||||
theme: ThemeDefinition;
|
||||
isActive: boolean;
|
||||
onLoadIntoEditor: () => void;
|
||||
onSetActive: () => void;
|
||||
onEditMetadata: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
function ThemeCard({ theme, isActive, onLoadIntoEditor, onSetActive, onEditMetadata, onDelete }: ThemeCardProps) {
|
||||
const swatches = [
|
||||
theme.tokens.background,
|
||||
theme.tokens.foreground,
|
||||
theme.tokens.primary,
|
||||
theme.tokens.accent,
|
||||
].map((hsl) => {
|
||||
try { return hslStringToHex(hsl); } catch { return '#888'; }
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
'flex items-center gap-3 rounded-lg border p-3 transition-colors',
|
||||
isActive ? 'border-primary/40 bg-primary/5' : 'border-border',
|
||||
)}>
|
||||
{/* Color swatches */}
|
||||
<div className="flex rounded-md overflow-hidden h-8 w-16 shrink-0">
|
||||
{swatches.map((hex, i) => (
|
||||
<div key={i} className="flex-1" style={{ backgroundColor: hex }} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-sm font-medium truncate">{theme.title}</span>
|
||||
{isActive && (
|
||||
<Badge variant="secondary" className="text-[10px] bg-green-500/10 text-green-600 border-green-500/20 shrink-0">
|
||||
Active
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{theme.description && (
|
||||
<p className="text-xs text-muted-foreground truncate">{theme.description}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button onClick={onLoadIntoEditor} className="p-1.5 rounded-md hover:bg-secondary transition-colors text-muted-foreground hover:text-foreground">
|
||||
<Pencil className="size-3.5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Edit in builder</TooltipContent>
|
||||
</Tooltip>
|
||||
{!isActive && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button onClick={onSetActive} className="p-1.5 rounded-md hover:bg-secondary transition-colors text-muted-foreground hover:text-primary">
|
||||
<Star className="size-3.5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Set as profile theme</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button onClick={onEditMetadata} className="p-1.5 rounded-md hover:bg-secondary transition-colors text-muted-foreground hover:text-foreground">
|
||||
<MoreHorizontal className="size-3.5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Edit title & description</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button onClick={onDelete} className="p-1.5 rounded-md hover:bg-secondary transition-colors text-muted-foreground hover:text-destructive">
|
||||
<Trash2 className="size-3.5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Delete theme</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Import from Profile ──────────────────────────────────────────────
|
||||
|
||||
function ImportFromProfile() {
|
||||
|
||||
Reference in New Issue
Block a user