diff --git a/src/components/LeftSidebar.tsx b/src/components/LeftSidebar.tsx index 7808bd3a..b96b649f 100644 --- a/src/components/LeftSidebar.tsx +++ b/src/components/LeftSidebar.tsx @@ -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() { ); })} - {/* Custom theme item — shown when user has a saved custom theme */} - {customTheme && !activePreset && ( + {/* User's published themes */} + {sidebarUserThemes.data && sidebarUserThemes.data.length > 0 && ( + <> + + My Themes + {sidebarUserThemes.data.map((ut) => { + const isActive = theme === 'custom' && customTheme && JSON.stringify(customTheme) === JSON.stringify(ut.tokens); + return ( + applyCustomTheme(ut.tokens)} + className="flex items-center justify-between cursor-pointer" + > +
+ + {ut.title} +
+ {isActive && ( + + )} +
+ ); + })} + + )} + {/* Custom theme item — shown when user has a non-preset, non-published custom theme */} + {customTheme && !activePreset && !activeUserTheme && ( { setAccountPopoverOpen(false); diff --git a/src/components/MobileDrawer.tsx b/src/components/MobileDrawer.tsx index d5e15430..53191be7 100644 --- a/src/components/MobileDrawer.tsx +++ b/src/components/MobileDrawer.tsx @@ -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: {preset.emoji}, })); - // 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: , + 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: }] + ? [{ id: 'custom', label: 'Custom', icon: , 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: {preset.emoji} }; } + // 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: }; })(); @@ -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); } } diff --git a/src/components/ThemeSelector.tsx b/src/components/ThemeSelector.tsx index 4d1eb416..dfa94967 100644 --- a/src/components/ThemeSelector.tsx +++ b/src/components/ThemeSelector.tsx @@ -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 ( + + ); + })} + {/* Browse public themes */} (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() { - {/* Profile Publishing Status */} + {/* My Themes */} {user && ( <>
-

Profile Sharing

+

My Themes

+ + {/* Active profile theme status */}
- {hasPublishedTheme ? 'Published to profile' : 'Not shared'} + {hasPublishedTheme ? 'Profile theme active' : 'No profile theme'}

{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.'}

{hasPublishedTheme && ( @@ -444,6 +497,57 @@ export function ThemeBuilderPage() { )}
+ + {/* Published theme list */} + {_userThemes.data && _userThemes.data.length > 0 && ( +
+ {_userThemes.data.map((theme) => ( + { + 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' }); + } + }} + /> + ))} +
+ )} + + {_userThemes.data?.length === 0 && ( +

+ No published themes yet. Save a theme above to publish it. +

+ )}
@@ -482,10 +586,159 @@ export function ThemeBuilderPage() { + + {/* ── Publish Dialog ── */} + + + + + {editingTheme ? 'Edit Theme Details' : 'Publish Theme on Nostr?'} + + + {editingTheme + ? 'Update your theme\'s title and description.' + : 'Share your theme with the Nostr community. Others can browse, preview, and use it.'} + + +
+ {/* Color swatch preview */} +
+ {(['background', 'foreground', 'primary', 'accent'] as const).map((key) => ( +
+ ))} +
+
+ + setPublishTitle(e.target.value)} + placeholder="e.g. My Dark Theme" + maxLength={80} + /> +
+
+ +