init elas atelier #1

Merged
nvrl merged 82 commits from ela into main 2026-05-18 13:55:42 +02:00
Showing only changes of commit b0f2634346 - Show all commits
+118 -13
View File
@@ -8,7 +8,7 @@ import { vim } from '@replit/codemirror-vim';
import { search, searchKeymap } from '@codemirror/search'; import { search, searchKeymap } from '@codemirror/search';
import { closeBrackets } from '@codemirror/autocomplete'; import { closeBrackets } from '@codemirror/autocomplete';
import { renderMarkdown } from '../../../lib/markdown'; import { renderMarkdown } from '../../../lib/markdown';
import { getPost, savePost, deletePost, getAssets, ApiError } from '../../../lib/api'; import { getPost, savePost, deletePost, getAssets, uploadAsset, ApiError } from '../../../lib/api';
import type { Asset } from '../../../lib/types'; import type { Asset } from '../../../lib/types';
import AssetManager from './AssetManager'; import AssetManager from './AssetManager';
@@ -79,6 +79,7 @@ export default function Editor({ editSlug }: Props) {
const previewRef = useRef<HTMLDivElement>(null); const previewRef = useRef<HTMLDivElement>(null);
const previewTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const previewTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const updatePreviewRef = useRef<() => void>(() => {}); const updatePreviewRef = useRef<() => void>(() => {});
const uploadFnRef = useRef<(files: File[], insertAt?: number) => void>(() => {});
const today = new Date().toISOString().slice(0, 10); const today = new Date().toISOString().slice(0, 10);
const [title, setTitle] = useState(''); const [title, setTitle] = useState('');
const [slug, setSlug] = useState(editSlug || ''); const [slug, setSlug] = useState(editSlug || '');
@@ -96,6 +97,9 @@ export default function Editor({ editSlug }: Props) {
const [showAutocomplete, setShowAutocomplete] = useState(false); const [showAutocomplete, setShowAutocomplete] = useState(false);
const [autocompleteAssets, setAutocompleteAssets] = useState<Asset[]>([]); const [autocompleteAssets, setAutocompleteAssets] = useState<Asset[]>([]);
const [autocompletePos, setAutocompletePos] = useState({ top: 0, left: 0 }); const [autocompletePos, setAutocompletePos] = useState({ top: 0, left: 0 });
const [isDragging, setIsDragging] = useState(false);
const [uploadingCount, setUploadingCount] = useState(0);
const dragDepthRef = useRef(0);
function showAlertMsg(msg: string, type: 'success' | 'error') { function showAlertMsg(msg: string, type: 'success' | 'error') {
setAlert({ msg, type }); setAlert({ msg, type });
@@ -110,6 +114,7 @@ export default function Editor({ editSlug }: Props) {
}, [showPreview]); }, [showPreview]);
useEffect(() => { updatePreviewRef.current = updatePreview; }, [updatePreview]); useEffect(() => { updatePreviewRef.current = updatePreview; }, [updatePreview]);
useEffect(() => { uploadFnRef.current = uploadFilesAndInsert; });
// Initialize CodeMirror once // Initialize CodeMirror once
useEffect(() => { useEffect(() => {
@@ -140,6 +145,49 @@ export default function Editor({ editSlug }: Props) {
setShowAutocomplete(false); 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;
},
}),
], ],
}); });
@@ -224,6 +272,34 @@ export default function Editor({ editSlug }: Props) {
setShowAutocomplete(false); 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);
let pos = typeof insertAt === 'number' ? insertAt : view.state.selection.main.head;
for (const file of images) {
try {
const asset = await uploadAsset(file);
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;
} catch (e) {
showAlertMsg(e instanceof ApiError ? `Upload failed: ${e.message}` : 'Upload failed.', 'error');
} finally {
setUploadingCount(c => Math.max(0, c - 1));
}
}
view.focus();
}
function handleAssetSelect(asset: Asset) { function handleAssetSelect(asset: Asset) {
insertAssetMarkdown(asset); insertAssetMarkdown(asset);
setShowModal(false); setShowModal(false);
@@ -236,7 +312,7 @@ export default function Editor({ editSlug }: Props) {
return; return;
} }
if (!/!\[[^\]]*\]\([^)]+\)/.test(content)) { if (!/!\[[^\]]*\]\([^)]+\)/.test(content)) {
showAlertMsg('A gallery work must include at least one image — insert via the Assets panel or type "!".', 'error'); showAlertMsg('Add at least one image before saving — drag, paste, or use the Add image button.', 'error');
return; return;
} }
const tags = tagsInput const tags = tagsInput
@@ -394,17 +470,20 @@ export default function Editor({ editSlug }: Props) {
{/* Editor Toolbar */} {/* Editor Toolbar */}
<div className="flex flex-col md:flex-row justify-between items-start md:items-end gap-2"> <div className="flex flex-col md:flex-row justify-between items-start md:items-end gap-2">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<label className="block text-sm font-medium text-subtext1 italic">Type '/' or '!' to insert an image · at least one image is required</label> <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>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button <button
type="button" type="button"
onClick={() => setVimEnabled(v => !v)} onClick={() => setVimEnabled(v => !v)}
className={`text-xs px-3 py-1.5 rounded border transition-colors font-mono ${ className={`text-xs px-3 py-1.5 border transition-colors font-mono ${
vimEnabled vimEnabled
? 'bg-mauve/20 text-mauve border-mauve/30' ? 'bg-[var(--mauve)]/20 text-[var(--mauve)] border-[var(--mauve)]/30'
: 'bg-surface0 text-subtext0 border-surface1 hover:text-text' : 'bg-[var(--surface0)] text-[var(--subtext0)] border-[var(--surface2)] hover:text-[var(--text)]'
}`} }`}
style={{ borderRadius: 2 }}
title={vimEnabled ? 'Vim mode ON' : 'Vim mode OFF'} title={vimEnabled ? 'Vim mode ON' : 'Vim mode OFF'}
> >
{vimEnabled ? 'VIM' : 'vim'} {vimEnabled ? 'VIM' : 'vim'}
@@ -412,21 +491,23 @@ export default function Editor({ editSlug }: Props) {
<button <button
type="button" type="button"
onClick={() => setShowPreview(p => !p)} onClick={() => setShowPreview(p => !p)}
className={`text-xs px-3 py-1.5 rounded border transition-colors ${ className={`text-xs px-3 py-1.5 border transition-colors ${
showPreview showPreview
? 'bg-blue/20 text-blue border-blue/30' ? 'bg-[var(--blue)]/20 text-[var(--blue)] border-[var(--blue)]/30'
: 'bg-surface0 text-subtext0 border-surface1 hover:text-text' : 'bg-[var(--surface0)] text-[var(--subtext0)] border-[var(--surface2)] hover:text-[var(--text)]'
}`} }`}
style={{ borderRadius: 2 }}
> >
{showPreview ? 'Hide Preview' : 'Show Preview'} {showPreview ? 'Hide Preview' : 'Show Preview'}
</button> </button>
<button <button
type="button" type="button"
onClick={() => setShowModal(true)} onClick={() => setShowModal(true)}
className="text-sm bg-surface0 hover:bg-surface1 text-lavender px-4 py-1.5 rounded border border-surface1 transition-colors inline-flex items-center gap-2" className="btn-stamp text-sm py-1.5 px-4"
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> <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>
Assets Add image
</button> </button>
</div> </div>
</div> </div>
@@ -472,6 +553,30 @@ export default function Editor({ editSlug }: Props) {
> >
<div ref={editorRef} className="min-h-[500px] md:flex-1 md:min-h-0" /> <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 */} {/* Autocomplete dropdown */}
{showAutocomplete && autocompleteAssets.length > 0 && ( {showAutocomplete && autocompleteAssets.length > 0 && (
<div <div
@@ -528,8 +633,8 @@ export default function Editor({ editSlug }: Props) {
<div className="glass w-full max-w-5xl max-h-[90vh] flex flex-col overflow-hidden bg-[var(--base)]"> <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"> <header className="p-4 md:p-6 border-b border-[var(--surface2)]/60 flex justify-between items-center bg-[var(--surface0)]/50">
<div> <div>
<h2 className="font-display italic text-2xl md:text-3xl text-[var(--text)] leading-tight">Asset library</h2> <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 asset to drop it into the work.</p> <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> </div>
<button onClick={() => setShowModal(false)} className="p-2 text-[var(--subtext0)] hover:text-[var(--red)] transition-colors"> <button onClick={() => setShowModal(false)} className="p-2 text-[var(--subtext0)] hover:text-[var(--red)] transition-colors">
<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> <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>