485 lines
20 KiB
TypeScript
485 lines
20 KiB
TypeScript
'use client';
|
|
import { useState, useCallback } from 'react';
|
|
|
|
// ─── Presets ────────────────────────────────────────────────────────────────
|
|
|
|
const PRESETS = {
|
|
'Classic NYT': {
|
|
'--nui-bg-page': '#F7F4ED',
|
|
'--nui-bg-surface': '#FBF9F4',
|
|
'--nui-text-primary': '#1A1A1A',
|
|
'--nui-text-body': '#22201C',
|
|
'--nui-text-secondary': '#4A4742',
|
|
'--nui-text-muted': '#6E6A63',
|
|
'--nui-rule-hairline': '#C9C2B2',
|
|
'--nui-rule-decorative': '#1A1A1A',
|
|
'--nui-accent-primary': '#7A1F1F',
|
|
'--nui-gutter': '1.5rem',
|
|
'--font-family-masthead': '"Cormorant Garamond", Georgia, serif',
|
|
'--font-family-body': '"Source Serif 4", Georgia, serif',
|
|
'--font-family-meta': '"Inter", system-ui, sans-serif',
|
|
},
|
|
'The Times': {
|
|
'--nui-bg-page': '#F5F2EB',
|
|
'--nui-bg-surface': '#F9F7F2',
|
|
'--nui-text-primary': '#1C1C1C',
|
|
'--nui-text-body': '#2A2520',
|
|
'--nui-text-secondary': '#4A4540',
|
|
'--nui-text-muted': '#706A62',
|
|
'--nui-rule-hairline': '#C8BFB0',
|
|
'--nui-rule-decorative': '#1C1C1C',
|
|
'--nui-accent-primary': '#1B2A4A',
|
|
'--nui-gutter': '1.25rem',
|
|
'--font-family-masthead': '"Cormorant Garamond", Georgia, serif',
|
|
'--font-family-body': '"Source Serif 4", Georgia, serif',
|
|
'--font-family-meta': '"Inter", system-ui, sans-serif',
|
|
},
|
|
'Modern Dark': {
|
|
'--nui-bg-page': '#14110D',
|
|
'--nui-bg-surface': '#1C1812',
|
|
'--nui-text-primary': '#EDE6D6',
|
|
'--nui-text-body': '#DCD4C2',
|
|
'--nui-text-secondary': '#A89F8C',
|
|
'--nui-text-muted': '#857C6A',
|
|
'--nui-rule-hairline': '#3A352C',
|
|
'--nui-rule-decorative': '#EDE6D6',
|
|
'--nui-accent-primary': '#C97A6E',
|
|
'--nui-gutter': '1.5rem',
|
|
'--font-family-masthead': '"Cormorant Garamond", Georgia, serif',
|
|
'--font-family-body': '"Source Serif 4", Georgia, serif',
|
|
'--font-family-meta': '"Inter", system-ui, sans-serif',
|
|
},
|
|
'Swiss Modern': {
|
|
'--nui-bg-page': '#FFFFFF',
|
|
'--nui-bg-surface': '#F5F5F5',
|
|
'--nui-text-primary': '#000000',
|
|
'--nui-text-body': '#111111',
|
|
'--nui-text-secondary': '#555555',
|
|
'--nui-text-muted': '#888888',
|
|
'--nui-rule-hairline': '#CCCCCC',
|
|
'--nui-rule-decorative': '#000000',
|
|
'--nui-accent-primary': '#E63329',
|
|
'--nui-gutter': '2rem',
|
|
'--font-family-masthead': '"Inter", system-ui, sans-serif',
|
|
'--font-family-body': '"Inter", system-ui, sans-serif',
|
|
'--font-family-meta': '"Inter", system-ui, sans-serif',
|
|
},
|
|
} as const;
|
|
|
|
type PresetName = keyof typeof PRESETS;
|
|
type ThemeVars = Record<string, string>;
|
|
|
|
// ─── Controls ───────────────────────────────────────────────────────────────
|
|
|
|
const CONTROLS = [
|
|
{ label: 'Page Background', key: '--nui-bg-page', type: 'color' },
|
|
{ label: 'Primary Text', key: '--nui-text-primary', type: 'color' },
|
|
{ label: 'Body Text', key: '--nui-text-body', type: 'color' },
|
|
{ label: 'Accent Color', key: '--nui-accent-primary', type: 'color' },
|
|
{ label: 'Hairline Rule', key: '--nui-rule-hairline', type: 'color' },
|
|
{ label: 'Gutter', key: '--nui-gutter', type: 'select', options: ['1rem', '1.25rem', '1.5rem', '2rem'] },
|
|
{
|
|
label: 'Masthead Font', key: '--font-family-masthead', type: 'select',
|
|
options: [
|
|
'"Cormorant Garamond", Georgia, serif',
|
|
'"Playfair Display", Georgia, serif',
|
|
'"UnifrakturMaguntia", serif',
|
|
'"Inter", system-ui, sans-serif',
|
|
],
|
|
display: ['Cormorant Garamond', 'Playfair Display', 'UnifrakturMaguntia', 'Inter'],
|
|
},
|
|
{
|
|
label: 'Body Font', key: '--font-family-body', type: 'select',
|
|
options: [
|
|
'"Source Serif 4", Georgia, serif',
|
|
'"Lora", Georgia, serif',
|
|
'"Noto Serif SC", serif',
|
|
'"Inter", system-ui, sans-serif',
|
|
],
|
|
display: ['Source Serif 4', 'Lora', 'Noto Serif SC', 'Inter'],
|
|
},
|
|
] as const;
|
|
|
|
// ─── CSS Generator ──────────────────────────────────────────────────────────
|
|
|
|
function generateCSS(vars: ThemeVars): string {
|
|
const lines = Object.entries(vars).map(([k, v]) => ` ${k}: ${v};`).join('\n');
|
|
return `:root {\n${lines}\n}`;
|
|
}
|
|
|
|
// ─── Component Preview ──────────────────────────────────────────────────────
|
|
|
|
function ComponentPreview({ vars }: { vars: ThemeVars }) {
|
|
const style = Object.fromEntries(
|
|
Object.entries(vars).map(([k, v]) => [k, v])
|
|
) as React.CSSProperties;
|
|
|
|
return (
|
|
<div style={{
|
|
...style,
|
|
background: vars['--nui-bg-page'],
|
|
color: vars['--nui-text-body'],
|
|
fontFamily: vars['--font-family-body'],
|
|
padding: '2rem',
|
|
minHeight: '100%',
|
|
transition: 'all 0.2s ease',
|
|
}}>
|
|
{/* Masthead */}
|
|
<div style={{ marginBottom: '1.5rem' }}>
|
|
<div style={{
|
|
height: '8px',
|
|
background: `linear-gradient(to bottom, ${vars['--nui-rule-decorative']} 0, ${vars['--nui-rule-decorative']} 1px, transparent 1px, transparent 5px, ${vars['--nui-rule-decorative']} 5px, ${vars['--nui-rule-decorative']} 8px)`,
|
|
marginBottom: '0.75rem',
|
|
}} />
|
|
<div style={{
|
|
fontFamily: vars['--font-family-meta'],
|
|
fontSize: '11px',
|
|
color: vars['--nui-text-muted'],
|
|
letterSpacing: '0.15em',
|
|
textAlign: 'center',
|
|
marginBottom: '0.25rem',
|
|
fontVariantCaps: 'small-caps',
|
|
}}>Late City Edition</div>
|
|
<h1 style={{
|
|
fontFamily: vars['--font-family-masthead'],
|
|
fontSize: 'clamp(32px, 5vw, 56px)',
|
|
fontWeight: 700,
|
|
lineHeight: 1,
|
|
letterSpacing: '0.02em',
|
|
color: vars['--nui-text-primary'],
|
|
textAlign: 'center',
|
|
margin: '0 0 0.5rem 0',
|
|
}}>The Daily Chronicle</h1>
|
|
<div style={{
|
|
display: 'flex',
|
|
justifyContent: 'space-between',
|
|
fontSize: '11px',
|
|
color: vars['--nui-text-muted'],
|
|
borderTop: `1px solid ${vars['--nui-rule-hairline']}`,
|
|
paddingTop: '0.4rem',
|
|
fontFamily: vars['--font-family-meta'],
|
|
}}>
|
|
<span>Vol. CXLIX · No. 51,895</span>
|
|
<span>Tuesday, May 20, 2026</span>
|
|
<span>$4.00</span>
|
|
</div>
|
|
<div style={{
|
|
height: '8px',
|
|
background: `linear-gradient(to bottom, ${vars['--nui-rule-decorative']} 0, ${vars['--nui-rule-decorative']} 1px, transparent 1px, transparent 5px, ${vars['--nui-rule-decorative']} 5px, ${vars['--nui-rule-decorative']} 8px)`,
|
|
marginTop: '0.75rem',
|
|
}} />
|
|
</div>
|
|
|
|
{/* Kicker + Headline + Subhead */}
|
|
<div style={{ marginBottom: '1.5rem', borderBottom: `1px solid ${vars['--nui-rule-hairline']}`, paddingBottom: '1.5rem' }}>
|
|
<div style={{
|
|
fontFamily: vars['--font-family-meta'],
|
|
fontSize: '11px',
|
|
fontWeight: 600,
|
|
color: vars['--nui-accent-primary'],
|
|
letterSpacing: '0.08em',
|
|
fontVariantCaps: 'small-caps',
|
|
marginBottom: '0.4rem',
|
|
}}>Capitol · Breaking</div>
|
|
<h2 style={{
|
|
fontFamily: vars['--font-family-masthead'],
|
|
fontSize: 'clamp(24px, 3.5vw, 40px)',
|
|
fontWeight: 600,
|
|
lineHeight: 1.1,
|
|
color: vars['--nui-text-primary'],
|
|
margin: '0 0 0.5rem 0',
|
|
letterSpacing: '-0.01em',
|
|
}}>Historic Accord Reshapes Continental Trade</h2>
|
|
<p style={{
|
|
fontFamily: vars['--font-family-body'],
|
|
fontSize: '15px',
|
|
fontStyle: 'italic',
|
|
lineHeight: 1.4,
|
|
color: vars['--nui-text-secondary'],
|
|
margin: '0 0 0.75rem 0',
|
|
}}>Negotiators emerge with sweeping framework on tariffs, labor, and emissions</p>
|
|
<div style={{
|
|
fontFamily: vars['--font-family-meta'],
|
|
fontSize: '11px',
|
|
fontVariantCaps: 'small-caps',
|
|
letterSpacing: '0.06em',
|
|
color: vars['--nui-text-secondary'],
|
|
}}>By Eleanor Whitcombe · <span style={{ color: vars['--nui-text-muted'] }}>Brussels</span></div>
|
|
</div>
|
|
|
|
{/* BodyText multi-column */}
|
|
<div style={{
|
|
columnCount: 2,
|
|
columnGap: vars['--nui-gutter'],
|
|
columnRule: `1px solid ${vars['--nui-rule-hairline']}`,
|
|
columnFill: 'balance',
|
|
fontFamily: vars['--font-family-body'],
|
|
fontSize: '14px',
|
|
lineHeight: 1.6,
|
|
color: vars['--nui-text-body'],
|
|
marginBottom: '1.5rem',
|
|
}}>
|
|
<p style={{ margin: 0 }}>After eleven consecutive days of negotiation, delegates from twenty-three nations announced a sweeping framework to reorganize commerce across the continent. The accord would harmonize tariff schedules, set common labor standards, and bind signatories to a shared emissions pathway through 2040.</p>
|
|
<p style={{ margin: '0', textIndent: '1em' }}>Officials briefed on the talks said the breakthrough came shortly before midnight, when a dispute over agricultural subsidies was resolved with a side letter granting transitional relief to producers in five smaller economies.</p>
|
|
<p style={{ margin: '0', textIndent: '1em' }}>Markets reacted with measured optimism. The continental composite index closed up 1.2 percent, led by capital-goods makers expected to benefit from infrastructure investment.</p>
|
|
</div>
|
|
|
|
{/* PullQuote */}
|
|
<div style={{
|
|
borderTop: `1px solid ${vars['--nui-rule-hairline']}`,
|
|
borderBottom: `1px solid ${vars['--nui-rule-hairline']}`,
|
|
padding: '1rem 0',
|
|
margin: '0 0 1.5rem 0',
|
|
textAlign: 'center',
|
|
}}>
|
|
<p style={{
|
|
fontFamily: vars['--font-family-masthead'],
|
|
fontSize: 'clamp(18px, 2.5vw, 24px)',
|
|
fontWeight: 600,
|
|
lineHeight: 1.2,
|
|
color: vars['--nui-text-primary'],
|
|
margin: '0 0 0.5rem 0',
|
|
}}>"A long argument that finally became a conversation."</p>
|
|
<div style={{
|
|
fontFamily: vars['--font-family-meta'],
|
|
fontSize: '11px',
|
|
fontVariantCaps: 'small-caps',
|
|
letterSpacing: '0.08em',
|
|
color: vars['--nui-text-muted'],
|
|
}}>— Margarethe Lindqvist, Chief Negotiator</div>
|
|
</div>
|
|
|
|
{/* Rule variants */}
|
|
<div style={{ marginBottom: '1.5rem' }}>
|
|
<div style={{ fontFamily: vars['--font-family-meta'], fontSize: '11px', color: vars['--nui-text-muted'], marginBottom: '0.5rem', fontVariantCaps: 'small-caps', letterSpacing: '0.06em' }}>Rule · hairline</div>
|
|
<hr style={{ border: 0, borderTop: `1px solid ${vars['--nui-rule-hairline']}`, margin: '0 0 0.75rem 0' }} />
|
|
<div style={{ fontFamily: vars['--font-family-meta'], fontSize: '11px', color: vars['--nui-text-muted'], marginBottom: '0.5rem', fontVariantCaps: 'small-caps', letterSpacing: '0.06em' }}>Rule · double</div>
|
|
<div style={{
|
|
height: '6px',
|
|
background: `linear-gradient(to bottom, ${vars['--nui-rule-decorative']} 0 1px, transparent 1px 4px, ${vars['--nui-rule-decorative']} 4px 6px)`,
|
|
marginBottom: '0.75rem',
|
|
}} />
|
|
<div style={{ fontFamily: vars['--font-family-meta'], fontSize: '11px', color: vars['--nui-text-muted'], marginBottom: '0.5rem', fontVariantCaps: 'small-caps', letterSpacing: '0.06em' }}>Rule · thick</div>
|
|
<hr style={{ border: 0, borderTop: `3px solid ${vars['--nui-rule-decorative']}`, margin: 0 }} />
|
|
</div>
|
|
|
|
{/* Caption */}
|
|
<div style={{ borderTop: `1px solid ${vars['--nui-rule-hairline']}`, paddingTop: '0.75rem' }}>
|
|
<p style={{
|
|
fontFamily: vars['--font-family-body'],
|
|
fontSize: '12px',
|
|
fontStyle: 'italic',
|
|
lineHeight: 1.4,
|
|
color: vars['--nui-text-secondary'],
|
|
margin: 0,
|
|
}}>
|
|
Negotiators applaud after the final draft was approved Monday evening.{' '}
|
|
<span style={{
|
|
fontStyle: 'normal',
|
|
fontVariantCaps: 'small-caps',
|
|
letterSpacing: '0.05em',
|
|
color: vars['--nui-text-muted'],
|
|
fontSize: '11px',
|
|
}}>Photograph by Jane Doe / Pool</span>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Main Page ───────────────────────────────────────────────────────────────
|
|
|
|
export default function CreatePage() {
|
|
const [activePreset, setActivePreset] = useState<PresetName>('Classic NYT');
|
|
const [vars, setVars] = useState<ThemeVars>({ ...PRESETS['Classic NYT'] });
|
|
const [copied, setCopied] = useState(false);
|
|
|
|
const applyPreset = useCallback((name: PresetName) => {
|
|
setActivePreset(name);
|
|
setVars({ ...PRESETS[name] });
|
|
}, []);
|
|
|
|
const updateVar = useCallback((key: string, value: string) => {
|
|
setActivePreset('Custom' as PresetName);
|
|
setVars(prev => ({ ...prev, [key]: value }));
|
|
}, []);
|
|
|
|
const css = generateCSS(vars);
|
|
|
|
const copyCSS = () => {
|
|
navigator.clipboard.writeText(css);
|
|
setCopied(true);
|
|
setTimeout(() => setCopied(false), 2000);
|
|
};
|
|
|
|
const downloadCSS = () => {
|
|
const blob = new Blob([css], { type: 'text/css' });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = 'newspaper-theme.css';
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
};
|
|
|
|
const downloadJSON = () => {
|
|
const blob = new Blob([JSON.stringify(vars, null, 2)], { type: 'application/json' });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = 'newspaper-theme.json';
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
};
|
|
|
|
const metaStyle = { fontFamily: 'var(--font-family-meta)', fontSize: '13px' };
|
|
|
|
return (
|
|
<div style={{ display: 'flex', height: 'calc(100vh - 65px)', overflow: 'hidden', background: 'var(--nui-bg-page)' }}>
|
|
|
|
{/* ── Left Panel ── */}
|
|
<aside style={{
|
|
width: '280px',
|
|
flexShrink: 0,
|
|
borderRight: '1px solid var(--nui-rule-hairline)',
|
|
background: 'var(--nui-bg-surface)',
|
|
overflowY: 'auto',
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
}}>
|
|
{/* Header */}
|
|
<div style={{ padding: '1.5rem 1.5rem 1rem', borderBottom: '1px solid var(--nui-rule-hairline)' }}>
|
|
<div style={{ ...metaStyle, fontVariantCaps: 'small-caps', letterSpacing: '0.08em', color: 'var(--nui-accent-primary)', fontWeight: 600, marginBottom: '0.25rem' }}>
|
|
Create Theme
|
|
</div>
|
|
<h2 style={{ fontFamily: 'var(--font-family-display)', fontSize: '20px', fontWeight: 600, margin: 0, color: 'var(--nui-text-primary)' }}>
|
|
Customize
|
|
</h2>
|
|
</div>
|
|
|
|
{/* Presets */}
|
|
<div style={{ padding: '1rem 1.5rem', borderBottom: '1px solid var(--nui-rule-hairline)' }}>
|
|
<div style={{ ...metaStyle, color: 'var(--nui-text-muted)', marginBottom: '0.75rem', fontVariantCaps: 'small-caps', letterSpacing: '0.06em' }}>Presets</div>
|
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.5rem' }}>
|
|
{(Object.keys(PRESETS) as PresetName[]).map(name => (
|
|
<button
|
|
key={name}
|
|
onClick={() => applyPreset(name)}
|
|
style={{
|
|
...metaStyle,
|
|
padding: '0.5rem 0.75rem',
|
|
border: `1px solid ${activePreset === name ? 'var(--nui-rule-decorative)' : 'var(--nui-rule-hairline)'}`,
|
|
background: activePreset === name ? 'var(--nui-text-primary)' : 'transparent',
|
|
color: activePreset === name ? 'var(--nui-bg-page)' : 'var(--nui-text-secondary)',
|
|
cursor: 'pointer',
|
|
textAlign: 'left',
|
|
fontWeight: activePreset === name ? 600 : 400,
|
|
transition: 'all 0.15s',
|
|
}}
|
|
>{name}</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Controls */}
|
|
<div style={{ padding: '1rem 1.5rem', flex: 1 }}>
|
|
<div style={{ ...metaStyle, color: 'var(--nui-text-muted)', marginBottom: '0.75rem', fontVariantCaps: 'small-caps', letterSpacing: '0.06em' }}>Customize</div>
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
|
{CONTROLS.map(ctrl => (
|
|
<div key={ctrl.key}>
|
|
<label style={{ ...metaStyle, color: 'var(--nui-text-secondary)', display: 'block', marginBottom: '0.35rem' }}>
|
|
{ctrl.label}
|
|
</label>
|
|
{ctrl.type === 'color' ? (
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
|
<input
|
|
type="color"
|
|
value={vars[ctrl.key] || '#000000'}
|
|
onChange={e => updateVar(ctrl.key, e.target.value)}
|
|
style={{ width: '32px', height: '32px', border: '1px solid var(--nui-rule-hairline)', cursor: 'pointer', padding: '2px', background: 'none' }}
|
|
/>
|
|
<span style={{ ...metaStyle, color: 'var(--nui-text-muted)', fontFamily: 'monospace', fontSize: '12px' }}>
|
|
{vars[ctrl.key]}
|
|
</span>
|
|
</div>
|
|
) : (
|
|
<select
|
|
value={vars[ctrl.key]}
|
|
onChange={e => updateVar(ctrl.key, e.target.value)}
|
|
style={{
|
|
...metaStyle,
|
|
width: '100%',
|
|
padding: '0.4rem 0.5rem',
|
|
border: '1px solid var(--nui-rule-hairline)',
|
|
background: 'var(--nui-bg-page)',
|
|
color: 'var(--nui-text-body)',
|
|
cursor: 'pointer',
|
|
}}
|
|
>
|
|
{ctrl.options.map((opt, i) => (
|
|
<option key={opt} value={opt}>
|
|
{'display' in ctrl ? ctrl.display[i] : opt}
|
|
</option>
|
|
))}
|
|
</select>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Export */}
|
|
<div style={{ padding: '1rem 1.5rem', borderTop: '1px solid var(--nui-rule-hairline)', display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
|
<button
|
|
onClick={copyCSS}
|
|
style={{
|
|
...metaStyle,
|
|
padding: '0.6rem 1rem',
|
|
background: 'var(--nui-text-primary)',
|
|
color: 'var(--nui-bg-page)',
|
|
border: 'none',
|
|
cursor: 'pointer',
|
|
fontWeight: 600,
|
|
fontVariantCaps: 'small-caps',
|
|
letterSpacing: '0.06em',
|
|
}}
|
|
>
|
|
{copied ? '✓ Copied!' : 'Copy CSS'}
|
|
</button>
|
|
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
|
<button
|
|
onClick={downloadCSS}
|
|
style={{
|
|
...metaStyle,
|
|
flex: 1,
|
|
padding: '0.5rem',
|
|
background: 'transparent',
|
|
color: 'var(--nui-text-secondary)',
|
|
border: '1px solid var(--nui-rule-hairline)',
|
|
cursor: 'pointer',
|
|
}}
|
|
>↓ .css</button>
|
|
<button
|
|
onClick={downloadJSON}
|
|
style={{
|
|
...metaStyle,
|
|
flex: 1,
|
|
padding: '0.5rem',
|
|
background: 'transparent',
|
|
color: 'var(--nui-text-secondary)',
|
|
border: '1px solid var(--nui-rule-hairline)',
|
|
cursor: 'pointer',
|
|
}}
|
|
>↓ .json</button>
|
|
</div>
|
|
</div>
|
|
</aside>
|
|
|
|
{/* ── Right Preview ── */}
|
|
<main style={{ flex: 1, overflowY: 'auto' }}>
|
|
<ComponentPreview vars={vars} />
|
|
</main>
|
|
</div>
|
|
);
|
|
}
|