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:
Mary Kate Fain
2026-02-25 16:15:46 -06:00
parent 096096f685
commit c23d91b2fb
4 changed files with 358 additions and 21 deletions
+39 -3
View File
@@ -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);
+27 -9
View File
@@ -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);
}
}
+30
View File
@@ -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"
+262 -9
View File
@@ -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() {