679 lines
28 KiB
TypeScript
679 lines
28 KiB
TypeScript
import { useState, useEffect, useRef, useCallback, lazy, Suspense } from 'react';
|
|
import { EditorView, keymap, placeholder as cmPlaceholder } from '@codemirror/view';
|
|
import { EditorState, Compartment } 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';
|
|
|
|
const AssetManager = lazy(() => import('./AssetManager'));
|
|
|
|
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<HTMLDivElement>(null);
|
|
const viewRef = useRef<EditorView | null>(null);
|
|
const previewRef = useRef<HTMLDivElement>(null);
|
|
const previewTimerRef = useRef<ReturnType<typeof setTimeout> | 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 [alert, setAlert] = useState<{ msg: string; type: 'success' | 'error' } | null>(null);
|
|
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<Asset[]>([]);
|
|
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<Asset[] | null>(null);
|
|
|
|
async function getCachedAssets(): Promise<Asset[]> {
|
|
if (assetsCacheRef.current) return assetsCacheRef.current;
|
|
const assets = await getAssets();
|
|
assetsCacheRef.current = assets;
|
|
return assets;
|
|
}
|
|
|
|
function showAlertMsg(msg: string, type: 'success' | 'error') {
|
|
setAlert({ msg, type });
|
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
setTimeout(() => setAlert(null), 5000);
|
|
}
|
|
|
|
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]);
|
|
|
|
useEffect(() => { updatePreviewRef.current = updatePreview; }, [updatePreview]);
|
|
useEffect(() => { uploadFnRef.current = uploadFilesAndInsert; });
|
|
|
|
// Initialize CodeMirror once
|
|
useEffect(() => {
|
|
if (!editorRef.current || viewRef.current) return;
|
|
|
|
const state = EditorState.create({
|
|
doc: '',
|
|
extensions: [
|
|
vimCompartment.of([]),
|
|
keymap.of([...defaultKeymap, ...searchKeymap, indentWithTab]),
|
|
search(),
|
|
closeBrackets(),
|
|
markdown({ base: markdownLanguage, codeLanguages: languages }),
|
|
EditorView.lineWrapping,
|
|
salonTheme,
|
|
cmPlaceholder('# A title for the work\n\n\n\nNotes, context, materials...'),
|
|
EditorView.updateListener.of(update => {
|
|
if (!update.docChanged) return;
|
|
if (previewTimerRef.current) clearTimeout(previewTimerRef.current);
|
|
previewTimerRef.current = setTimeout(() => updatePreviewRef.current(), 300);
|
|
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);
|
|
} else if (lastChar === ' ' || textBefore.length === 0) {
|
|
setShowAutocomplete(false);
|
|
}
|
|
}),
|
|
EditorView.domEventHandlers({
|
|
dragenter(event) {
|
|
if (!event.dataTransfer?.types.includes('Files')) return false;
|
|
dragDepthRef.current += 1;
|
|
setIsDragging(true);
|
|
return false;
|
|
},
|
|
dragover(event) {
|
|
if (!event.dataTransfer?.types.includes('Files')) return false;
|
|
event.preventDefault();
|
|
return true;
|
|
},
|
|
dragleave() {
|
|
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1);
|
|
if (dragDepthRef.current === 0) setIsDragging(false);
|
|
return false;
|
|
},
|
|
drop(event, view) {
|
|
const files = event.dataTransfer?.files;
|
|
if (!files || files.length === 0) return false;
|
|
event.preventDefault();
|
|
dragDepthRef.current = 0;
|
|
setIsDragging(false);
|
|
const pos = view.posAtCoords({ x: event.clientX, y: event.clientY }) ?? view.state.selection.main.head;
|
|
uploadFnRef.current(Array.from(files), pos);
|
|
return true;
|
|
},
|
|
paste(event, view) {
|
|
const items = event.clipboardData?.items;
|
|
if (!items) return false;
|
|
const imageFiles: File[] = [];
|
|
for (const item of Array.from(items)) {
|
|
if (item.kind === 'file' && item.type.startsWith('image/')) {
|
|
const f = item.getAsFile();
|
|
if (f) imageFiles.push(f);
|
|
}
|
|
}
|
|
if (imageFiles.length === 0) return false;
|
|
event.preventDefault();
|
|
uploadFnRef.current(imageFiles, view.state.selection.main.head);
|
|
return true;
|
|
},
|
|
}),
|
|
],
|
|
});
|
|
|
|
const view = new EditorView({ state, parent: editorRef.current });
|
|
viewRef.current = view;
|
|
|
|
return () => { view.destroy(); viewRef.current = null; };
|
|
}, []);
|
|
|
|
// Hot-swap vim mode via compartment reconfiguration; lazy-load vim module
|
|
useEffect(() => {
|
|
if (!viewRef.current) return;
|
|
if (!vimEnabled) {
|
|
viewRef.current.dispatch({ effects: vimCompartment.reconfigure([]) });
|
|
return;
|
|
}
|
|
let cancelled = false;
|
|
import('@replit/codemirror-vim').then(({ vim }) => {
|
|
if (cancelled || !viewRef.current) return;
|
|
viewRef.current.dispatch({ effects: vimCompartment.reconfigure(vim()) });
|
|
});
|
|
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})`;
|
|
|
|
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 = ``;
|
|
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;
|
|
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;
|
|
if (!confirm(`Remove work "${target}" from the catalogue permanently?`)) 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 (
|
|
<>
|
|
{alert && (
|
|
<div className={`p-4 rounded-lg mb-6 text-sm font-semibold text-center backdrop-blur-sm shadow-lg ${
|
|
alert.type === 'success' ? 'bg-green/15 border border-green/30' : 'bg-red/15 border border-red/30'
|
|
}`} style={{ color: 'var(--text)' }}>
|
|
{alert.msg}
|
|
</div>
|
|
)}
|
|
|
|
{/* Actions bar */}
|
|
<div className="flex flex-wrap gap-4 mb-6">
|
|
{originalSlug && (
|
|
<button onClick={handleDelete} className="btn btn--danger">
|
|
Remove
|
|
</button>
|
|
)}
|
|
<button onClick={handleSave} className="btn btn--primary">
|
|
Save work
|
|
</button>
|
|
{originalSlug && (
|
|
<a
|
|
href={`/posts/${encodeURIComponent(originalSlug)}`}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
className="btn btn--ghost"
|
|
>
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
|
|
View work
|
|
</a>
|
|
)}
|
|
</div>
|
|
|
|
<div className="space-y-6">
|
|
{/* Title + Date */}
|
|
<div className="grid grid-cols-1 md:grid-cols-[1fr_180px] gap-4">
|
|
<div>
|
|
<label className="field-label">Title</label>
|
|
<input
|
|
type="text"
|
|
value={title}
|
|
onChange={e => setTitle(e.target.value)}
|
|
required
|
|
placeholder="Untitled (charcoal on paper)"
|
|
className="field-input"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="field-label">Date</label>
|
|
<input
|
|
type="date"
|
|
value={date}
|
|
onChange={e => setDate(e.target.value)}
|
|
className="field-input font-mono"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Slug */}
|
|
<div>
|
|
<label className="field-label">
|
|
Slug <span className="normal-case tracking-normal text-[var(--overlay0)] italic font-display">— auto-derived from title</span>
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={slug}
|
|
onChange={e => { setSlug(e.target.value); setSlugTouched(true); }}
|
|
required
|
|
placeholder="untitled-charcoal-on-paper"
|
|
className="field-input font-mono"
|
|
/>
|
|
</div>
|
|
|
|
{/* Tags + Draft */}
|
|
<div className="grid grid-cols-1 md:grid-cols-[1fr_auto] gap-4 md:items-end">
|
|
<div>
|
|
<label className="field-label">Tags (comma-separated)</label>
|
|
<input
|
|
type="text"
|
|
value={tagsInput}
|
|
onChange={e => setTagsInput(e.target.value)}
|
|
placeholder="oil, paper, 2026, study"
|
|
className="field-input"
|
|
/>
|
|
</div>
|
|
<label className="flex items-center gap-2 px-4 py-3 bg-[var(--surface0)]/60 border border-[var(--surface2)] cursor-pointer hover:border-[var(--peach)] transition-colors select-none" style={{ borderRadius: 1 }}>
|
|
<input
|
|
type="checkbox"
|
|
checked={draft}
|
|
onChange={e => setDraft(e.target.checked)}
|
|
className="accent-[var(--peach)]"
|
|
/>
|
|
<span className="text-sm font-display italic text-[var(--subtext1)]">Sketch (draft)</span>
|
|
</label>
|
|
</div>
|
|
|
|
{/* Summary */}
|
|
<div>
|
|
<label className="field-label">Caption (optional)</label>
|
|
<textarea
|
|
value={summary}
|
|
onChange={e => setSummary(e.target.value)}
|
|
rows={2}
|
|
placeholder="A short caption for the catalogue index..."
|
|
className="field-input resize-none"
|
|
/>
|
|
</div>
|
|
|
|
{/* Editor Toolbar */}
|
|
<div className="flex flex-col md:flex-row justify-between items-start md:items-end gap-2">
|
|
<div className="flex items-center gap-3">
|
|
<label className="block text-sm font-display italic text-[var(--subtext1)]">
|
|
Drag, paste, or click <span className="text-[var(--mauve)]">Add image</span> to insert. At least one image is required.
|
|
</label>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => setVimEnabled(v => !v)}
|
|
className={`btn btn--ghost btn--sm${vimEnabled ? ' is-active' : ''}`}
|
|
title={vimEnabled ? 'Vim mode ON' : 'Vim mode OFF'}
|
|
>
|
|
{vimEnabled ? 'VIM' : 'vim'}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowPreview(p => !p)}
|
|
className={`btn btn--ghost btn--sm${showPreview ? ' is-active' : ''}`}
|
|
>
|
|
{showPreview ? 'Hide Preview' : 'Show Preview'}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowModal(true)}
|
|
className="btn btn--primary btn--sm"
|
|
title="Insert an image — also: drag an image into the editor, or paste from clipboard"
|
|
>
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/></svg>
|
|
Add image
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Mobile-only Edit | Preview tab bar */}
|
|
{showPreview && (
|
|
<div className="md:hidden flex gap-2" role="tablist" aria-label="Editor view">
|
|
<button
|
|
type="button"
|
|
role="tab"
|
|
aria-selected={mobileView === 'edit'}
|
|
onClick={() => setMobileView('edit')}
|
|
className={`btn btn--ghost btn--sm flex-1${mobileView === 'edit' ? ' is-active' : ''}`}
|
|
>
|
|
Edit
|
|
</button>
|
|
<button
|
|
type="button"
|
|
role="tab"
|
|
aria-selected={mobileView === 'preview'}
|
|
onClick={() => setMobileView('preview')}
|
|
className={`btn btn--ghost btn--sm flex-1${mobileView === 'preview' ? ' is-active' : ''}`}
|
|
>
|
|
Preview
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Editor + Preview — desktop side-by-side, mobile single-pane via tabs */}
|
|
<div className={`relative ${showPreview ? 'md:grid md:grid-cols-2 md:gap-4 md:items-stretch' : ''}`}>
|
|
<div
|
|
className={`relative min-h-[500px] md:flex md:flex-col ${
|
|
showPreview && mobileView === 'preview' ? 'hidden' : ''
|
|
}`}
|
|
>
|
|
<div ref={editorRef} className="min-h-[500px] md:flex-1 md:min-h-0" />
|
|
|
|
{/* Drop overlay */}
|
|
{isDragging && (
|
|
<div
|
|
className="absolute inset-0 z-40 flex items-center justify-center pointer-events-none border-2 border-dashed border-[var(--mauve)] bg-[var(--mauve)]/10 backdrop-blur-[1px]"
|
|
style={{ borderRadius: 2 }}
|
|
>
|
|
<div className="text-center">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" className="mx-auto mb-3 text-[var(--mauve)]"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" x2="12" y1="3" y2="15"/></svg>
|
|
<p className="font-display italic text-xl text-[var(--text)]">Drop image to insert</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Uploading indicator */}
|
|
{uploadingCount > 0 && (
|
|
<div
|
|
className="absolute top-3 right-3 z-30 px-3 py-1.5 bg-[var(--surface0)] border border-[var(--mauve)]/40 text-xs font-display italic text-[var(--text)] flex items-center gap-2 shadow-lg"
|
|
style={{ borderRadius: 2 }}
|
|
>
|
|
<span className="w-2 h-2 rounded-full bg-[var(--mauve)] animate-pulse" />
|
|
Uploading {uploadingCount} image{uploadingCount === 1 ? '' : 's'}…
|
|
</div>
|
|
)}
|
|
|
|
{/* Autocomplete dropdown */}
|
|
{showAutocomplete && autocompleteAssets.length > 0 && (
|
|
<div
|
|
className="absolute z-50 bg-[var(--base)] border border-[var(--surface2)] shadow-2xl max-h-64 overflow-y-auto w-80"
|
|
style={{ top: autocompletePos.top, left: autocompletePos.left, borderRadius: 2 }}
|
|
onClick={e => e.stopPropagation()}
|
|
>
|
|
<div className="p-2 text-[10px] text-[var(--subtext0)] uppercase tracking-[0.2em] border-b border-[var(--surface2)]/60 bg-[var(--surface0)]/60 font-display italic">Assets</div>
|
|
<ul className="py-1">
|
|
{autocompleteAssets.map(asset => {
|
|
const img = /\.(jpg|jpeg|png|webp|gif|svg)$/i.test(asset.name);
|
|
return (
|
|
<li
|
|
key={asset.name}
|
|
onClick={() => insertAssetMarkdown(asset)}
|
|
className="px-4 py-2 hover:bg-[var(--mauve)]/15 cursor-pointer text-sm truncate text-[var(--subtext1)] hover:text-[var(--mauve)] flex items-center gap-3 transition-colors"
|
|
>
|
|
<div className="w-6 h-6 flex-shrink-0 bg-[var(--surface0)] border border-[var(--surface2)] flex items-center justify-center overflow-hidden" style={{ borderRadius: 1 }}>
|
|
{img ? (
|
|
<img src={asset.url} className="w-full h-full object-cover" alt="" />
|
|
) : (
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/></svg>
|
|
)}
|
|
</div>
|
|
<span className="truncate font-mono text-xs">{asset.name}</span>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Live Preview — stretches to match editor height on desktop, full-pane via tabs on mobile */}
|
|
{showPreview && (
|
|
<div
|
|
className={`border border-[var(--surface2)] bg-[var(--base)] overflow-y-auto flex-col md:flex md:min-h-0 ${
|
|
mobileView === 'preview' ? 'flex min-h-[60vh]' : 'hidden'
|
|
}`}
|
|
style={{ borderRadius: 2 }}
|
|
>
|
|
<div className="sticky top-0 bg-[var(--surface0)] px-4 py-2 text-xs text-[var(--subtext0)] uppercase tracking-[0.2em] border-b border-[var(--surface2)] z-10 font-display italic">
|
|
Preview
|
|
</div>
|
|
<div ref={previewRef} className="prose max-w-none p-4 md:p-6 flex-1" />
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Asset Modal */}
|
|
{showModal && (
|
|
<div className="fixed inset-0 z-[100] bg-[var(--crust)]/55 backdrop-blur-sm flex items-center justify-center p-4 md:p-6">
|
|
<div className="glass w-full max-w-5xl max-h-[90vh] flex flex-col overflow-hidden bg-[var(--base)]">
|
|
<header className="p-4 md:p-6 border-b border-[var(--surface2)]/60 flex justify-between items-center bg-[var(--surface0)]/50">
|
|
<div>
|
|
<h2 className="font-display italic text-2xl md:text-3xl text-[var(--text)] leading-tight">Add image</h2>
|
|
<p className="text-xs text-[var(--subtext0)] font-display italic mt-1">Click an image to insert it. Drag new files in to upload.</p>
|
|
</div>
|
|
<button onClick={closeAssetModal} className="btn btn--ghost btn--icon btn--sm">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
|
|
</button>
|
|
</header>
|
|
<div className="p-4 md:p-6 overflow-y-auto flex-1 bg-[var(--base)]/50">
|
|
<Suspense fallback={<div className="text-center py-12 font-display italic text-[var(--subtext0)]">Loading assets…</div>}>
|
|
<AssetManager mode="select" onSelect={handleAssetSelect} />
|
|
</Suspense>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
}
|