From 93fdb8d1fc648c8aa5e55e1578cc115f9ebe7d1a Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Sun, 17 May 2026 14:44:45 +0200 Subject: [PATCH] split global.css --- .../src/components/react/admin/Editor.tsx | 362 +- .../react/admin/editor/codemirror.ts | 72 + .../admin/editor/useAssetAutocomplete.ts | 74 + .../react/admin/editor/useAssetCache.ts | 28 + .../react/admin/editor/useImageUpload.ts | 67 + .../react/admin/editor/useLivePreview.ts | 51 + .../react/admin/editor/usePostMeta.ts | 121 + frontend/src/pages/api/[...path].ts | 6 +- frontend/src/styles/global.css | 3116 +---------------- frontend/src/styles/partials/00-theme.css | 41 + frontend/src/styles/partials/10-tokens.css | 174 + .../src/styles/partials/20-atmosphere.css | 105 + frontend/src/styles/partials/30-prose.css | 347 ++ .../src/styles/partials/40-components.css | 343 ++ frontend/src/styles/partials/50-controls.css | 426 +++ frontend/src/styles/partials/60-breakcore.css | 471 +++ .../src/styles/partials/70-cybersigil.css | 1153 ++++++ frontend/src/styles/partials/90-keyframes.css | 25 + .../src/styles/partials/99-reduced-motion.css | 20 + 19 files changed, 3605 insertions(+), 3397 deletions(-) create mode 100644 frontend/src/components/react/admin/editor/codemirror.ts create mode 100644 frontend/src/components/react/admin/editor/useAssetAutocomplete.ts create mode 100644 frontend/src/components/react/admin/editor/useAssetCache.ts create mode 100644 frontend/src/components/react/admin/editor/useImageUpload.ts create mode 100644 frontend/src/components/react/admin/editor/useLivePreview.ts create mode 100644 frontend/src/components/react/admin/editor/usePostMeta.ts create mode 100644 frontend/src/styles/partials/00-theme.css create mode 100644 frontend/src/styles/partials/10-tokens.css create mode 100644 frontend/src/styles/partials/20-atmosphere.css create mode 100644 frontend/src/styles/partials/30-prose.css create mode 100644 frontend/src/styles/partials/40-components.css create mode 100644 frontend/src/styles/partials/50-controls.css create mode 100644 frontend/src/styles/partials/60-breakcore.css create mode 100644 frontend/src/styles/partials/70-cybersigil.css create mode 100644 frontend/src/styles/partials/90-keyframes.css create mode 100644 frontend/src/styles/partials/99-reduced-motion.css diff --git a/frontend/src/components/react/admin/Editor.tsx b/frontend/src/components/react/admin/Editor.tsx index 7c905cc..07254db 100644 --- a/frontend/src/components/react/admin/Editor.tsx +++ b/frontend/src/components/react/admin/Editor.tsx @@ -1,14 +1,18 @@ -import { useState, useEffect, useRef, useCallback, lazy, Suspense } from 'react'; +import { lazy, Suspense, useCallback, useEffect, useRef, useState } from 'react'; import { EditorView, keymap, placeholder as cmPlaceholder } from '@codemirror/view'; -import { EditorState, Compartment } from '@codemirror/state'; +import { EditorState } from '@codemirror/state'; import { markdown, markdownLanguage } from '@codemirror/lang-markdown'; import { languages } from '@codemirror/language-data'; import { defaultKeymap, indentWithTab } from '@codemirror/commands'; import { search, searchKeymap } from '@codemirror/search'; import { closeBrackets } from '@codemirror/autocomplete'; -import { getPost, savePost, deletePost, getAssets, uploadAsset, ApiError } from '../../../lib/api'; import type { Asset } from '../../../lib/types'; -import { confirmDialog, notify } from '../../../lib/confirm'; +import { salonTheme, vimCompartment } from './editor/codemirror'; +import { useAssetCache } from './editor/useAssetCache'; +import { useLivePreview } from './editor/useLivePreview'; +import { useImageUpload } from './editor/useImageUpload'; +import { useAssetAutocomplete } from './editor/useAssetAutocomplete'; +import { usePostMeta } from './editor/usePostMeta'; const AssetManager = lazy(() => import('./AssetManager')); @@ -16,117 +20,61 @@ interface Props { editSlug?: string; } -const salonTheme = EditorView.theme({ - '&': { - backgroundColor: 'var(--base)', - color: 'var(--text)', - border: '1px solid var(--surface2)', - borderRadius: '2px', - fontSize: '14px', - boxShadow: 'inset 0 0 0 1px color-mix(in srgb, var(--surface1) 40%, transparent)', - }, - '.cm-content': { - fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace', - padding: '1rem', - caretColor: 'var(--mauve)', - color: 'var(--text)', - }, - '.cm-cursor': { borderLeftColor: 'var(--mauve)', borderLeftWidth: '2px' }, - '.cm-selectionBackground': { backgroundColor: 'color-mix(in srgb, var(--mauve) 25%, transparent) !important' }, - '&.cm-focused .cm-selectionBackground': { backgroundColor: 'color-mix(in srgb, var(--mauve) 30%, transparent) !important' }, - '.cm-activeLine': { backgroundColor: 'color-mix(in srgb, var(--surface0) 55%, transparent)' }, - '.cm-gutters': { - backgroundColor: 'var(--surface0)', - color: 'var(--subtext0)', - border: 'none', - borderRight: '1px solid var(--surface2)', - fontFamily: 'var(--font-display)', - fontStyle: 'italic', - }, - '.cm-activeLineGutter': { backgroundColor: 'color-mix(in srgb, var(--mauve) 12%, transparent)', color: 'var(--mauve)' }, - '.cm-panels': { - backgroundColor: 'var(--surface0)', - color: 'var(--text)', - borderTop: '1px solid var(--surface2)', - }, - '.cm-searchMatch': { backgroundColor: 'color-mix(in srgb, var(--yellow) 45%, transparent)' }, - '.cm-searchMatch-selected': { backgroundColor: 'color-mix(in srgb, var(--peach) 55%, transparent)' }, - '.cm-fat-cursor': { - backgroundColor: 'var(--mauve) !important', - color: 'var(--rosewater) !important', - }, - '&:not(.cm-focused) .cm-fat-cursor': { - outline: '1px solid var(--mauve)', - backgroundColor: 'transparent !important', - }, -}, { dark: false }); - -// Compartment for hot-swapping vim mode without recreating the editor -const vimCompartment = new Compartment(); - -function clientSlugify(s: string): string { - return s - .toLowerCase() - .normalize('NFD') - .replace(/[̀-ͯ]/g, '') - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-+|-+$/g, ''); -} - export default function Editor({ editSlug }: Props) { const editorRef = useRef(null); const viewRef = useRef(null); - const previewRef = useRef(null); - const previewTimerRef = useRef | null>(null); - const updatePreviewRef = useRef<() => void>(() => {}); - const uploadFnRef = useRef<(files: File[], insertAt?: number) => void>(() => {}); - const renderMarkdownRef = useRef<((src: string) => string) | null>(null); - const today = new Date().toISOString().slice(0, 10); - const [title, setTitle] = useState(''); - const [slug, setSlug] = useState(editSlug || ''); - const [slugTouched, setSlugTouched] = useState(!!editSlug); - const [date, setDate] = useState(today); - const [summary, setSummary] = useState(''); - const [tagsInput, setTagsInput] = useState(''); - const [draft, setDraft] = useState(false); - const [originalSlug, setOriginalSlug] = useState(editSlug || ''); - const [showModal, setShowModal] = useState(false); - const [showPreview, setShowPreview] = useState(false); - const [mobileView, setMobileView] = useState<'edit' | 'preview'>('edit'); const [vimEnabled, setVimEnabled] = useState(false); - const [showAutocomplete, setShowAutocomplete] = useState(false); - const [autocompleteAssets, setAutocompleteAssets] = useState([]); - const [autocompletePos, setAutocompletePos] = useState({ top: 0, left: 0 }); - const [isDragging, setIsDragging] = useState(false); - const [uploadingCount, setUploadingCount] = useState(0); - const dragDepthRef = useRef(0); - const assetsCacheRef = useRef(null); + const [showModal, setShowModal] = useState(false); - async function getCachedAssets(): Promise { - if (assetsCacheRef.current) return assetsCacheRef.current; - const assets = await getAssets(); - assetsCacheRef.current = assets; - return assets; - } + const getView = useCallback(() => viewRef.current, []); + const getContent = useCallback(() => viewRef.current?.state.doc.toString() || '', []); + const setContent = useCallback((s: string) => { + const v = viewRef.current; + if (v) v.dispatch({ changes: { from: 0, to: v.state.doc.length, insert: s } }); + }, []); - function showAlertMsg(msg: string, type: 'success' | 'error') { - notify(msg, type); - } + const assetCache = useAssetCache(); + const preview = useLivePreview({ getView }); + const upload = useImageUpload({ getView, prependAssets: assetCache.prepend }); + const autocomplete = useAssetAutocomplete({ + getView, + editorRef, + getCachedAssets: assetCache.getCachedAssets, + }); + const meta = usePostMeta({ editSlug, getContent, setContent }); - const updatePreview = useCallback(async () => { - if (!showPreview || !viewRef.current || !previewRef.current) return; - if (!renderMarkdownRef.current) { - const mod = await import('../../../lib/markdown'); - renderMarkdownRef.current = mod.renderMarkdown; - } - const content = viewRef.current.state.doc.toString(); - previewRef.current.innerHTML = renderMarkdownRef.current(content); - }, [showPreview]); + const { + title, setTitle, slug, setSlug, setSlugTouched, date, setDate, + summary, setSummary, tagsInput, setTagsInput, draft, setDraft, + originalSlug, handleSave, handleDelete, + } = meta; + const { showPreview, setShowPreview, mobileView, setMobileView, previewRef } = preview; + const { isDragging, uploadingCount, setIsDragging, dragDepthRef, uploadFilesAndInsert } = upload; + const { + showAutocomplete, setShowAutocomplete, autocompleteAssets, autocompletePos, + triggerAutocomplete, insertAssetMarkdown, + } = autocomplete; - useEffect(() => { updatePreviewRef.current = updatePreview; }, [updatePreview]); - useEffect(() => { uploadFnRef.current = uploadFilesAndInsert; }); + // Latest handler closures for the (once-created) CodeMirror listeners — + // same stale-closure-avoidance pattern the original component used. + const cmRef = useRef<{ + schedulePreview: () => void; + triggerAutocomplete: (v: EditorView) => void; + closeAutocomplete: () => void; + upload: (files: File[], at?: number) => void; + setDragging: (b: boolean) => void; + }>(null!); + useEffect(() => { + cmRef.current = { + schedulePreview: preview.schedulePreview, + triggerAutocomplete, + closeAutocomplete: () => setShowAutocomplete(false), + upload: uploadFilesAndInsert, + setDragging: setIsDragging, + }; + }); - // Initialize CodeMirror once + // Initialize CodeMirror once. useEffect(() => { if (!editorRef.current || viewRef.current) return; @@ -143,23 +91,22 @@ export default function Editor({ editSlug }: Props) { cmPlaceholder('# A title for the work\n\n![alt text](/uploads/your-image.jpg "optional caption")\n\nNotes, context, materials...'), EditorView.updateListener.of(update => { if (!update.docChanged) return; - if (previewTimerRef.current) clearTimeout(previewTimerRef.current); - previewTimerRef.current = setTimeout(() => updatePreviewRef.current(), 300); + cmRef.current.schedulePreview(); const pos = update.state.selection.main.head; const line = update.state.doc.lineAt(pos); const textBefore = line.text.slice(0, pos - line.from); const lastChar = textBefore.slice(-1); if (lastChar === '/' || lastChar === '!') { - triggerAutocomplete(update.view); + cmRef.current.triggerAutocomplete(update.view); } else if (lastChar === ' ' || textBefore.length === 0) { - setShowAutocomplete(false); + cmRef.current.closeAutocomplete(); } }), EditorView.domEventHandlers({ dragenter(event) { if (!event.dataTransfer?.types.includes('Files')) return false; dragDepthRef.current += 1; - setIsDragging(true); + cmRef.current.setDragging(true); return false; }, dragover(event) { @@ -169,7 +116,7 @@ export default function Editor({ editSlug }: Props) { }, dragleave() { dragDepthRef.current = Math.max(0, dragDepthRef.current - 1); - if (dragDepthRef.current === 0) setIsDragging(false); + if (dragDepthRef.current === 0) cmRef.current.setDragging(false); return false; }, drop(event, view) { @@ -177,9 +124,9 @@ export default function Editor({ editSlug }: Props) { if (!files || files.length === 0) return false; event.preventDefault(); dragDepthRef.current = 0; - setIsDragging(false); + cmRef.current.setDragging(false); const pos = view.posAtCoords({ x: event.clientX, y: event.clientY }) ?? view.state.selection.main.head; - uploadFnRef.current(Array.from(files), pos); + cmRef.current.upload(Array.from(files), pos); return true; }, paste(event, view) { @@ -194,7 +141,7 @@ export default function Editor({ editSlug }: Props) { } if (imageFiles.length === 0) return false; event.preventDefault(); - uploadFnRef.current(imageFiles, view.state.selection.main.head); + cmRef.current.upload(imageFiles, view.state.selection.main.head); return true; }, }), @@ -205,9 +152,9 @@ export default function Editor({ editSlug }: Props) { viewRef.current = view; return () => { view.destroy(); viewRef.current = null; }; - }, []); + }, [dragDepthRef]); - // Hot-swap vim mode via compartment reconfiguration; lazy-load vim module + // Hot-swap vim mode via compartment reconfiguration; lazy-load vim module. useEffect(() => { if (!viewRef.current) return; if (!vimEnabled) { @@ -222,189 +169,16 @@ export default function Editor({ editSlug }: Props) { return () => { cancelled = true; }; }, [vimEnabled]); - // Load existing post for editing - useEffect(() => { - if (!editSlug) return; - getPost(editSlug).then(post => { - if (post.title) setTitle(post.title); - if (post.summary) setSummary(post.summary); - if (post.date) setDate(post.date); - if (post.tags?.length) setTagsInput(post.tags.join(', ')); - setDraft(!!post.draft); - if (post.content && viewRef.current) { - viewRef.current.dispatch({ - changes: { from: 0, to: viewRef.current.state.doc.length, insert: post.content }, - }); - } - }).catch(() => showAlertMsg('Failed to load post.', 'error')); - }, [editSlug]); - - // Auto-derive slug from title until user edits the slug field - useEffect(() => { - if (slugTouched) return; - setSlug(clientSlugify(title)); - }, [title, slugTouched]); - - useEffect(() => { - if (showPreview) updatePreview(); - }, [showPreview, updatePreview]); - - async function triggerAutocomplete(view: EditorView) { - try { - const assets = await getCachedAssets(); - setAutocompleteAssets(assets.slice(0, 8)); - const pos = view.state.selection.main.head; - const coords = view.coordsAtPos(pos); - if (coords) { - const editorRect = editorRef.current?.getBoundingClientRect(); - if (editorRect) { - setAutocompletePos({ - top: coords.bottom - editorRect.top + 4, - left: coords.left - editorRect.left, - }); - } - } - setShowAutocomplete(true); - } catch { /* ignore */ } - } - - function insertAssetMarkdown(asset: Asset) { - const view = viewRef.current; - if (!view) return; - const isImage = /\.(jpg|jpeg|png|webp|gif|svg)$/i.test(asset.name); - const md = isImage ? `![${asset.name}](${asset.url})` : `[${asset.name}](${asset.url})`; - - const pos = view.state.selection.main.head; - const line = view.state.doc.lineAt(pos); - const textBefore = line.text.slice(0, pos - line.from); - const triggerIdx = Math.max(textBefore.lastIndexOf('/'), textBefore.lastIndexOf('!')); - - if (triggerIdx !== -1) { - const from = line.from + triggerIdx; - view.dispatch({ changes: { from, to: pos, insert: md } }); - } else { - view.dispatch({ changes: { from: pos, insert: md } }); - } - view.focus(); - setShowAutocomplete(false); - } - - async function uploadFilesAndInsert(files: File[], insertAt?: number) { - const view = viewRef.current; - if (!view || files.length === 0) return; - const images = files.filter(f => f.type.startsWith('image/')); - if (images.length === 0) { - showAlertMsg('Only image files can be dropped here.', 'error'); - return; - } - setUploadingCount(c => c + images.length); - - // Fire all uploads in parallel; the browser caps per-origin concurrency. - // Insert results in submission order so the markdown reflects user intent. - const uploads = images.map(file => - uploadAsset(file).then( - asset => ({ ok: true as const, asset }), - err => ({ ok: false as const, err }), - ), - ); - - let pos = typeof insertAt === 'number' ? insertAt : view.state.selection.main.head; - const newAssets: Asset[] = []; - for (const promise of uploads) { - const result = await promise; - setUploadingCount(c => Math.max(0, c - 1)); - if (result.ok) { - const { asset } = result; - newAssets.push(asset); - const md = `![${asset.name}](${asset.url})`; - const line = view.state.doc.lineAt(pos); - const atLineEnd = pos === line.to; - const insertText = atLineEnd ? `\n\n${md}\n` : `${md}\n\n`; - view.dispatch({ changes: { from: pos, insert: insertText } }); - pos += insertText.length; - } else { - const e = result.err; - showAlertMsg(e instanceof ApiError ? `Upload failed: ${e.message}` : 'Upload failed.', 'error'); - } - } - - if (newAssets.length > 0) { - assetsCacheRef.current = assetsCacheRef.current - ? [...newAssets, ...assetsCacheRef.current] - : null; - } - view.focus(); - } - function handleAssetSelect(asset: Asset) { insertAssetMarkdown(asset); setShowModal(false); } function closeAssetModal() { - assetsCacheRef.current = null; + assetCache.invalidate(); setShowModal(false); } - async function handleSave() { - const content = viewRef.current?.state.doc.toString() || ''; - if (!title.trim() || !slug || !content) { - showAlertMsg('Title, slug, and body are required.', 'error'); - return; - } - if (!/!\[[^\]]*\]\([^)]+\)/.test(content)) { - showAlertMsg('Add at least one image before saving — drag, paste, or use the Add image button.', 'error'); - return; - } - const tags = tagsInput - .split(',') - .map(t => t.trim()) - .filter(Boolean); - try { - const saved = await savePost({ - slug, - old_slug: originalSlug || null, - title: title.trim(), - date, - summary: summary || null, - tags, - draft, - content, - }); - showAlertMsg('Post saved!', 'success'); - if (saved?.slug && saved.slug !== slug) { - setSlug(saved.slug); - setSlugTouched(true); - } - setOriginalSlug(saved?.slug ?? slug); - } catch (e) { - showAlertMsg(e instanceof ApiError ? `Error: ${e.message}` : 'Failed to connect to server.', 'error'); - } - } - - async function handleDelete() { - const target = originalSlug || slug; - const ok = await confirmDialog({ - title: 'Remove from catalogue?', - message: `“${target}” will be permanently removed. This cannot be undone.`, - confirmLabel: 'Remove', - }); - if (!ok) return; - try { - await deletePost(target); - window.location.href = '/admin'; - } catch { - showAlertMsg('Error deleting post.', 'error'); - } - } - - useEffect(() => { - if (!showAutocomplete) return; - const handler = () => setShowAutocomplete(false); - window.addEventListener('click', handler); - return () => window.removeEventListener('click', handler); - }, [showAutocomplete]); - return ( <> @@ -513,7 +287,7 @@ export default function Editor({ editSlug }: Props) { Drag, paste, or click Add image to insert. At least one image is required. -
+