Files
eranos/src/components/ThemeContent.tsx
T
Alex Gleason c9e447ff13 Add title font support for profile display name
Implement the title font feature across the full stack:

- Type system: Add titleFont to ThemeConfig, ThemePreset, ThemeDefinition,
  ActiveProfileTheme interfaces and Zod schema
- Event encoding: Update f-tag build/parse to emit role markers (body/title)
  with backward-compatible parsing of legacy roleless tags
- Font loading: Add loadAndApplyTitleFont with --title-font-family CSS var
- UI: Add Title Font picker to theme builder dialog and profile theme editor,
  with configurable label prop on FontPicker
- Profile rendering: Apply --title-font-family to the display name h2
- Data flow: Thread titleFont through ThemeSelector snapshots, theme
  presets, publish/update, NostrSync, ThemeContent, and diff detection
2026-03-25 13:37:29 -05:00

195 lines
6.4 KiB
TypeScript

import React, { useMemo, useCallback, useRef } from 'react';
import { Link } from 'react-router-dom';
import { Pencil } from 'lucide-react';
import type { NostrEvent } from '@nostrify/nostrify';
import { Button } from '@/components/ui/button';
import { ToastAction } from '@/components/ui/toast';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useTheme } from '@/hooks/useTheme';
import { toast } from '@/hooks/useToast';
import { parseThemeDefinition, parseActiveProfileTheme, THEME_DEFINITION_KIND, ACTIVE_THEME_KIND } from '@/lib/themeEvent';
import { type Theme } from '@/contexts/AppContext';
import { coreToTokens, type CoreThemeColors, type ThemeConfig } from '@/themes';
interface ThemeContentProps {
event: NostrEvent;
}
/** Extracts HSL color string from a theme token value like "258 70% 55%" */
function hsl(value: string): string {
return `hsl(${value})`;
}
/**
* Renders the inline theme preview content for kind 36767 (Theme Definition)
* and kind 16767 (Active Profile Theme) events within NoteCard.
* Uses the same mini-mockup design as ThemeSelector, scaled up.
*/
export function ThemeContent({ event }: ThemeContentProps) {
const { user } = useCurrentUser();
const { theme, customTheme, setTheme, applyCustomTheme } = useTheme();
const isOwn = user?.pubkey === event.pubkey;
const parsed = useMemo(() => {
if (event.kind === THEME_DEFINITION_KIND) {
return parseThemeDefinition(event);
}
if (event.kind === ACTIVE_THEME_KIND) {
const active = parseActiveProfileTheme(event);
if (!active) return null;
const title = event.tags.find(([n]) => n === 'title')?.[1];
return {
colors: active.colors,
title: title ?? 'Profile Theme',
description: undefined as string | undefined,
identifier: undefined as string | undefined,
background: active.background,
font: active.font,
titleFont: active.titleFont,
sourceRef: active.sourceRef,
};
}
return null;
}, [event]);
const previousThemeRef = useRef<{ mode: Theme; config?: ThemeConfig }>();
/** Apply the theme directly when clicked. */
const handleApplyTheme = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
e.preventDefault();
if (!parsed) return;
// Snapshot current theme so we can undo
previousThemeRef.current = { mode: theme, config: customTheme };
const themeConfig: ThemeConfig = {
colors: parsed.colors,
title: parsed.title,
font: parsed.font,
titleFont: parsed.titleFont,
background: parsed.background,
};
applyCustomTheme(themeConfig);
const undo = () => {
const prev = previousThemeRef.current;
if (!prev) return;
if (prev.mode === 'custom' && prev.config) {
applyCustomTheme(prev.config);
} else {
setTheme(prev.mode);
}
};
toast({
title: 'Theme applied',
description: `"${parsed.title}" is now your active theme.`,
action: <ToastAction altText="Undo theme change" onClick={undo}>Undo</ToastAction>,
});
}, [parsed, theme, customTheme, applyCustomTheme, setTheme]);
if (!parsed) return null;
const { colors, title, description } = parsed;
const backgroundUrl = parsed.background?.url;
const isDefinition = event.kind === THEME_DEFINITION_KIND;
const identifier = isDefinition ? (parsed as { identifier?: string }).identifier : undefined;
return (
<div className="mt-2 space-y-2">
<button
type="button"
className="w-full text-left cursor-pointer transition-opacity hover:opacity-90 active:opacity-75"
onClick={handleApplyTheme}
>
<ThemeMockup colors={colors} title={title} description={description} backgroundUrl={backgroundUrl} />
</button>
{/* Actions — only Edit for own theme definitions */}
{isDefinition && identifier && isOwn && (
<div className="flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
<Link to="/themes">
<Button variant="ghost" size="sm" className="h-8 text-xs text-muted-foreground hover:text-accent">
<Pencil className="size-3.5 mr-1" />
Edit Theme
</Button>
</Link>
</div>
)}
</div>
);
}
/**
* Scaled-up version of the ThemePreviewCard mini-mockup from ThemeSelector.
* Same proportions (4:3 aspect ratio, simulated header/content/sidebar),
* with sizes multiplied ~4x so it reads well in a feed card.
* Includes title and optional description below the mockup.
*/
function ThemeMockup({
colors,
title,
description,
backgroundUrl,
}: {
colors: CoreThemeColors;
title: string;
description?: string;
backgroundUrl?: string;
}) {
const tokens = useMemo(() => coreToTokens(colors), [colors]);
return (
<div className="rounded-xl overflow-hidden border border-border">
{/* Scaled mockup — same 4:3 aspect, elements ~4x the ThemeSelector sizes */}
<div
className="aspect-[4/3] relative"
style={{ backgroundColor: hsl(tokens.background) }}
>
{/* Background image layer */}
{backgroundUrl && (
<img
src={backgroundUrl}
alt=""
className="absolute inset-0 w-full h-full object-cover opacity-40"
/>
)}
{/* Content preview area */}
<div className="p-6 pt-10 space-y-4 relative">
{/* Simulated text lines */}
<div
className="h-4 w-3/4 rounded-full"
style={{ backgroundColor: hsl(tokens.foreground), opacity: 0.6 }}
/>
<div
className="h-4 w-1/2 rounded-full"
style={{ backgroundColor: hsl(tokens.mutedForeground), opacity: 0.4 }}
/>
{/* Simulated button */}
<div className="pt-2">
<div
className="h-8 w-32 rounded"
style={{ backgroundColor: hsl(tokens.primary) }}
/>
</div>
</div>
</div>
{/* Title + description bar */}
<div className="px-3 py-2.5" style={{ backgroundColor: hsl(tokens.card) }}>
<span className="text-sm font-semibold block truncate" style={{ color: hsl(tokens.foreground) }}>
{title}
</span>
{description && (
<span className="text-xs block truncate mt-0.5" style={{ color: hsl(tokens.mutedForeground) }}>
{description}
</span>
)}
</div>
</div>
);
}