diff options
Diffstat (limited to 'src/components')
| -rw-r--r-- | src/components/BrandDoc.tsx | 518 | ||||
| -rw-r--r-- | src/components/CopyButton.tsx | 31 | ||||
| -rw-r--r-- | src/components/InputPanel.tsx | 133 | ||||
| -rw-r--r-- | src/components/PackageSwitcher.tsx | 125 | ||||
| -rw-r--r-- | src/components/PreviewPanel.tsx | 116 | ||||
| -rw-r--r-- | src/components/SettingsPanel.tsx | 158 | ||||
| -rw-r--r-- | src/components/TokenInput.tsx | 85 |
7 files changed, 1166 insertions, 0 deletions
diff --git a/src/components/BrandDoc.tsx b/src/components/BrandDoc.tsx new file mode 100644 index 0000000..fffea97 --- /dev/null +++ b/src/components/BrandDoc.tsx @@ -0,0 +1,518 @@ +import { useRef, useState } from 'react'; +import type { BrandInputs, BrandOutputs, ColorSwatch, LockedSections, Typography } from '../types'; +import { CopyButton } from './CopyButton'; + +// ── Editable text ───────────────────────────────────────────────────────────── + +interface EditableProps { + value: string; + onChange: (v: string) => void; + multiline?: boolean; +} + +function Editable({ value, onChange, multiline = false }: EditableProps) { + const [editing, setEditing] = useState(false); + const [draft, setDraft] = useState(value); + const ref = useRef<HTMLTextAreaElement | HTMLInputElement>(null); + + const commit = () => { + setEditing(false); + if (draft !== value) onChange(draft); + }; + + if (!editing) { + return ( + <div + className="editable-text" + onClick={() => { setDraft(value); setEditing(true); }} + title="Click to edit" + style={{ whiteSpace: multiline ? 'pre-wrap' : 'normal', padding: '2px 4px', margin: '-2px -4px' }} + > + {value} + </div> + ); + } + + if (multiline) { + return ( + <textarea + ref={ref as React.RefObject<HTMLTextAreaElement>} + autoFocus + className="editable-text editing" + value={draft} + onChange={e => setDraft(e.target.value)} + onBlur={commit} + onKeyDown={e => { if (e.key === 'Escape') { setEditing(false); setDraft(value); } }} + style={{ + width: '100%', + resize: 'vertical', + minHeight: 80, + padding: '4px 6px', + fontFamily: 'inherit', + fontSize: 'inherit', + lineHeight: 1.65, + background: 'var(--bg-2)', + border: '1px solid var(--border-3)', + borderRadius: 3, + color: 'var(--text)', + outline: 'none', + }} + /> + ); + } + + return ( + <input + ref={ref as React.RefObject<HTMLInputElement>} + autoFocus + className="editable-text editing" + value={draft} + onChange={e => setDraft(e.target.value)} + onBlur={commit} + onKeyDown={e => { + if (e.key === 'Enter') commit(); + if (e.key === 'Escape') { setEditing(false); setDraft(value); } + }} + style={{ + width: '100%', + padding: '2px 6px', + fontFamily: 'inherit', + fontSize: 'inherit', + background: 'var(--bg-2)', + border: '1px solid var(--border-3)', + borderRadius: 3, + color: 'var(--text)', + outline: 'none', + }} + /> + ); +} + +// ── Section wrapper ─────────────────────────────────────────────────────────── + +interface SectionProps { + title: string; + locked: boolean; + onToggleLock: () => void; + copyText?: string; + children: React.ReactNode; +} + +function Section({ title, locked, onToggleLock, copyText, children }: SectionProps) { + return ( + <div className={`doc-section${locked ? ' is-locked' : ''}`}> + <div className="doc-section-header"> + <div className="doc-section-title">{title}</div> + <div className="doc-section-actions"> + {copyText && <CopyButton text={copyText} label="Copy" />} + <button + type="button" + className={`section-lock${locked ? ' locked' : ''}`} + onClick={onToggleLock} + title={locked ? 'Locked — click to unlock' : 'Lock to preserve on regenerate'} + > + {locked ? '● locked' : '○ lock'} + </button> + </div> + </div> + <div className="doc-section-body"> + {children} + </div> + </div> + ); +} + +// ── Editable list item ──────────────────────────────────────────────────────── + +interface EditableListProps { + items: string[]; + onChange: (items: string[]) => void; + numbered?: boolean; +} + +function EditableList({ items, onChange, numbered = true }: EditableListProps) { + const updateAt = (i: number, v: string) => { + const next = [...items]; + next[i] = v; + onChange(next); + }; + + if (numbered) { + return ( + <div className="numbered-list"> + {items.map((item, i) => ( + <div key={i} className="numbered-item"> + <span className="numbered-item-num">{i + 1}.</span> + <div + className="numbered-item-text" + contentEditable + suppressContentEditableWarning + onBlur={e => updateAt(i, e.currentTarget.textContent ?? '')} + onKeyDown={e => { if (e.key === 'Escape') e.currentTarget.blur(); }} + > + {item} + </div> + </div> + ))} + </div> + ); + } + + return ( + <div className="bullet-list"> + {items.map((item, i) => ( + <div key={i} className="bullet-item"> + {item} + </div> + ))} + </div> + ); +} + +// ── Typography section ──────────────────────────────────────────────────────── + +function TypographySection({ typography }: { typography: Typography }) { + return ( + <div className="type-section"> + <div className="type-fonts"> + <div className="type-font-row"> + <span className="type-font-role">Primary</span> + <span className="type-font-name">{typography.primary}</span> + </div> + {typography.secondary !== typography.primary && ( + <div className="type-font-row"> + <span className="type-font-role">Secondary</span> + <span className="type-font-name">{typography.secondary}</span> + </div> + )} + <div className="type-font-row"> + <span className="type-font-role">Monospace</span> + <span className="type-font-name" style={{ fontFamily: 'var(--font-mono)' }}>{typography.mono}</span> + </div> + <div className="type-pair-note">{typography.pairNote}</div> + </div> + + <div className="type-scale"> + <div className="type-scale-header"> + <span>Style</span> + <span>Size</span> + <span>Weight</span> + <span className="type-scale-usage">Usage</span> + </div> + {typography.scale.map(token => ( + <div key={token.label} className="type-scale-row"> + <span className="type-scale-label">{token.label}</span> + <span className="type-scale-size">{token.size}</span> + <span className="type-scale-weight">{token.weight}</span> + <span className="type-scale-usage">{token.usage}</span> + </div> + ))} + </div> + </div> + ); +} + +// ── Color swatch card ───────────────────────────────────────────────────────── + +interface ColorSwatchCardProps { + swatch: ColorSwatch; + onChange: (s: ColorSwatch) => void; + onRemove: () => void; +} + +function ColorSwatchCard({ swatch, onChange, onRemove }: ColorSwatchCardProps) { + const [nameEditing, setNameEditing] = useState(false); + const [nameDraft, setNameDraft] = useState(swatch.name); + const [hexEditing, setHexEditing] = useState(false); + const [hexDraft, setHexDraft] = useState(swatch.hex); + + const commitName = () => { + setNameEditing(false); + const v = nameDraft.trim(); + if (v && v !== swatch.name) onChange({ ...swatch, name: v }); + }; + + const commitHex = () => { + setHexEditing(false); + const v = hexDraft.trim().toLowerCase(); + const normalized = v.startsWith('#') ? v : `#${v}`; + if (/^#[0-9a-f]{6}$/.test(normalized)) { + onChange({ ...swatch, hex: normalized }); + } else { + setHexDraft(swatch.hex); + } + }; + + return ( + <div className="color-swatch-card"> + <div className="color-swatch-preview" style={{ background: swatch.hex }}> + <input + type="color" + className="color-swatch-picker" + value={swatch.hex} + onChange={e => onChange({ ...swatch, hex: e.target.value })} + title="Pick color" + /> + <button className="color-swatch-remove" onClick={onRemove} title="Remove">×</button> + </div> + <div className="color-swatch-info"> + {nameEditing ? ( + <input + autoFocus + className="color-swatch-field-input" + value={nameDraft} + onChange={e => setNameDraft(e.target.value)} + onBlur={commitName} + onKeyDown={e => { + if (e.key === 'Enter') commitName(); + if (e.key === 'Escape') { setNameEditing(false); setNameDraft(swatch.name); } + }} + /> + ) : ( + <div className="color-swatch-name" onClick={() => { setNameDraft(swatch.name); setNameEditing(true); }} title="Click to edit"> + {swatch.name} + </div> + )} + {hexEditing ? ( + <input + autoFocus + className="color-swatch-field-input color-swatch-hex-input" + value={hexDraft} + onChange={e => setHexDraft(e.target.value)} + onBlur={commitHex} + onKeyDown={e => { + if (e.key === 'Enter') commitHex(); + if (e.key === 'Escape') { setHexEditing(false); setHexDraft(swatch.hex); } + }} + /> + ) : ( + <div className="color-swatch-hex" onClick={() => { setHexDraft(swatch.hex); setHexEditing(true); }} title="Click to edit"> + {swatch.hex} + </div> + )} + </div> + </div> + ); +} + +// ── Main BrandDoc ───────────────────────────────────────────────────────────── + +interface Props { + inputs: BrandInputs; + outputs: BrandOutputs; + locked: LockedSections; + onToggleLock: (section: keyof LockedSections) => void; + onEdit: <K extends keyof BrandOutputs>(key: K, value: BrandOutputs[K]) => void; +} + +export function BrandDoc({ inputs, outputs, locked, onToggleLock, onEdit }: Props) { + const paletteCopy = outputs.palette.swatches + .map(s => `${s.name}: ${s.hex}`) + .join('\n'); + + const updateSwatch = (index: number, updated: ColorSwatch) => { + const swatches = [...outputs.palette.swatches]; + swatches[index] = updated; + onEdit('palette', { swatches }); + }; + + const removeSwatch = (index: number) => { + const swatches = outputs.palette.swatches.filter((_, i) => i !== index); + onEdit('palette', { swatches }); + }; + + const addSwatch = () => { + const id = `s${Date.now()}`; + onEdit('palette', { + swatches: [...outputs.palette.swatches, { id, name: 'New Color', hex: '#888888', role: 'accent' }], + }); + }; + + const messagingCopy = [ + 'Titles:', + ...outputs.titles.map((t, i) => `${i + 1}. ${t}`), + '', + 'Taglines:', + ...outputs.taglines.map((t, i) => `${i + 1}. ${t}`), + ].join('\n'); + + return ( + <div className="brand-doc"> + <div className="brand-doc-header"> + <div className="brand-doc-title">{inputs.name || 'Brand Package'}</div> + <div className="brand-doc-meta"> + {inputs.category && ( + <span className="brand-doc-meta-item"> + <span className="brand-doc-meta-label">category</span> + {inputs.category} + </span> + )} + {inputs.audience && ( + <span className="brand-doc-meta-item"> + <span className="brand-doc-meta-label">audience</span> + {inputs.audience} + </span> + )} + </div> + </div> + + {/* Overview */} + <Section title="Overview" locked={locked.overview} onToggleLock={() => onToggleLock('overview')} copyText={outputs.overview}> + <Editable value={outputs.overview} onChange={v => onEdit('overview', v)} multiline /> + </Section> + + {/* Positioning */} + <Section title="Positioning" locked={locked.positioning} onToggleLock={() => onToggleLock('positioning')} copyText={outputs.positioning}> + <Editable value={outputs.positioning} onChange={v => onEdit('positioning', v)} multiline /> + </Section> + + {/* Tone */} + <Section title="Tone & Voice" locked={locked.tone} onToggleLock={() => onToggleLock('tone')}> + <div className="tone-grid"> + <div className="tone-row"> + <div className="tone-row-label">Attributes</div> + <div className="tone-tags"> + {outputs.tone.attributes.map(a => ( + <span key={a} className="tone-tag">{a}</span> + ))} + </div> + </div> + <div className="tone-row"> + <div className="tone-row-label">Voice</div> + <div className="tone-row-value">{outputs.tone.voiceNotes}</div> + </div> + {outputs.tone.avoidList.length > 0 && ( + <div className="tone-row"> + <div className="tone-row-label">Avoid</div> + <div className="tone-row-value">{outputs.tone.avoidList.join(', ')}</div> + </div> + )} + {outputs.tone.examplePhrases.length > 0 && ( + <div className="tone-row"> + <div className="tone-row-label">Example phrases</div> + <div className="tone-phrases"> + {outputs.tone.examplePhrases.map((p, i) => ( + <div key={i} className="tone-phrase">{p}</div> + ))} + </div> + </div> + )} + </div> + </Section> + + {/* Messaging */} + <Section title="Messaging" locked={locked.messaging} onToggleLock={() => onToggleLock('messaging')} copyText={messagingCopy}> + <div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}> + <div> + <div style={{ fontSize: 11, color: 'var(--text-3)', fontWeight: 500, marginBottom: 10 }}>Titles</div> + <EditableList items={outputs.titles} onChange={v => onEdit('titles', v)} /> + </div> + <div> + <div style={{ fontSize: 11, color: 'var(--text-3)', fontWeight: 500, marginBottom: 10 }}>Subtitles</div> + <EditableList items={outputs.subtitles} onChange={v => onEdit('subtitles', v)} /> + </div> + <div> + <div style={{ fontSize: 11, color: 'var(--text-3)', fontWeight: 500, marginBottom: 10 }}>Taglines</div> + <EditableList items={outputs.taglines} onChange={v => onEdit('taglines', v)} /> + </div> + </div> + </Section> + + {/* Visual Directions */} + <Section title="Visual Direction" locked={locked.visual} onToggleLock={() => onToggleLock('visual')}> + <div className="directions-grid"> + {outputs.visualDirections.map(dir => ( + <div key={dir.id} className="direction-card"> + <div className="direction-name">{dir.name}</div> + <div className="direction-desc">{dir.description}</div> + <div className="direction-attrs"> + <div className="direction-attr"> + <span className="direction-attr-label">Palette</span> + <span className="direction-attr-value">{dir.palette}</span> + </div> + <div className="direction-attr"> + <span className="direction-attr-label">Typography</span> + <span className="direction-attr-value">{dir.typography}</span> + </div> + <div className="direction-attr"> + <span className="direction-attr-label">References</span> + <span className="direction-attr-value">{dir.references}</span> + </div> + </div> + </div> + ))} + </div> + </Section> + + {/* Color Palette */} + <Section title="Color Palette" locked={locked.palette} onToggleLock={() => onToggleLock('palette')} copyText={paletteCopy}> + <div className="color-palette"> + {outputs.palette.swatches.map((swatch, i) => ( + <ColorSwatchCard + key={swatch.id} + swatch={swatch} + onChange={updated => updateSwatch(i, updated)} + onRemove={() => removeSwatch(i)} + /> + ))} + <button className="color-swatch-add" onClick={addSwatch} title="Add color"> + + + </button> + </div> + </Section> + + {/* Typography */} + <Section title="Typography" locked={locked.typography} onToggleLock={() => onToggleLock('typography')}> + <TypographySection typography={outputs.typography} /> + </Section> + + {/* Logo Concepts */} + <Section title="Logo Concepts" locked={locked.logo} onToggleLock={() => onToggleLock('logo')}> + <div className="logo-grid"> + {outputs.logoConcepts.map(lc => ( + <div key={lc.id} className="logo-card"> + <div className="logo-card-title">{lc.title}</div> + <div className="logo-card-concept">{lc.concept}</div> + <div className="logo-card-attrs"> + <div className="logo-card-attr"> + <span className="logo-card-attr-label">Mark</span> + <span className="logo-card-attr-value">{lc.mark}</span> + </div> + <div className="logo-card-attr"> + <span className="logo-card-attr-label">Execution</span> + <span className="logo-card-attr-value">{lc.execution}</span> + </div> + </div> + </div> + ))} + </div> + </Section> + + {/* Usage Examples */} + <Section title="Usage Examples" locked={locked.usage} onToggleLock={() => onToggleLock('usage')}> + <div className="usage-grid"> + {outputs.usageExamples.map((ex, i) => ( + <div key={i} className="usage-item"> + <div className="usage-context">{ex.context}</div> + <div className="usage-text" style={{ position: 'relative' }}> + {ex.text} + <div className="usage-copy"> + <CopyButton text={ex.text} label="Copy" /> + </div> + </div> + </div> + ))} + </div> + </Section> + + {/* Constraints */} + <Section title="Constraints" locked={locked.constraints} onToggleLock={() => onToggleLock('constraints')}> + <div className="constraints-list"> + {outputs.constraints.map((c, i) => ( + <div key={i} className="constraint-item">{c}</div> + ))} + </div> + </Section> + </div> + ); +} diff --git a/src/components/CopyButton.tsx b/src/components/CopyButton.tsx new file mode 100644 index 0000000..18a7433 --- /dev/null +++ b/src/components/CopyButton.tsx @@ -0,0 +1,31 @@ +import { useState } from 'react'; +import { copyToClipboard } from '../lib/clipboard'; + +interface Props { + text: string; + label?: string; + className?: string; +} + +export function CopyButton({ text, label = 'Copy', className = '' }: Props) { + const [copied, setCopied] = useState(false); + + const handle = async () => { + const ok = await copyToClipboard(text); + if (ok) { + setCopied(true); + setTimeout(() => setCopied(false), 1500); + } + }; + + return ( + <button + type="button" + className={`copy-btn${copied ? ' copied' : ''} ${className}`} + onClick={handle} + title={label} + > + {copied ? '✓ Copied' : label} + </button> + ); +} diff --git a/src/components/InputPanel.tsx b/src/components/InputPanel.tsx new file mode 100644 index 0000000..c4ad902 --- /dev/null +++ b/src/components/InputPanel.tsx @@ -0,0 +1,133 @@ +import type { BrandInputs } from '../types'; +import { TokenInput } from './TokenInput'; + +const TONE_SUGGESTIONS = ['minimal', 'technical', 'calm', 'bold', 'warm', 'dry', 'focused', 'sharp']; +const AVOID_SUGGESTIONS = ['buzzwords', 'hype', 'startup language', 'passive voice', 'corporate tone', 'exclamation points', 'superlatives']; + +interface Props { + inputs: BrandInputs; + onChange: (inputs: BrandInputs) => void; + onGenerate: () => void; + isGenerating: boolean; + generateLabel?: string; +} + +export function InputPanel({ inputs, onChange, onGenerate, isGenerating, generateLabel }: Props) { + const set = <K extends keyof BrandInputs>(key: K) => + (value: BrandInputs[K]) => onChange({ ...inputs, [key]: value }); + + const handleKey = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { + e.preventDefault(); + onGenerate(); + } + }; + + const canGenerate = inputs.name.trim().length > 0 && inputs.purpose.trim().length > 0; + + return ( + <div className="input-panel" onKeyDown={handleKey}> + <div className="input-section"> + <div className="input-section-label">Project</div> + <div className="field"> + <label className="field-label" htmlFor="f-name">Name</label> + <input + id="f-name" + className="field-input" + placeholder="e.g. Foundry" + value={inputs.name} + onChange={e => set('name')(e.target.value)} + /> + </div> + <div className="field"> + <label className="field-label" htmlFor="f-category">Category</label> + <input + id="f-category" + className="field-input" + placeholder="e.g. developer tool, design studio" + value={inputs.category} + onChange={e => set('category')(e.target.value)} + /> + </div> + <div className="field"> + <label className="field-label" htmlFor="f-purpose">One-line purpose</label> + <input + id="f-purpose" + className="field-input" + placeholder="e.g. deploy microservices without boilerplate" + value={inputs.purpose} + onChange={e => set('purpose')(e.target.value)} + /> + </div> + <div className="field"> + <label className="field-label" htmlFor="f-audience">Audience</label> + <input + id="f-audience" + className="field-input" + placeholder="e.g. backend engineers" + value={inputs.audience} + onChange={e => set('audience')(e.target.value)} + /> + </div> + </div> + + <div className="input-section"> + <div className="input-section-label">Voice</div> + <div className="field"> + <TokenInput + label="Tone attributes" + values={inputs.tone} + onChange={set('tone')} + suggestions={TONE_SUGGESTIONS} + placeholder="Add tone..." + /> + </div> + <div className="field" style={{ marginTop: 8 }}> + <TokenInput + label="Avoid" + values={inputs.avoid} + onChange={set('avoid')} + suggestions={AVOID_SUGGESTIONS} + placeholder="Add avoid..." + /> + </div> + </div> + + <div className="input-section"> + <div className="input-section-label">Notes</div> + <div className="field"> + <label className="field-label sr-only" htmlFor="f-notes">Notes & constraints</label> + <textarea + id="f-notes" + className="field-textarea" + placeholder="Optional: constraints, context, inspirations, or anything the brand package should reflect." + value={inputs.notes} + onChange={e => set('notes')(e.target.value)} + rows={4} + /> + </div> + </div> + + <div className="generate-area"> + <button + className={`btn-generate${isGenerating ? ' generating' : ''}`} + onClick={onGenerate} + disabled={!isGenerating && !canGenerate} + title={!canGenerate ? 'Enter a name and purpose to generate' : 'Generate brand package (⌘Enter)'} + > + {generateLabel ?? (isGenerating ? 'Generating…' : 'Generate brand package')} + </button> + {!canGenerate && ( + <div style={{ marginTop: 7, fontSize: 11, color: 'var(--text-4)', textAlign: 'center' }}> + Name and purpose required + </div> + )} + {canGenerate && !isGenerating && ( + <div style={{ marginTop: 7, fontSize: 11, color: 'var(--text-4)', textAlign: 'center' }}> + ⌘ Enter to generate + </div> + )} + </div> + </div> + ); +} diff --git a/src/components/PackageSwitcher.tsx b/src/components/PackageSwitcher.tsx new file mode 100644 index 0000000..38c1967 --- /dev/null +++ b/src/components/PackageSwitcher.tsx @@ -0,0 +1,125 @@ +import { useState, useEffect } from 'react'; +import { createPortal } from 'react-dom'; +import type { PackageSlot } from '../hooks/usePackages'; + +interface Props { + slots: PackageSlot[]; + activeId: string; + onSwitch: (id: string) => void; + onCreate: () => void; + onRemove: (id: string) => void; + onRename: (id: string, name: string) => void; + onDuplicate: (id: string) => void; +} + +interface MenuState { + id: string; + top: number; + left: number; +} + +export function PackageSwitcher({ slots, activeId, onSwitch, onCreate, onRemove, onRename, onDuplicate }: Props) { + const [editingId, setEditingId] = useState<string | null>(null); + const [draft, setDraft] = useState(''); + const [menu, setMenu] = useState<MenuState | null>(null); + + // Close dropdown on any outside click + useEffect(() => { + if (!menu) return; + const handler = () => setMenu(null); + document.addEventListener('click', handler); + return () => document.removeEventListener('click', handler); + }, [menu]); + + const openMenu = (id: string, e: React.MouseEvent<HTMLButtonElement>) => { + e.stopPropagation(); + if (menu?.id === id) { setMenu(null); return; } + const rect = e.currentTarget.getBoundingClientRect(); + setMenu({ id, top: rect.bottom + 4, left: rect.left }); + }; + + const commitRename = (id: string) => { + const name = draft.trim(); + if (name) onRename(id, name); + setEditingId(null); + }; + + return ( + <div className="pkg-switcher"> + {slots.map(slot => ( + <div + key={slot.id} + className={`pkg-tab${slot.id === activeId ? ' active' : ''}`} + onClick={() => slot.id !== activeId && onSwitch(slot.id)} + > + {editingId === slot.id ? ( + <input + autoFocus + className="pkg-tab-input" + value={draft} + onChange={e => setDraft(e.target.value)} + onBlur={() => commitRename(slot.id)} + onKeyDown={e => { + if (e.key === 'Enter') commitRename(slot.id); + if (e.key === 'Escape') setEditingId(null); + }} + onClick={e => e.stopPropagation()} + /> + ) : ( + <span + className="pkg-tab-name" + onDoubleClick={e => { + e.stopPropagation(); + setDraft(slot.name); + setEditingId(slot.id); + }} + > + {slot.name} + </span> + )} + + <div className="pkg-tab-actions"> + <button + className="pkg-tab-menu-btn" + title="Options" + onClick={e => openMenu(slot.id, e)} + > + ··· + </button> + {slots.length > 1 && ( + <button + className="pkg-tab-close" + title="Remove" + onClick={e => { e.stopPropagation(); onRemove(slot.id); }} + > + × + </button> + )} + </div> + </div> + ))} + + <button className="pkg-new-btn" onClick={onCreate} title="New package">+</button> + + {/* Dropdown rendered in a portal to escape overflow clipping */} + {menu && createPortal( + <div + className="pkg-tab-dropdown" + style={{ position: 'fixed', top: menu.top, left: menu.left }} + onClick={e => e.stopPropagation()} + > + <button onClick={() => { + const slot = slots.find(s => s.id === menu.id); + if (slot) { setDraft(slot.name); setEditingId(slot.id); } + setMenu(null); + }}>Rename</button> + <button onClick={() => { onDuplicate(menu.id); setMenu(null); }}>Duplicate</button> + {slots.length > 1 && ( + <button className="danger" onClick={() => { onRemove(menu.id); setMenu(null); }}>Delete</button> + )} + </div>, + document.body + )} + </div> + ); +} diff --git a/src/components/PreviewPanel.tsx b/src/components/PreviewPanel.tsx new file mode 100644 index 0000000..695698a --- /dev/null +++ b/src/components/PreviewPanel.tsx @@ -0,0 +1,116 @@ +import type { BrandInputs, BrandOutputs, LockedSections } from '../types'; +import { BrandDoc } from './BrandDoc'; +import { CopyButton } from './CopyButton'; +import { toMarkdown, toJSON, toHTML, downloadFile } from '../lib/export'; + +interface Props { + inputs: BrandInputs; + outputs: BrandOutputs | null; + locked: LockedSections; + onToggleLock: (section: keyof LockedSections) => void; + onEdit: <K extends keyof BrandOutputs>(key: K, value: BrandOutputs[K]) => void; + isGenerating: boolean; + generateMode?: 'template' | string; + generateError?: string | null; + onGenerate?: () => void; +} + +export function PreviewPanel({ inputs, outputs, locked, onToggleLock, onEdit, isGenerating, generateMode, generateError, onGenerate }: Props) { + const handleExportMd = () => { + if (!outputs) return; + downloadFile(toMarkdown(inputs, outputs), `${inputs.name || 'brand'}-package.md`, 'text/markdown'); + }; + + const handleExportJson = () => { + if (!outputs) return; + downloadFile(toJSON(inputs, outputs), `${inputs.name || 'brand'}-package.json`, 'application/json'); + }; + + const handleExportHtml = () => { + if (!outputs) return; + downloadFile(toHTML(inputs, outputs), `${inputs.name || 'brand'}-guidelines.html`, 'text/html'); + }; + + const fullMarkdown = outputs ? toMarkdown(inputs, outputs) : ''; + + return ( + <div className="preview-panel"> + <div className="preview-toolbar"> + <div className="preview-toolbar-left"> + <span className="preview-doc-label"> + {outputs ? 'Brand Package' : 'Preview'} + </span> + {outputs && ( + <span style={{ fontSize: 11, color: 'var(--text-4)' }}> + · {new Date().toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })} + </span> + )} + </div> + {outputs && ( + <div className="preview-toolbar-right"> + <CopyButton text={fullMarkdown} label="Copy all" /> + </div> + )} + </div> + + {generateError && ( + <div className="generate-error"> + <span className="generate-error-icon">⚠</span> + <span className="generate-error-msg">{generateError}</span> + {onGenerate && ( + <button className="generate-error-retry btn btn-ghost" onClick={onGenerate}> + Try template instead + </button> + )} + </div> + )} + + <div className="preview-scroll"> + {isGenerating ? ( + <div className="empty-state"> + <div className="empty-state-title" style={{ color: 'var(--text-3)' }}> + {generateMode && generateMode !== 'template' + ? `Thinking with ${generateMode}…` + : 'Generating…'} + </div> + {generateMode && generateMode !== 'template' && ( + <div className="empty-state-sub">This may take 15–60 seconds depending on your hardware</div> + )} + </div> + ) : outputs ? ( + <BrandDoc + inputs={inputs} + outputs={outputs} + locked={locked} + onToggleLock={onToggleLock} + onEdit={onEdit} + /> + ) : ( + <div className="empty-state"> + <div className="empty-state-title">No package generated yet</div> + <div className="empty-state-sub">Fill in the project details and click Generate</div> + </div> + )} + </div> + + {outputs && ( + <div className="export-bar"> + <span className="export-bar-left"> + Export + </span> + <div className="export-bar-right"> + <button className="btn" onClick={handleExportMd}> + Export Markdown + </button> + <button className="btn" onClick={handleExportJson}> + Export JSON + </button> + <button className="btn" onClick={handleExportHtml}> + Export HTML + </button> + </div> + </div> + )} + </div> + ); +} diff --git a/src/components/SettingsPanel.tsx b/src/components/SettingsPanel.tsx new file mode 100644 index 0000000..bb44c74 --- /dev/null +++ b/src/components/SettingsPanel.tsx @@ -0,0 +1,158 @@ +import { useState, useEffect } from 'react'; +import type { OllamaSettings } from '../hooks/useSettings'; +import { testOllamaConnection } from '../engine/aiGenerator'; + +interface Props { + settings: OllamaSettings; + onChange: (next: Partial<OllamaSettings>) => void; + onClose: () => void; +} + +type TestState = + | { status: 'idle' } + | { status: 'testing' } + | { status: 'ok'; models: string[] } + | { status: 'error'; message: string }; + +export function SettingsPanel({ settings, onChange, onClose }: Props) { + const [url, setUrl] = useState(settings.baseUrl); + const [model, setModel] = useState(settings.model); + const [test, setTest] = useState<TestState>({ status: 'idle' }); + + // Commit field on blur/enter + const commitUrl = () => onChange({ baseUrl: url }); + const commitModel = (m: string) => { setModel(m); onChange({ model: m }); }; + + const runTest = async () => { + onChange({ baseUrl: url }); // save current URL first + setTest({ status: 'testing' }); + try { + const models = await testOllamaConnection(url); + setTest({ status: 'ok', models }); + } catch (err) { + setTest({ status: 'error', message: (err as Error).message }); + } + }; + + // Close on Escape + useEffect(() => { + const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); }; + window.addEventListener('keydown', handler); + return () => window.removeEventListener('keydown', handler); + }, [onClose]); + + return ( + <div className="settings-overlay" onClick={onClose}> + <div className="settings-panel" onClick={e => e.stopPropagation()}> + <div className="settings-header"> + <span className="settings-title">Settings</span> + <button className="settings-close" onClick={onClose}>×</button> + </div> + + <div className="settings-body"> + {/* AI toggle */} + <div className="settings-section"> + <div className="settings-section-label">AI Generation</div> + <label className="settings-toggle-row"> + <div className="settings-toggle-info"> + <span className="settings-toggle-name">Use Ollama</span> + <span className="settings-toggle-desc"> + Replace template generation with a local AI model + </span> + </div> + <button + className={`toggle${settings.enabled ? ' on' : ''}`} + onClick={() => onChange({ enabled: !settings.enabled })} + role="switch" + aria-checked={settings.enabled} + > + <span className="toggle-thumb" /> + </button> + </label> + </div> + + {/* Ollama config */} + {settings.enabled && ( + <div className="settings-section"> + <div className="settings-section-label">Ollama</div> + + <div className="settings-field"> + <label className="settings-field-label">Server URL</label> + <div className="settings-field-row"> + <input + className="settings-input" + type="url" + value={url} + onChange={e => setUrl(e.target.value)} + onBlur={commitUrl} + onKeyDown={e => e.key === 'Enter' && commitUrl()} + placeholder="http://localhost:11434" + spellCheck={false} + /> + <button + className={`btn settings-test-btn${test.status === 'testing' ? ' testing' : ''}`} + onClick={runTest} + disabled={test.status === 'testing'} + > + {test.status === 'testing' ? 'Testing…' : 'Test'} + </button> + </div> + + {test.status === 'ok' && ( + <div className="settings-status ok"> + Connected · {test.models.length} model{test.models.length !== 1 ? 's' : ''} available + </div> + )} + {test.status === 'error' && ( + <div className="settings-status error">{test.message}</div> + )} + </div> + + <div className="settings-field"> + <label className="settings-field-label">Model</label> + <input + className="settings-input" + value={model} + onChange={e => setModel(e.target.value)} + onBlur={() => commitModel(model)} + onKeyDown={e => e.key === 'Enter' && commitModel(model)} + placeholder="llama3.2" + spellCheck={false} + /> + {test.status === 'ok' && test.models.length > 0 && ( + <div className="settings-model-list"> + {test.models.map(m => ( + <button + key={m} + className={`settings-model-item${m === model ? ' active' : ''}`} + onClick={() => commitModel(m)} + > + {m} + </button> + ))} + </div> + )} + <div className="settings-field-hint"> + Pull a model first: <code>ollama pull llama3.2</code> + </div> + </div> + </div> + )} + + {/* Help */} + <div className="settings-section settings-help"> + <div className="settings-section-label">About Ollama</div> + <p> + Ollama runs AI models locally on your machine — no API key, no data sent externally. + Install from <strong>ollama.com</strong>, then pull any model to get started. + </p> + <p> + If connection fails, ensure Ollama is running and CORS is open:<br /> + <code>OLLAMA_ORIGINS=* ollama serve</code> + </p> + </div> + </div> + </div> + </div> + ); +} diff --git a/src/components/TokenInput.tsx b/src/components/TokenInput.tsx new file mode 100644 index 0000000..c0e23de --- /dev/null +++ b/src/components/TokenInput.tsx @@ -0,0 +1,85 @@ +import { useState, useRef, KeyboardEvent } from 'react'; + +interface Props { + label: string; + values: string[]; + onChange: (values: string[]) => void; + suggestions?: string[]; + placeholder?: string; +} + +export function TokenInput({ label, values, onChange, suggestions = [], placeholder = 'Type and press Enter' }: Props) { + const [draft, setDraft] = useState(''); + const inputRef = useRef<HTMLInputElement>(null); + + const add = (val: string) => { + const trimmed = val.trim().toLowerCase(); + if (trimmed && !values.includes(trimmed)) { + onChange([...values, trimmed]); + } + setDraft(''); + }; + + const remove = (val: string) => { + onChange(values.filter(v => v !== val)); + }; + + const handleKey = (e: KeyboardEvent<HTMLInputElement>) => { + if ((e.key === 'Enter' || e.key === ',') && draft.trim()) { + e.preventDefault(); + add(draft); + } else if (e.key === 'Backspace' && !draft && values.length > 0) { + remove(values[values.length - 1]); + } + }; + + const availableSuggestions = suggestions.filter(s => !values.includes(s)); + + return ( + <div className="token-field"> + <label className="field-label">{label}</label> + <div + className="token-container" + onClick={() => inputRef.current?.focus()} + > + {values.map(v => ( + <span key={v} className="token"> + {v} + <button + type="button" + className="token-remove" + onClick={e => { e.stopPropagation(); remove(v); }} + aria-label={`Remove ${v}`} + > + × + </button> + </span> + ))} + <input + ref={inputRef} + className="token-input" + value={draft} + onChange={e => setDraft(e.target.value)} + onKeyDown={handleKey} + onBlur={() => { if (draft.trim()) add(draft); }} + placeholder={values.length === 0 ? placeholder : ''} + aria-label={label} + /> + </div> + {availableSuggestions.length > 0 && ( + <div className="token-suggestions"> + {availableSuggestions.map(s => ( + <button + key={s} + type="button" + className={`token-suggestion${values.includes(s) ? ' active' : ''}`} + onClick={() => add(s)} + > + {s} + </button> + ))} + </div> + )} + </div> + ); +} |
