diff --git a/frontend/src/components/react/AssetsButton.tsx b/frontend/src/components/react/AssetsButton.tsx new file mode 100644 index 0000000..7b4e5b0 --- /dev/null +++ b/frontend/src/components/react/AssetsButton.tsx @@ -0,0 +1,117 @@ +import { useEffect, useState } from 'react'; +import AssetManager from './admin/AssetManager'; + +interface Props { + className?: string; + label?: string; +} + +export default function AssetsButton({ + className = 'inline-flex items-center gap-2 bg-surface0 hover:bg-surface1 text-subtext1 hover:text-text px-3 py-2 rounded-lg border border-surface1 transition-colors text-sm', + label = 'Assets', +}: Props) { + const [open, setOpen] = useState(false); + + useEffect(() => { + if (!open) return; + function onKey(e: KeyboardEvent) { + if (e.key === 'Escape') setOpen(false); + } + window.addEventListener('keydown', onKey); + const prev = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + return () => { + window.removeEventListener('keydown', onKey); + document.body.style.overflow = prev; + }; + }, [open]); + + return ( + <> + + + {open && ( +
setOpen(false)} + > + + )} + + ); +} diff --git a/frontend/src/components/react/EditableText.tsx b/frontend/src/components/react/EditableText.tsx new file mode 100644 index 0000000..0c5318f --- /dev/null +++ b/frontend/src/components/react/EditableText.tsx @@ -0,0 +1,159 @@ +import { useEffect, useRef, useState } from 'react'; +import { updateConfig } from '../../lib/api'; +import type { SiteConfig } from '../../lib/types'; + +type ConfigKey = keyof SiteConfig; + +interface Props { + initial: string; + fieldKey: ConfigKey; + isAdmin?: boolean; + className?: string; + multiline?: boolean; + ariaLabel?: string; + pencilPosition?: 'inline' | 'overlay'; +} + +export default function EditableText({ + initial, + fieldKey, + isAdmin = false, + className = '', + multiline = false, + ariaLabel, + pencilPosition = 'inline', +}: Props) { + const [value, setValue] = useState(initial); + const [draft, setDraft] = useState(initial); + const [editing, setEditing] = useState(false); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const inputRef = useRef(null); + + useEffect(() => { + if (editing) { + requestAnimationFrame(() => { + inputRef.current?.focus(); + if (inputRef.current && 'select' in inputRef.current) inputRef.current.select(); + }); + } + }, [editing]); + + if (!isAdmin) { + return {value}; + } + + function startEdit(e?: React.MouseEvent) { + e?.preventDefault(); + e?.stopPropagation(); + setDraft(value); + setError(null); + setEditing(true); + } + + function cancel() { + setDraft(value); + setError(null); + setEditing(false); + } + + async function save() { + if (saving) return; + const next = draft.trim(); + if (next === value) { + setEditing(false); + return; + } + setSaving(true); + setError(null); + try { + await updateConfig({ [fieldKey]: next } as Partial); + setValue(next); + setEditing(false); + } catch (e) { + setError(e instanceof Error ? e.message : 'Save failed'); + } finally { + setSaving(false); + } + } + + function onKey(e: React.KeyboardEvent) { + if (e.key === 'Escape') { + e.preventDefault(); + cancel(); + } else if (e.key === 'Enter' && !multiline) { + e.preventDefault(); + save(); + } else if (e.key === 'Enter' && multiline && (e.metaKey || e.ctrlKey)) { + e.preventDefault(); + save(); + } + } + + if (editing) { + const sharedClass = `bg-crust border border-mauve/50 rounded px-2 py-0.5 outline-none focus:border-mauve text-text ${className}`; + return ( + + {multiline ? ( +