Replace kind 30203 with new multi-theme NIP: kind 33891 (definitions) + kind 11667 (active profile theme)
- Theme definitions are addressable (multiple per user, each with title/description) - Active profile theme is replaceable (one per user, copies tokens from source) - Updated all hooks, components, and feed integration for new kinds - Theme builder preview now shows accent color on streak flame, banner gradient, and repost action
This commit is contained in:
@@ -2,52 +2,94 @@
|
||||
|
||||
## Event Kinds Overview
|
||||
|
||||
| Kind | Name | Description |
|
||||
|-------|---------------|--------------------------------------------------|
|
||||
| 30203 | Profile Theme | Profile appearance and color configuration |
|
||||
| Kind | Name | Description |
|
||||
|-------|----------------------|-------------------------------------------------------|
|
||||
| 33891 | Theme Definition | Shareable, named custom UI theme with colors |
|
||||
| 11667 | Active Profile Theme | The user's currently active theme (one per user) |
|
||||
|
||||
---
|
||||
|
||||
## Kind 30203: Profile Theme
|
||||
## Kind 33891: Theme Definition
|
||||
|
||||
### Summary
|
||||
|
||||
Addressable event kind allowing users to customize their profile appearance with colors and typography. Compatible with the [Yourspace specification](https://gitlab.com/soapbox-pub/yourspace/-/blob/main/NIP.md).
|
||||
Addressable event kind for publishing shareable custom UI themes. A single user may publish multiple themes, each identified by a unique `d` tag. Themes include a title, optional description, and a full set of HSL color tokens.
|
||||
|
||||
### Event Structure
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": 30203,
|
||||
"content": "{\"preset\":\"custom\",\"primaryColor\":\"#7c3aed\",\"accentColor\":\"#7c3aed\",\"backgroundColor\":\"#141727\",\"textColor\":\"#e5e9f0\",\"borderRadius\":\"12\",\"fontFamily\":\"Inter\",\"fontSize\":\"14\",\"effects\":{\"particleEffect\":\"none\",\"particleIntensity\":0,\"particleColor\":\"#7c3aed\",\"hoverAnimation\":\"none\",\"entranceAnimation\":\"none\",\"clickEffect\":\"none\",\"cursorTrail\":\"none\",\"cursorEmoji\":\"\"}}",
|
||||
"kind": 33891,
|
||||
"content": "{\"background\":\"228 20% 10%\",\"foreground\":\"210 40% 98%\",\"card\":\"228 20% 12%\",\"cardForeground\":\"210 40% 98%\",\"popover\":\"228 20% 12%\",\"popoverForeground\":\"210 40% 98%\",\"primary\":\"258 70% 60%\",\"primaryForeground\":\"0 0% 100%\",\"secondary\":\"228 16% 18%\",\"secondaryForeground\":\"210 40% 98%\",\"muted\":\"228 16% 18%\",\"mutedForeground\":\"215 20.2% 65.1%\",\"accent\":\"225 65% 55%\",\"accentForeground\":\"0 0% 100%\",\"destructive\":\"0 72% 51%\",\"destructiveForeground\":\"210 40% 98%\",\"border\":\"228 14% 20%\",\"input\":\"228 14% 20%\",\"ring\":\"258 70% 60%\"}",
|
||||
"tags": [
|
||||
["d", "profile-theme"],
|
||||
["alt", "Profile theme configuration"]
|
||||
["d", "mk-dark-theme"],
|
||||
["title", "MK Dark Theme"],
|
||||
["description", "A sleek dark theme with purple and blue accents"],
|
||||
["alt", "Custom theme: MK Dark Theme"],
|
||||
["t", "theme"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Content Fields
|
||||
### Content
|
||||
|
||||
- **`preset`**: Theme preset name (e.g. "custom", "modern")
|
||||
- **`primaryColor`** (required): Primary color in hex format (#RRGGBB)
|
||||
- **`accentColor`** (required): Accent color in hex format (#RRGGBB)
|
||||
- **`backgroundColor`** (required): Background color in hex format (#RRGGBB)
|
||||
- **`textColor`** (required): Text color in hex format (#RRGGBB)
|
||||
- **`borderRadius`**: Border radius in pixels (as string)
|
||||
- **`fontFamily`**: Font family name
|
||||
- **`fontSize`**: Font size in pixels (as string)
|
||||
- **`effects`**: Visual effects configuration (optional, not currently implemented by this client)
|
||||
JSON object containing the full theme token set. Each value is an HSL color string in the format `"H S% L%"` (e.g. `"258 70% 60%"`).
|
||||
|
||||
**Required fields:** `background`, `foreground`, `primary`, `accent`
|
||||
|
||||
**Optional fields** (clients should derive these if missing): `card`, `cardForeground`, `popover`, `popoverForeground`, `primaryForeground`, `secondary`, `secondaryForeground`, `muted`, `mutedForeground`, `accentForeground`, `destructive`, `destructiveForeground`, `border`, `input`, `ring`
|
||||
|
||||
### Tags
|
||||
|
||||
- **`d`** (required): Set to `"profile-theme"`
|
||||
- **`alt`** (recommended): Human-readable description per NIP-31
|
||||
| Tag | Required | Description |
|
||||
|---------------|----------|----------------------------------------------------------|
|
||||
| `d` | Yes | Unique identifier (slug) for this theme, e.g. `"mk-dark-theme"` |
|
||||
| `title` | Yes | Human-readable theme name |
|
||||
| `description` | No | Brief description of the theme |
|
||||
| `alt` | Yes | NIP-31 human-readable fallback |
|
||||
| `t` | Yes | Set to `"theme"` for discoverability |
|
||||
|
||||
### Multiple Themes Per User
|
||||
|
||||
Since kind 33891 is addressable, a user can publish multiple themes by using different `d` tag values. Publishing a new event with the same `d` tag replaces the previous version (this is how editing works).
|
||||
|
||||
---
|
||||
|
||||
## Kind 11667: Active Profile Theme
|
||||
|
||||
### Summary
|
||||
|
||||
Replaceable event that represents the user's currently active profile theme. Only one per user. When other users visit a profile, they query this kind to determine what theme to display.
|
||||
|
||||
The content is a **copy** of the theme tokens from whichever theme definition (the user's own or someone else's) they have set as active. An `a` tag references the source theme definition for attribution.
|
||||
|
||||
### Event Structure
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": 11667,
|
||||
"content": "{\"background\":\"228 20% 10%\",\"foreground\":\"210 40% 98%\",...}",
|
||||
"tags": [
|
||||
["a", "33891:<source-author-pubkey>:<source-d-tag>"],
|
||||
["alt", "Active profile theme"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Content
|
||||
|
||||
Same JSON format as kind 33891 — a full set of HSL theme tokens.
|
||||
|
||||
### Tags
|
||||
|
||||
| Tag | Required | Description |
|
||||
|-------|----------|-----------------------------------------------------------------|
|
||||
| `a` | No | Reference to the source kind 33891 event (`kind:pubkey:d-tag`). Allows attribution ("Using X's theme"). |
|
||||
| `alt` | Yes | NIP-31 human-readable fallback |
|
||||
|
||||
### Client Behavior
|
||||
|
||||
This client internally uses a 28-token HSL color system for full UI theming. When importing Yourspace themes from other clients, the 4 core colors (primary, accent, background, text) are mapped to the full token set using intelligent derivation based on background luminance. When publishing, the internal tokens are exported to the Yourspace hex color format for interoperability.
|
||||
|
||||
### Interoperability
|
||||
|
||||
Events published by this client are fully compatible with Yourspace and other clients implementing kind 30203. Effects fields are published with `"none"` defaults since this client focuses on color theming only.
|
||||
- When visiting a profile, clients query `{ kinds: [11667], authors: [pubkey], limit: 1 }` to get the active theme.
|
||||
- The `a` tag lets clients display attribution: "Using MK Dark Theme by @mk" with a link to the source theme.
|
||||
- Setting a new active theme publishes a new kind 11667 event (replacing the old one).
|
||||
- To remove the active theme, publish a kind 5 deletion event targeting kind 11667.
|
||||
|
||||
+1
-1
@@ -61,7 +61,7 @@ export function AppRouter() {
|
||||
<Route path="/streams" element={<StreamsFeedPage />} />
|
||||
<Route path="/articles" element={<KindFeedPage kind={30023} title="Articles" icon={<FileText className="size-5" />} />} />
|
||||
<Route path="/decks" element={<KindFeedPage kind={37381} title="Magic Decks" icon={<CardsIcon className="size-5" />} />} />
|
||||
<Route path="/themes" element={<KindFeedPage kind={30203} title="Public Themes" icon={<Sparkles className="size-5" />} emptyMessage="No public themes yet. Be the first to share yours!" />} />
|
||||
<Route path="/themes" element={<KindFeedPage kind={33891} title="Public Themes" icon={<Sparkles className="size-5" />} emptyMessage="No public themes yet. Be the first to share yours!" />} />
|
||||
<Route path="/bookmarks" element={<BookmarksPage />} />
|
||||
|
||||
{/* NIP-19 route for npub1, note1, naddr1, nevent1, nprofile1 */}
|
||||
|
||||
@@ -6,7 +6,8 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import { useProfileTheme, usePublishProfileTheme } from '@/hooks/useProfileTheme';
|
||||
import { useActiveProfileTheme } from '@/hooks/useActiveProfileTheme';
|
||||
import { usePublishTheme } from '@/hooks/usePublishTheme';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Form,
|
||||
@@ -466,19 +467,18 @@ interface ImageUploadFieldProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* Section in Edit Profile for sharing / un-sharing the user's custom theme.
|
||||
* Queries the user's current kind 30203 to detect existing published theme.
|
||||
* Section in Edit Profile for sharing / un-sharing the user's active profile theme.
|
||||
* Queries the user's current kind 11667 to detect existing published theme.
|
||||
*/
|
||||
function ShareThemeSection() {
|
||||
const { user } = useCurrentUser();
|
||||
const { customTheme } = useTheme();
|
||||
const { toast } = useToast();
|
||||
const profileThemeQuery = useProfileTheme(user?.pubkey);
|
||||
const { publish, isPending: isPublishing } = usePublishProfileTheme();
|
||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||
const activeThemeQuery = useActiveProfileTheme(user?.pubkey);
|
||||
const { setActiveTheme, clearActiveTheme, isPending: isPublishing } = usePublishTheme();
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
const isShared = !!profileThemeQuery.data;
|
||||
const isShared = !!activeThemeQuery.data;
|
||||
const hasCustomTheme = !!customTheme;
|
||||
|
||||
const handleToggle = async (share: boolean) => {
|
||||
@@ -490,25 +490,18 @@ function ShareThemeSection() {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await publish(customTheme);
|
||||
profileThemeQuery.refetch();
|
||||
await setActiveTheme({ tokens: customTheme });
|
||||
activeThemeQuery.refetch();
|
||||
toast({ title: 'Theme published', description: 'Your custom theme is now visible on your profile.' });
|
||||
} catch (error) {
|
||||
console.error('Failed to publish theme:', error);
|
||||
toast({ title: 'Failed to publish', description: 'Could not publish your theme.', variant: 'destructive' });
|
||||
}
|
||||
} else {
|
||||
// Delete by publishing a kind 5 deletion event
|
||||
try {
|
||||
setIsDeleting(true);
|
||||
await publishEvent({
|
||||
kind: 5,
|
||||
content: '',
|
||||
tags: [
|
||||
['a', `30203:${user.pubkey}:profile-theme`],
|
||||
],
|
||||
});
|
||||
profileThemeQuery.refetch();
|
||||
await clearActiveTheme();
|
||||
activeThemeQuery.refetch();
|
||||
toast({ title: 'Theme removed', description: 'Your profile theme is no longer shared.' });
|
||||
} catch (error) {
|
||||
console.error('Failed to delete theme:', error);
|
||||
@@ -559,7 +552,7 @@ function ShareThemeSection() {
|
||||
<Switch
|
||||
checked={isShared}
|
||||
onCheckedChange={handleToggle}
|
||||
disabled={!hasCustomTheme || isPublishing || isDeleting || profileThemeQuery.isLoading}
|
||||
disabled={!hasCustomTheme || isPublishing || isDeleting || activeThemeQuery.isLoading}
|
||||
className="scale-90"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -260,8 +260,8 @@ export function NoteCard({ event, className, repostedBy, compact, threaded }: No
|
||||
const vineTitle = isVine ? getTag(event.tags, 'title') : undefined;
|
||||
const hashtags = isVine ? event.tags.filter(([n]) => n === 't').map(([, v]) => v) : [];
|
||||
|
||||
// Profile theme events get a specialized card
|
||||
if (event.kind === 30203) {
|
||||
// Theme definition events get a specialized card
|
||||
if (event.kind === 33891) {
|
||||
return <ThemeUpdateCard event={event} />;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,8 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useProfileUrl } from '@/hooks/useProfileUrl';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { parseYourspaceEvent } from '@/lib/yourspaceTheme';
|
||||
import { parseThemeDefinition } from '@/lib/themeEvent';
|
||||
import { hslStringToHex } from '@/lib/colorUtils';
|
||||
import { timeAgo } from '@/lib/timeAgo';
|
||||
import { EmojifiedText } from '@/components/CustomEmoji';
|
||||
|
||||
@@ -18,8 +19,8 @@ interface ThemeUpdateCardProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a kind 30203 (Profile Theme) event as a visually appealing
|
||||
* card in feeds, showing the user's theme colors as a swatch strip.
|
||||
* Renders a kind 33891 (Theme Definition) event as a visually appealing
|
||||
* card in feeds, showing the theme's colors, title, and description.
|
||||
*/
|
||||
export function ThemeUpdateCard({ event }: ThemeUpdateCardProps) {
|
||||
const author = useAuthor(event.pubkey);
|
||||
@@ -28,15 +29,26 @@ export function ThemeUpdateCard({ event }: ThemeUpdateCardProps) {
|
||||
const displayName = metadata?.name ?? genUserName(event.pubkey);
|
||||
const profileUrl = useProfileUrl(event.pubkey, metadata);
|
||||
|
||||
const themeContent = useMemo(() => parseYourspaceEvent(event), [event]);
|
||||
const theme = useMemo(() => parseThemeDefinition(event), [event]);
|
||||
|
||||
if (!themeContent) return null;
|
||||
if (!theme) return null;
|
||||
|
||||
const colors = [
|
||||
{ label: 'Background', color: themeContent.backgroundColor },
|
||||
{ label: 'Text', color: themeContent.textColor },
|
||||
{ label: 'Primary', color: themeContent.primaryColor },
|
||||
{ label: 'Accent', color: themeContent.accentColor },
|
||||
const { tokens, title, description } = theme;
|
||||
|
||||
// Convert core colors to hex for inline styles
|
||||
const hexColors = {
|
||||
background: safeHex(tokens.background),
|
||||
foreground: safeHex(tokens.foreground),
|
||||
primary: safeHex(tokens.primary),
|
||||
accent: safeHex(tokens.accent),
|
||||
muted: safeHex(tokens.muted || tokens.secondary || tokens.background),
|
||||
};
|
||||
|
||||
const swatchColors = [
|
||||
{ label: 'Background', hex: hexColors.background },
|
||||
{ label: 'Text', hex: hexColors.foreground },
|
||||
{ label: 'Primary', hex: hexColors.primary },
|
||||
{ label: 'Accent', hex: hexColors.accent },
|
||||
];
|
||||
|
||||
const relativeTime = timeAgo(event.created_at);
|
||||
@@ -60,55 +72,57 @@ export function ThemeUpdateCard({ event }: ThemeUpdateCardProps) {
|
||||
<EmojifiedText tags={authorEvent.tags}>{displayName}</EmojifiedText>
|
||||
) : displayName}
|
||||
</Link>
|
||||
<span className="text-muted-foreground text-sm">updated their theme</span>
|
||||
<span className="text-muted-foreground text-sm">shared a theme</span>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">{relativeTime}</span>
|
||||
</div>
|
||||
<Palette className="size-4 text-primary shrink-0" />
|
||||
<Palette className="size-4 text-accent shrink-0" />
|
||||
</div>
|
||||
|
||||
{/* Theme swatch card */}
|
||||
<div
|
||||
className="rounded-xl overflow-hidden border border-border"
|
||||
style={{
|
||||
background: `linear-gradient(135deg, ${themeContent.backgroundColor}ee, ${themeContent.primaryColor}15)`,
|
||||
background: `linear-gradient(135deg, ${hexColors.background}ee, ${hexColors.primary}15)`,
|
||||
}}
|
||||
>
|
||||
{/* Color swatch strip */}
|
||||
<div className="flex h-16">
|
||||
{colors.map(({ label, color }) => (
|
||||
{swatchColors.map(({ label, hex }) => (
|
||||
<Tooltip key={label}>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className="flex-1 transition-all hover:flex-[1.3] cursor-default"
|
||||
style={{ backgroundColor: color }}
|
||||
style={{ backgroundColor: hex }}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="text-xs">
|
||||
<p className="font-medium">{label}</p>
|
||||
<p className="font-mono text-muted-foreground">{color}</p>
|
||||
<p className="font-mono text-muted-foreground">{hex}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Preview bar */}
|
||||
<div className="px-3 py-2.5 flex items-center justify-between" style={{ backgroundColor: themeContent.backgroundColor }}>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Preview bar with title */}
|
||||
<div className="px-3 py-2.5 flex items-center justify-between" style={{ backgroundColor: hexColors.background }}>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<div
|
||||
className="size-6 rounded-full flex items-center justify-center text-[10px] font-bold"
|
||||
style={{ backgroundColor: themeContent.primaryColor, color: themeContent.backgroundColor }}
|
||||
className="size-6 rounded-full flex items-center justify-center text-[10px] font-bold shrink-0"
|
||||
style={{ backgroundColor: hexColors.primary, color: hexColors.background }}
|
||||
>
|
||||
{displayName[0]?.toUpperCase()}
|
||||
</div>
|
||||
<span className="text-xs font-medium" style={{ color: themeContent.textColor }}>
|
||||
{themeContent.preset === 'custom' ? 'Custom Theme' : themeContent.preset ?? 'Custom Theme'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-[10px] font-mono" style={{ color: `${themeContent.textColor}99` }}>
|
||||
{themeContent.primaryColor}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<span className="text-xs font-semibold block truncate" style={{ color: hexColors.foreground }}>
|
||||
{title}
|
||||
</span>
|
||||
{description && (
|
||||
<span className="text-[10px] block truncate" style={{ color: `${hexColors.foreground}99` }}>
|
||||
{description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -120,7 +134,7 @@ export function ThemeUpdateCard({ event }: ThemeUpdateCardProps) {
|
||||
View Profile
|
||||
</Button>
|
||||
</Link>
|
||||
<Link to={`/settings/theme?import=${event.pubkey}`}>
|
||||
<Link to={`/settings/theme?import=${event.pubkey}&theme=${theme.identifier}`}>
|
||||
<Button variant="ghost" size="sm" className="h-8 text-xs text-muted-foreground hover:text-primary">
|
||||
<Copy className="size-3.5 mr-1" />
|
||||
Copy Theme
|
||||
@@ -130,3 +144,12 @@ export function ThemeUpdateCard({ event }: ThemeUpdateCardProps) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Safely convert HSL string to hex, with fallback. */
|
||||
function safeHex(hsl: string): string {
|
||||
try {
|
||||
return hslStringToHex(hsl);
|
||||
} catch {
|
||||
return '#888888';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ export interface FeedSettings {
|
||||
showDecks: boolean;
|
||||
/** Include Magic Decks in the follows/global feed */
|
||||
feedIncludeDecks: boolean;
|
||||
/** Show Profile Themes (kind 30203) link in sidebar (not used — feed-only) */
|
||||
/** Show Custom Themes (kind 33891) link in sidebar (not used — feed-only) */
|
||||
showProfileThemes: boolean;
|
||||
/** Include Profile Theme updates in the follows/global feed */
|
||||
feedIncludeProfileThemes: boolean;
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { ACTIVE_THEME_KIND, parseActiveProfileTheme, type ActiveProfileTheme } from '@/lib/themeEvent';
|
||||
|
||||
/**
|
||||
* Query a user's active profile theme (kind 11667).
|
||||
* This is a replaceable event — only one per user.
|
||||
* Returns the parsed theme tokens + source reference if available.
|
||||
*/
|
||||
export function useActiveProfileTheme(pubkey: string | undefined) {
|
||||
const { nostr } = useNostr();
|
||||
|
||||
return useQuery<ActiveProfileTheme | null>({
|
||||
queryKey: ['activeProfileTheme', pubkey],
|
||||
queryFn: async () => {
|
||||
if (!pubkey) return null;
|
||||
|
||||
const events = await nostr.query(
|
||||
[{
|
||||
kinds: [ACTIVE_THEME_KIND],
|
||||
authors: [pubkey],
|
||||
limit: 1,
|
||||
}],
|
||||
{ signal: AbortSignal.timeout(5000) },
|
||||
);
|
||||
|
||||
if (events.length === 0) return null;
|
||||
return parseActiveProfileTheme(events[0]);
|
||||
},
|
||||
enabled: !!pubkey,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
gcTime: 10 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import type { ThemeTokens } from '@/themes';
|
||||
import { useCurrentUser } from './useCurrentUser';
|
||||
import { useNostrPublish } from './useNostrPublish';
|
||||
import { parseYourspaceEvent, yourspaceToTokens, tokensToYourspace } from '@/lib/yourspaceTheme';
|
||||
|
||||
/**
|
||||
* Query a user's profile theme (kind 30203) and return parsed ThemeTokens.
|
||||
* Returns undefined if the user has no published theme.
|
||||
*/
|
||||
export function useProfileTheme(pubkey: string | undefined) {
|
||||
const { nostr } = useNostr();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['profileTheme', pubkey],
|
||||
queryFn: async () => {
|
||||
if (!pubkey) return null;
|
||||
|
||||
const events = await nostr.query(
|
||||
[{
|
||||
kinds: [30203],
|
||||
authors: [pubkey],
|
||||
'#d': ['profile-theme'],
|
||||
limit: 1,
|
||||
}],
|
||||
{ signal: AbortSignal.timeout(5000) },
|
||||
);
|
||||
|
||||
if (events.length === 0) return null;
|
||||
|
||||
const parsed = parseYourspaceEvent(events[0]);
|
||||
if (!parsed) return null;
|
||||
|
||||
return {
|
||||
tokens: yourspaceToTokens(parsed),
|
||||
yourspace: parsed,
|
||||
event: events[0],
|
||||
};
|
||||
},
|
||||
enabled: !!pubkey,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
gcTime: 10 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to publish the current user's custom theme as a kind 30203 event.
|
||||
*/
|
||||
export function usePublishProfileTheme() {
|
||||
const { user } = useCurrentUser();
|
||||
const { mutateAsync: publishEvent, isPending } = useNostrPublish();
|
||||
|
||||
const publish = async (tokens: ThemeTokens) => {
|
||||
if (!user) throw new Error('Must be logged in to publish theme');
|
||||
|
||||
const yourspaceContent = tokensToYourspace(tokens);
|
||||
|
||||
await publishEvent({
|
||||
kind: 30203,
|
||||
content: JSON.stringify(yourspaceContent),
|
||||
tags: [
|
||||
['d', 'profile-theme'],
|
||||
['alt', 'Profile theme configuration'],
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
return { publish, isPending };
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import type { ThemeTokens } from '@/themes';
|
||||
import { useCurrentUser } from './useCurrentUser';
|
||||
import { useNostrPublish } from './useNostrPublish';
|
||||
import {
|
||||
THEME_DEFINITION_KIND,
|
||||
ACTIVE_THEME_KIND,
|
||||
buildThemeDefinitionTags,
|
||||
buildActiveThemeTags,
|
||||
titleToSlug,
|
||||
} from '@/lib/themeEvent';
|
||||
|
||||
/**
|
||||
* Hook to publish theme-related Nostr events.
|
||||
*
|
||||
* - `publishTheme`: Publish a kind 33891 theme definition (create or update)
|
||||
* - `setActiveTheme`: Publish a kind 11667 active profile theme
|
||||
* - `deleteTheme`: Publish a kind 5 deletion for a theme definition
|
||||
* - `clearActiveTheme`: Publish a kind 5 deletion for the active profile theme
|
||||
*/
|
||||
export function usePublishTheme() {
|
||||
const { user } = useCurrentUser();
|
||||
const { mutateAsync: publishEvent, isPending } = useNostrPublish();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
/** Publish or update a kind 33891 theme definition. */
|
||||
const publishTheme = useCallback(async (opts: {
|
||||
tokens: ThemeTokens;
|
||||
title: string;
|
||||
description?: string;
|
||||
/** Existing identifier to update; if omitted, generates from title */
|
||||
identifier?: string;
|
||||
}) => {
|
||||
if (!user) throw new Error('Must be logged in');
|
||||
|
||||
const identifier = opts.identifier || titleToSlug(opts.title);
|
||||
const tags = buildThemeDefinitionTags(identifier, opts.title, opts.description);
|
||||
|
||||
await publishEvent({
|
||||
kind: THEME_DEFINITION_KIND,
|
||||
content: JSON.stringify(opts.tokens),
|
||||
tags,
|
||||
});
|
||||
|
||||
// Invalidate the user's theme list cache
|
||||
queryClient.invalidateQueries({ queryKey: ['userThemes', user.pubkey] });
|
||||
|
||||
return identifier;
|
||||
}, [user, publishEvent, queryClient]);
|
||||
|
||||
/** Set a theme as the active profile theme (kind 11667). */
|
||||
const setActiveTheme = useCallback(async (opts: {
|
||||
tokens: ThemeTokens;
|
||||
/** Author of the source theme definition */
|
||||
sourceAuthor?: string;
|
||||
/** d-tag of the source theme definition */
|
||||
sourceIdentifier?: string;
|
||||
}) => {
|
||||
if (!user) throw new Error('Must be logged in');
|
||||
|
||||
const tags = buildActiveThemeTags(opts.sourceAuthor, opts.sourceIdentifier);
|
||||
|
||||
await publishEvent({
|
||||
kind: ACTIVE_THEME_KIND,
|
||||
content: JSON.stringify(opts.tokens),
|
||||
tags,
|
||||
});
|
||||
|
||||
queryClient.invalidateQueries({ queryKey: ['activeProfileTheme', user.pubkey] });
|
||||
}, [user, publishEvent, queryClient]);
|
||||
|
||||
/** Delete a kind 33891 theme definition. */
|
||||
const deleteTheme = useCallback(async (identifier: string) => {
|
||||
if (!user) throw new Error('Must be logged in');
|
||||
|
||||
await publishEvent({
|
||||
kind: 5,
|
||||
content: '',
|
||||
tags: [
|
||||
['a', `${THEME_DEFINITION_KIND}:${user.pubkey}:${identifier}`],
|
||||
],
|
||||
});
|
||||
|
||||
queryClient.invalidateQueries({ queryKey: ['userThemes', user.pubkey] });
|
||||
}, [user, publishEvent, queryClient]);
|
||||
|
||||
/** Clear the active profile theme (kind 5 deletion of kind 11667). */
|
||||
const clearActiveTheme = useCallback(async () => {
|
||||
if (!user) throw new Error('Must be logged in');
|
||||
|
||||
await publishEvent({
|
||||
kind: 5,
|
||||
content: '',
|
||||
tags: [
|
||||
['k', String(ACTIVE_THEME_KIND)],
|
||||
],
|
||||
});
|
||||
|
||||
queryClient.invalidateQueries({ queryKey: ['activeProfileTheme', user.pubkey] });
|
||||
}, [user, publishEvent, queryClient]);
|
||||
|
||||
return { publishTheme, setActiveTheme, deleteTheme, clearActiveTheme, isPending };
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { THEME_DEFINITION_KIND, parseThemeDefinition, type ThemeDefinition } from '@/lib/themeEvent';
|
||||
|
||||
/**
|
||||
* Query all kind 33891 theme definitions published by a given user.
|
||||
* Returns an array of parsed ThemeDefinition objects, sorted newest first.
|
||||
*/
|
||||
export function useUserThemes(pubkey: string | undefined) {
|
||||
const { nostr } = useNostr();
|
||||
|
||||
return useQuery<ThemeDefinition[]>({
|
||||
queryKey: ['userThemes', pubkey],
|
||||
queryFn: async () => {
|
||||
if (!pubkey) return [];
|
||||
|
||||
const events = await nostr.query(
|
||||
[{
|
||||
kinds: [THEME_DEFINITION_KIND],
|
||||
authors: [pubkey],
|
||||
limit: 50,
|
||||
}],
|
||||
{ signal: AbortSignal.timeout(5000) },
|
||||
);
|
||||
|
||||
// Deduplicate by d-tag (keep latest per identifier)
|
||||
const byIdentifier = new Map<string, typeof events[number]>();
|
||||
for (const event of events) {
|
||||
const d = event.tags.find(([n]) => n === 'd')?.[1] ?? '';
|
||||
const existing = byIdentifier.get(d);
|
||||
if (!existing || event.created_at > existing.created_at) {
|
||||
byIdentifier.set(d, event);
|
||||
}
|
||||
}
|
||||
|
||||
// Parse and filter invalid
|
||||
const themes: ThemeDefinition[] = [];
|
||||
for (const event of byIdentifier.values()) {
|
||||
const parsed = parseThemeDefinition(event);
|
||||
if (parsed) themes.push(parsed);
|
||||
}
|
||||
|
||||
// Sort newest first
|
||||
return themes.sort((a, b) => b.event.created_at - a.event.created_at);
|
||||
},
|
||||
enabled: !!pubkey,
|
||||
staleTime: 2 * 60 * 1000,
|
||||
gcTime: 10 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
@@ -133,11 +133,11 @@ export const EXTRA_KINDS: ExtraKindDef[] = [
|
||||
},
|
||||
// Social
|
||||
{
|
||||
kind: 30203,
|
||||
kind: 33891,
|
||||
showKey: 'showProfileThemes',
|
||||
feedKey: 'feedIncludeProfileThemes',
|
||||
label: 'Profile Themes',
|
||||
description: 'Custom profile theme updates',
|
||||
label: 'Custom Themes',
|
||||
description: 'Shareable custom UI themes',
|
||||
addressable: true,
|
||||
section: 'social',
|
||||
feedOnly: true,
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
import type { ThemeTokens } from '@/themes';
|
||||
|
||||
// ─── Kind Constants ───────────────────────────────────────────────────
|
||||
|
||||
/** Addressable event: a shareable, named theme definition. Multiple per user. */
|
||||
export const THEME_DEFINITION_KIND = 33891;
|
||||
|
||||
/** Replaceable event: the user's currently active profile theme. One per user. */
|
||||
export const ACTIVE_THEME_KIND = 11667;
|
||||
|
||||
// ─── Theme Definition (Kind 33891) ────────────────────────────────────
|
||||
|
||||
export interface ThemeDefinition {
|
||||
/** The d-tag identifier (slug) */
|
||||
identifier: string;
|
||||
/** Theme title */
|
||||
title: string;
|
||||
/** Optional description */
|
||||
description?: string;
|
||||
/** The full theme token set */
|
||||
tokens: ThemeTokens;
|
||||
/** The original Nostr event */
|
||||
event: NostrEvent;
|
||||
}
|
||||
|
||||
/** All ThemeTokens keys for validation. */
|
||||
const REQUIRED_TOKEN_KEYS: (keyof ThemeTokens)[] = [
|
||||
'background', 'foreground', 'card', 'cardForeground', 'popover', 'popoverForeground',
|
||||
'primary', 'primaryForeground', 'secondary', 'secondaryForeground',
|
||||
'muted', 'mutedForeground', 'accent', 'accentForeground',
|
||||
'destructive', 'destructiveForeground', 'border', 'input', 'ring',
|
||||
];
|
||||
|
||||
/** Parse and validate a kind 33891 theme definition event. Returns null if invalid. */
|
||||
export function parseThemeDefinition(event: NostrEvent): ThemeDefinition | null {
|
||||
if (event.kind !== THEME_DEFINITION_KIND) return null;
|
||||
|
||||
const identifier = event.tags.find(([n]) => n === 'd')?.[1];
|
||||
if (!identifier) return null;
|
||||
|
||||
const title = event.tags.find(([n]) => n === 'title')?.[1];
|
||||
if (!title) return null;
|
||||
|
||||
const description = event.tags.find(([n]) => n === 'description')?.[1];
|
||||
|
||||
try {
|
||||
const tokens = JSON.parse(event.content) as ThemeTokens;
|
||||
|
||||
// Validate that at least the 4 core colors exist
|
||||
if (!tokens.background || !tokens.foreground || !tokens.primary || !tokens.accent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { identifier, title, description, tokens, event };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Create tags for a kind 33891 theme definition event. */
|
||||
export function buildThemeDefinitionTags(
|
||||
identifier: string,
|
||||
title: string,
|
||||
description?: string,
|
||||
): string[][] {
|
||||
const tags: string[][] = [
|
||||
['d', identifier],
|
||||
['title', title],
|
||||
['alt', `Custom theme: ${title}`],
|
||||
['t', 'theme'],
|
||||
];
|
||||
if (description) {
|
||||
tags.push(['description', description]);
|
||||
}
|
||||
return tags;
|
||||
}
|
||||
|
||||
/** Generate a URL-safe slug from a title. */
|
||||
export function titleToSlug(title: string): string {
|
||||
return title
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^a-z0-9\s-]/g, '')
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.slice(0, 64);
|
||||
}
|
||||
|
||||
// ─── Active Profile Theme (Kind 11667) ────────────────────────────────
|
||||
|
||||
export interface ActiveProfileTheme {
|
||||
/** The full theme token set */
|
||||
tokens: ThemeTokens;
|
||||
/** naddr-style reference to the source theme definition, if any */
|
||||
sourceRef?: string;
|
||||
/** The original Nostr event */
|
||||
event: NostrEvent;
|
||||
}
|
||||
|
||||
/** Parse and validate a kind 11667 active profile theme event. Returns null if invalid. */
|
||||
export function parseActiveProfileTheme(event: NostrEvent): ActiveProfileTheme | null {
|
||||
if (event.kind !== ACTIVE_THEME_KIND) return null;
|
||||
|
||||
try {
|
||||
const tokens = JSON.parse(event.content) as ThemeTokens;
|
||||
|
||||
// Validate core colors exist
|
||||
if (!tokens.background || !tokens.foreground || !tokens.primary || !tokens.accent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sourceRef = event.tags.find(([n]) => n === 'a')?.[1];
|
||||
|
||||
return { tokens, sourceRef, event };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Create tags for a kind 11667 active profile theme event. */
|
||||
export function buildActiveThemeTags(
|
||||
sourceAuthor?: string,
|
||||
sourceIdentifier?: string,
|
||||
): string[][] {
|
||||
const tags: string[][] = [
|
||||
['alt', 'Active profile theme'],
|
||||
];
|
||||
if (sourceAuthor && sourceIdentifier) {
|
||||
tags.push(['a', `${THEME_DEFINITION_KIND}:${sourceAuthor}:${sourceIdentifier}`]);
|
||||
}
|
||||
return tags;
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
import type { ThemeTokens } from '@/themes';
|
||||
import { hexToHslString, hslStringToHex, deriveTokensFromCore } from '@/lib/colorUtils';
|
||||
|
||||
// ─── Yourspace (Kind 30203) Schema ────────────────────────────────────
|
||||
|
||||
/** The content JSON of a Yourspace kind 30203 event. */
|
||||
export interface YourspaceThemeContent {
|
||||
preset?: string;
|
||||
primaryColor: string;
|
||||
accentColor: string;
|
||||
backgroundColor: string;
|
||||
textColor: string;
|
||||
borderRadius: string;
|
||||
fontFamily: string;
|
||||
fontSize: string;
|
||||
effects?: {
|
||||
particleEffect?: string;
|
||||
particleIntensity?: number;
|
||||
particleColor?: string;
|
||||
hoverAnimation?: string;
|
||||
entranceAnimation?: string;
|
||||
clickEffect?: string;
|
||||
cursorTrail?: string;
|
||||
cursorEmoji?: string;
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Validation ───────────────────────────────────────────────────────
|
||||
|
||||
const HEX_COLOR = /^#[0-9a-fA-F]{6}$/;
|
||||
|
||||
/** Validate and parse a kind 30203 event. Returns null if invalid. */
|
||||
export function parseYourspaceEvent(event: NostrEvent): YourspaceThemeContent | null {
|
||||
if (event.kind !== 30203) return null;
|
||||
|
||||
const dTag = event.tags.find(([n]) => n === 'd')?.[1];
|
||||
if (dTag !== 'profile-theme') return null;
|
||||
|
||||
try {
|
||||
const content = JSON.parse(event.content) as YourspaceThemeContent;
|
||||
|
||||
// Validate required hex color fields
|
||||
if (
|
||||
!HEX_COLOR.test(content.primaryColor) ||
|
||||
!HEX_COLOR.test(content.accentColor) ||
|
||||
!HEX_COLOR.test(content.backgroundColor) ||
|
||||
!HEX_COLOR.test(content.textColor)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return content;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Conversion: Yourspace → Our Tokens ───────────────────────────────
|
||||
|
||||
/**
|
||||
* Convert a Yourspace theme (hex colors) to our 28-token ThemeTokens format.
|
||||
* Uses deriveTokensFromCore to intelligently generate surface/UI tokens
|
||||
* based on whether the background is dark or light.
|
||||
*/
|
||||
export function yourspaceToTokens(ys: YourspaceThemeContent): ThemeTokens {
|
||||
const background = hexToHslString(ys.backgroundColor);
|
||||
const foreground = hexToHslString(ys.textColor);
|
||||
const primary = hexToHslString(ys.primaryColor);
|
||||
const accent = hexToHslString(ys.accentColor);
|
||||
|
||||
return deriveTokensFromCore(background, foreground, primary, accent);
|
||||
}
|
||||
|
||||
// ─── Conversion: Our Tokens → Yourspace ───────────────────────────────
|
||||
|
||||
/**
|
||||
* Convert our ThemeTokens to Yourspace format for publishing as kind 30203.
|
||||
* Maps core tokens to hex colors. Effects are set to defaults (none).
|
||||
*/
|
||||
export function tokensToYourspace(tokens: ThemeTokens): YourspaceThemeContent {
|
||||
return {
|
||||
preset: 'custom',
|
||||
primaryColor: hslStringToHex(tokens.primary),
|
||||
accentColor: hslStringToHex(tokens.accent),
|
||||
backgroundColor: hslStringToHex(tokens.background),
|
||||
textColor: hslStringToHex(tokens.foreground),
|
||||
borderRadius: '12',
|
||||
fontFamily: 'Inter',
|
||||
fontSize: '14',
|
||||
effects: {
|
||||
particleEffect: 'none',
|
||||
particleIntensity: 0,
|
||||
particleColor: hslStringToHex(tokens.primary),
|
||||
hoverAnimation: 'none',
|
||||
entranceAnimation: 'none',
|
||||
clickEffect: 'none',
|
||||
cursorTrail: 'none',
|
||||
cursorEmoji: '',
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -38,7 +38,7 @@ import { EmojifiedText } from '@/components/CustomEmoji';
|
||||
import { PullToRefresh } from '@/components/PullToRefresh';
|
||||
import { ScopedTheme } from '@/components/ScopedTheme';
|
||||
import type { ThemeTokens } from '@/themes';
|
||||
import { useProfileTheme } from '@/hooks/useProfileTheme';
|
||||
import { useActiveProfileTheme } from '@/hooks/useActiveProfileTheme';
|
||||
import { useFeedSettings } from '@/hooks/useFeedSettings';
|
||||
import { cn, STICKY_HEADER_CLASS } from '@/lib/utils';
|
||||
import type { FeedItem } from '@/lib/feedUtils';
|
||||
@@ -716,10 +716,10 @@ export function ProfilePage() {
|
||||
const isOwnProfile = user?.pubkey === pubkey;
|
||||
const { togglePin } = usePinnedNotes(isOwnProfile ? pubkey : undefined);
|
||||
|
||||
// Profile theme: query the visited user's kind 30203 (if not own profile and enabled)
|
||||
// Profile theme: query the visited user's active profile theme (kind 11667)
|
||||
const { feedSettings } = useFeedSettings();
|
||||
const showCustomProfileThemes = feedSettings.showCustomProfileThemes !== false;
|
||||
const profileThemeQuery = useProfileTheme(
|
||||
const profileThemeQuery = useActiveProfileTheme(
|
||||
!isOwnProfile && showCustomProfileThemes ? pubkey : undefined,
|
||||
);
|
||||
const profileThemeTokens = profileThemeQuery.data?.tokens;
|
||||
|
||||
@@ -16,7 +16,9 @@ import { ColorPicker } from '@/components/ui/color-picker';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useProfileTheme, usePublishProfileTheme } from '@/hooks/useProfileTheme';
|
||||
import { useActiveProfileTheme } from '@/hooks/useActiveProfileTheme';
|
||||
import { usePublishTheme } from '@/hooks/usePublishTheme';
|
||||
import { useUserThemes } from '@/hooks/useUserThemes';
|
||||
import { builtinThemes, themePresets, type ThemeTokens } from '@/themes';
|
||||
import { hslStringToHex, hexToHslString, deriveTokensFromCore, getContrastRatioHsl } from '@/lib/colorUtils';
|
||||
import { cn, STICKY_HEADER_CLASS } from '@/lib/utils';
|
||||
@@ -87,14 +89,18 @@ export function ThemeBuilderPage() {
|
||||
const { toast } = useToast();
|
||||
const { theme: currentTheme, customTheme: savedCustomTheme, applyCustomTheme } = useTheme();
|
||||
const { user } = useCurrentUser();
|
||||
const { publish: publishProfileTheme, isPending: isPublishing } = usePublishProfileTheme();
|
||||
const { setActiveTheme, isPending: isPublishing } = usePublishTheme();
|
||||
|
||||
// Check if we're importing from a profile
|
||||
const importPubkey = searchParams.get('import');
|
||||
const importThemeId = searchParams.get('theme');
|
||||
|
||||
// Check if the user currently has a published profile theme
|
||||
const ownProfileTheme = useProfileTheme(user?.pubkey);
|
||||
const hasPublishedTheme = !!ownProfileTheme.data;
|
||||
// Check if the user currently has a published active profile theme
|
||||
const ownActiveTheme = useActiveProfileTheme(user?.pubkey);
|
||||
const hasPublishedTheme = !!ownActiveTheme.data;
|
||||
|
||||
// User's published theme definitions
|
||||
const _userThemes = useUserThemes(user?.pubkey);
|
||||
|
||||
useSeoMeta({
|
||||
title: 'Theme Builder | Ditto',
|
||||
@@ -112,15 +118,27 @@ export function ThemeBuilderPage() {
|
||||
const [autoDerive, setAutoDerive] = useState(true);
|
||||
const [previewing, setPreviewing] = useState(false);
|
||||
|
||||
// Import from another user's profile theme
|
||||
const importQuery = useProfileTheme(importPubkey ?? undefined);
|
||||
// Import from another user's active profile theme or a specific theme definition
|
||||
const importActiveQuery = useActiveProfileTheme(importPubkey && !importThemeId ? importPubkey : undefined);
|
||||
const importThemesQuery = useUserThemes(importPubkey && importThemeId ? importPubkey : undefined);
|
||||
|
||||
useEffect(() => {
|
||||
if (importQuery.data?.tokens) {
|
||||
setTokens(importQuery.data.tokens);
|
||||
setAutoDerive(false); // imported themes have specific tokens
|
||||
// Import a specific theme by identifier
|
||||
if (importThemeId && importThemesQuery.data) {
|
||||
const target = importThemesQuery.data.find(t => t.identifier === importThemeId);
|
||||
if (target) {
|
||||
setTokens(target.tokens);
|
||||
setAutoDerive(false);
|
||||
toast({ title: 'Theme imported', description: `Imported "${target.title}". Customize it and save!` });
|
||||
}
|
||||
}
|
||||
// Import from active profile theme
|
||||
else if (importActiveQuery.data?.tokens) {
|
||||
setTokens(importActiveQuery.data.tokens);
|
||||
setAutoDerive(false);
|
||||
toast({ title: 'Theme imported', description: 'Imported theme from profile. Customize it and save!' });
|
||||
}
|
||||
}, [importQuery.data]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
}, [importActiveQuery.data, importThemesQuery.data, importThemeId]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Hex representations of current tokens for color pickers
|
||||
const hexTokens = useMemo(() => {
|
||||
@@ -184,24 +202,24 @@ export function ThemeBuilderPage() {
|
||||
}
|
||||
}, [previewing, currentTheme, savedCustomTheme, tokens, applyCustomTheme]);
|
||||
|
||||
// Save & optionally re-publish
|
||||
// Save & optionally re-publish active profile theme
|
||||
const handleSave = useCallback(async () => {
|
||||
applyCustomTheme(tokens);
|
||||
setPreviewing(false);
|
||||
|
||||
// If user has a published profile theme, auto-republish with updated tokens
|
||||
// If user has a published active profile theme, auto-update it
|
||||
if (user && hasPublishedTheme) {
|
||||
try {
|
||||
await publishProfileTheme(tokens);
|
||||
toast({ title: 'Theme saved & published', description: 'Your custom theme is now active and updated on your profile.' });
|
||||
await setActiveTheme({ tokens });
|
||||
toast({ title: 'Theme saved & updated', description: 'Your custom theme is now active and updated on your profile.' });
|
||||
} catch (error) {
|
||||
console.error('Failed to republish theme:', error);
|
||||
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 {
|
||||
toast({ title: 'Theme saved', description: 'Your custom theme is now active.' });
|
||||
}
|
||||
}, [tokens, user, hasPublishedTheme, applyCustomTheme, publishProfileTheme, toast]);
|
||||
}, [tokens, user, hasPublishedTheme, applyCustomTheme, setActiveTheme, toast]);
|
||||
|
||||
// Export/import JSON
|
||||
const handleExport = useCallback(() => {
|
||||
@@ -532,8 +550,13 @@ function ThemePreview({ hexTokens }: { tokens: ThemeTokens; hexTokens: Record<st
|
||||
|
||||
{/* Banner */}
|
||||
<div className="h-32 relative" style={{ backgroundColor: hexTokens.secondary }}>
|
||||
{banner && (
|
||||
{banner ? (
|
||||
<img src={banner} alt="" className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{ background: `linear-gradient(135deg, ${hexTokens.accent}1a, transparent, ${hexTokens.primary}0d)` }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -590,7 +613,7 @@ function ThemePreview({ hexTokens }: { tokens: ThemeTokens; hexTokens: Record<st
|
||||
<span className="text-xs" style={{ color: hexTokens.mutedForeground }}>following</span>
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Flame className="size-3.5" style={{ color: hexTokens.primary }} />
|
||||
<Flame className="size-3.5" style={{ color: hexTokens.accent }} />
|
||||
<span className="text-xs font-bold" style={{ color: hexTokens.foreground }}>7</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user