diff --git a/v2/operator-app/index.html b/v2/operator-app/index.html index cc9117c..82da635 100644 --- a/v2/operator-app/index.html +++ b/v2/operator-app/index.html @@ -10,6 +10,11 @@ href="https://fonts.googleapis.com/css2?family=Montserrat:wght@300;400;500;600;700&display=swap" rel="stylesheet" /> + +
diff --git a/v2/operator-app/src/App.tsx b/v2/operator-app/src/App.tsx index 5151a93..6bc01fd 100644 --- a/v2/operator-app/src/App.tsx +++ b/v2/operator-app/src/App.tsx @@ -7,6 +7,7 @@ import BriefsList from './routes/briefs/list'; import BriefNew from './routes/briefs/new'; import BriefDetail from './routes/briefs/detail'; import BriefEdit from './routes/briefs/edit'; +import BriefTheme from './routes/briefs/theme'; import ReportDetail from './routes/reports/detail'; import TeamsList from './routes/teams/list'; import TeamDetail from './routes/teams/detail'; @@ -31,6 +32,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/v2/operator-app/src/components/ThemeEditor.tsx b/v2/operator-app/src/components/ThemeEditor.tsx index 1a8ca99..a97873a 100644 --- a/v2/operator-app/src/components/ThemeEditor.tsx +++ b/v2/operator-app/src/components/ThemeEditor.tsx @@ -33,7 +33,7 @@ const BACKGROUNDS: { key: BriefTheme['background']; label: string; bg: string; i { key: 'ink', label: 'Ink (dark)', bg: '#1a1614', ink: '#f5f0e6' }, ]; -const DEFAULT_THEME: BriefTheme = { +export const DEFAULT_THEME: BriefTheme = { accent_hex: '#c2602a', heading_font: 'fraunces', background: 'cream', @@ -42,9 +42,11 @@ const DEFAULT_THEME: BriefTheme = { export interface ThemeEditorProps { briefId: string; initialTheme: BriefTheme | null; + /** Fires on every tweak BEFORE save — lets a sibling preview track live. */ + onPreview?: (theme: BriefTheme) => void; } -export function ThemeEditor({ briefId, initialTheme }: ThemeEditorProps) { +export function ThemeEditor({ briefId, initialTheme, onPreview }: ThemeEditorProps) { const update = useUpdateBriefTheme(briefId); const reset = useResetBriefTheme(briefId); const [theme, setTheme] = useState(initialTheme ?? DEFAULT_THEME); @@ -56,6 +58,11 @@ export function ThemeEditor({ briefId, initialTheme }: ThemeEditorProps) { if (initialTheme) setTheme(initialTheme); }, [initialTheme]); + // Push every tweak to a sibling preview pane. + useEffect(() => { + onPreview?.(theme); + }, [theme, onPreview]); + function patch(partial: Partial) { setTheme((cur) => ({ ...cur, ...partial })); } diff --git a/v2/operator-app/src/components/ThemePreview.tsx b/v2/operator-app/src/components/ThemePreview.tsx new file mode 100644 index 0000000..09ce93b --- /dev/null +++ b/v2/operator-app/src/components/ThemePreview.tsx @@ -0,0 +1,218 @@ +// Live preview pane for the theme editor. Renders a small slice of what the +// per-report dashboard will look like (topbar with agency name, KPI tile, +// leaderboard-style row with format dot + bar, sample trend card with truth +// quote), all styled by the picked theme. +// +// Implementation: scope the CSS custom properties to a single wrapper div +// (style={{ ...vars }}) so changing the picker doesn't bleed into the +// operator app's chrome. Fonts come from the same Google Fonts link tag the +// dashboard SPA uses — Inter + Fraunces + Playfair + Space Grotesk are all +// loaded into the operator app via the index.html below. + +import type { BriefTheme } from '../api/briefs'; + +const FONT_STACKS: Record = { + fraunces: '"Fraunces", Georgia, serif', + playfair: '"Playfair Display", Georgia, serif', + inter: '"Inter", system-ui, sans-serif', + 'space-grotesk': '"Space Grotesk", system-ui, sans-serif', +}; + +interface BgPalette { + bg: string; + paper: string; + paperSoft: string; + ink: string; + ink2: string; + ink3: string; + muted: string; + line: string; +} + +const BG_PALETTES: Record = { + cream: { + bg: '#f5f0e6', paper: '#fbf7ef', paperSoft: '#f0e9dc', + ink: '#1a1614', ink2: '#4a3f37', ink3: '#74675c', + muted: '#9b8d80', line: '#e2d9c8', + }, + paper: { + bg: '#fbf7ef', paper: '#ffffff', paperSoft: '#f6f1e6', + ink: '#1a1614', ink2: '#4a3f37', ink3: '#74675c', + muted: '#9b8d80', line: '#e2d9c8', + }, + ink: { + bg: '#1a1614', paper: '#22201d', paperSoft: '#2c2924', + ink: '#f5f0e6', ink2: '#d8cdb6', ink3: '#9b8d80', + muted: '#74675c', line: '#3a342d', + }, +}; + +function deriveAccent2(hex: string): string { + // Cheap HSL-darken; keep it independent from the pipeline's helper so the + // preview doesn't need to round-trip through the server. + const m = /^#?([0-9a-f]{6})$/i.exec(hex); + if (!m) return hex; + const n = parseInt(m[1]!, 16); + const r = (n >> 16) & 0xff, g = (n >> 8) & 0xff, b = n & 0xff; + const dim = (c: number) => Math.max(0, Math.round(c * 0.7)); + const c = (x: number) => x.toString(16).padStart(2, '0'); + return `#${c(dim(r))}${c(dim(g))}${c(dim(b))}`; +} + +export function ThemePreview({ theme }: { theme: BriefTheme }) { + const palette = BG_PALETTES[theme.background] ?? BG_PALETTES.cream; + const fontStack = FONT_STACKS[theme.heading_font] ?? FONT_STACKS.fraunces; + const accent = /^#[0-9a-f]{6}$/i.test(theme.accent_hex) ? theme.accent_hex : '#c2602a'; + const accent2 = theme.accent_2_hex && /^#[0-9a-f]{6}$/i.test(theme.accent_2_hex) + ? theme.accent_2_hex + : deriveAccent2(accent); + + const wrap: React.CSSProperties = { + background: palette.bg, + color: palette.ink, + fontFamily: '"Inter", system-ui, sans-serif', + padding: 16, + borderRadius: 8, + border: `1px solid ${palette.line}`, + }; + const card: React.CSSProperties = { + background: palette.paper, + border: `1px solid ${palette.line}`, + borderRadius: 8, + padding: 14, + }; + + return ( +
+ {/* Topbar */} +
+
+ {theme.agency_name || 'Social Listening'} +
+
+ The Branded Glass Moment +
+
+ What are the cultural moments emerging in beauty on TikTok right now? +
+
+ + {/* KPI tile row */} +
+ {[ + ['Trends', '47'], + ['Plays', '566M'], + ['Avg STL', '4.3%'], + ].map(([k, v]) => ( +
+
{k}
+
{v}
+
+ ))} +
+ + {/* Leaderboard row sample */} +
+
+ + Top by plays + + + Lane Leaderboard + +
+
+ 01 +
+
The Ceremonial Hair Wash
+
+ + routine · 84 vids +
+
+
+
+
+ 566M +
+
+ + {/* Sample trend card */} +
+
+ + big anchor + + + + routine + + Hair Rituals +
+
+ The Ceremonial Hair Wash +
+
+ "The bathroom is the only room where no one can interrupt you." +
+
+ 84 vids·566M·STL 4.1% +
+
+ + {/* Button sample */} +
+ + + + accent_2 swatch + +
+
+ ); +} diff --git a/v2/operator-app/src/routes/briefs/detail.tsx b/v2/operator-app/src/routes/briefs/detail.tsx index d099c9d..b71f4ad 100644 --- a/v2/operator-app/src/routes/briefs/detail.tsx +++ b/v2/operator-app/src/routes/briefs/detail.tsx @@ -228,7 +228,14 @@ function DetailBody({ brief, canDelete, canRun, onDelete, deleting }: {

slug: {brief.slug}

-
+
+ + Theme & branding + + {id && ( + + Theme & branding ↗ + + )}
- - {id && ( - - )}
); } diff --git a/v2/operator-app/src/routes/briefs/list.tsx b/v2/operator-app/src/routes/briefs/list.tsx index eb35aba..b9fb53e 100644 --- a/v2/operator-app/src/routes/briefs/list.tsx +++ b/v2/operator-app/src/routes/briefs/list.tsx @@ -47,14 +47,30 @@ function BriefRow({ brief, canDelete }: { brief: BriefSummary; canDelete: boolea

-
{formatBudget(brief.budget_usd)} / {brief.date_window_days}d
+
+ {brief.theme && ( + + )} + {formatBudget(brief.budget_usd)} / {brief.date_window_days}d +
{formatDate(brief.created_at)}
- {canDelete && ( -
- {err && {err}} +
+ {err && {err}} + + {brief.theme ? 'Edit theme' : 'Add theme'} + + {canDelete && ( -
- )} + )} +
); } diff --git a/v2/operator-app/src/routes/briefs/theme.tsx b/v2/operator-app/src/routes/briefs/theme.tsx new file mode 100644 index 0000000..4fa510e --- /dev/null +++ b/v2/operator-app/src/routes/briefs/theme.tsx @@ -0,0 +1,73 @@ +// Dedicated brief theme & branding tool. The same ThemeEditor that lives on +// the brief edit page, but with a live preview pane to its right and proper +// page chrome (title, breadcrumb, back-to-brief link) so it feels like a +// real edit tool. +// +// Path: /briefs/:id/theme — linked from the brief detail page header, +// brief edit page, and brief list rows. + +import { useState } from 'react'; +import { Link, useParams } from 'react-router-dom'; +import { useBrief, type BriefTheme } from '../../api/briefs'; +import { ThemeEditor, DEFAULT_THEME } from '../../components/ThemeEditor'; +import { ThemePreview } from '../../components/ThemePreview'; + +export default function BriefThemeRoute() { + const { id } = useParams(); + const { data, isLoading, error } = useBrief(id); + const [livePreview, setLivePreview] = useState( + data?.brief.theme ?? DEFAULT_THEME, + ); + + if (isLoading) return
Loading…
; + if (error || !data?.brief) { + return
Could not load brief.
; + } + const brief = data.brief; + + return ( +
+
+
+
+ Briefs + / + {brief.client_name} + / + Theme & branding +
+

Theme & branding

+

+ Pick the look the per-report dashboard renders for {brief.client_name}. + Reports rebuilt after a change pick up the new theme. +

+
+ + Back to brief + +
+ +
+
+ +
+
+
Live preview
+ +

+ This pane updates live as you tweak the controls — it's a slice of what the per-report + dashboard will render. Changes only persist once you click Save theme; + existing finished reports keep their old theme until rebuilt. +

+
+
+
+ ); +}