init elas atelier #1
@@ -8,7 +8,7 @@ import { vim } from '@replit/codemirror-vim';
|
||||
import { search, searchKeymap } from '@codemirror/search';
|
||||
import { closeBrackets } from '@codemirror/autocomplete';
|
||||
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 AssetManager from './AssetManager';
|
||||
|
||||
@@ -79,6 +79,7 @@ export default function Editor({ editSlug }: Props) {
|
||||
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 today = new Date().toISOString().slice(0, 10);
|
||||
const [title, setTitle] = useState('');
|
||||
const [slug, setSlug] = useState(editSlug || '');
|
||||
@@ -96,6 +97,9 @@ export default function Editor({ editSlug }: Props) {
|
||||
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);
|
||||
|
||||
function showAlertMsg(msg: string, type: 'success' | 'error') {
|
||||
setAlert({ msg, type });
|
||||
@@ -110,6 +114,7 @@ export default function Editor({ editSlug }: Props) {
|
||||
}, [showPreview]);
|
||||
|
||||
useEffect(() => { updatePreviewRef.current = updatePreview; }, [updatePreview]);
|
||||
useEffect(() => { uploadFnRef.current = uploadFilesAndInsert; });
|
||||
|
||||
// Initialize CodeMirror once
|
||||
useEffect(() => {
|
||||
@@ -140,6 +145,49 @@ export default function Editor({ editSlug }: Props) {
|
||||
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);
|
||||
}
|
||||
|
||||
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 = ``;
|
||||
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) {
|
||||
insertAssetMarkdown(asset);
|
||||
setShowModal(false);
|
||||
@@ -236,7 +312,7 @@ export default function Editor({ editSlug }: Props) {
|
||||
return;
|
||||
}
|
||||
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;
|
||||
}
|
||||
const tags = tagsInput
|
||||
@@ -394,17 +470,20 @@ export default function Editor({ editSlug }: Props) {
|
||||
{/* 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-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 className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
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
|
||||
? 'bg-mauve/20 text-mauve border-mauve/30'
|
||||
: 'bg-surface0 text-subtext0 border-surface1 hover:text-text'
|
||||
? 'bg-[var(--mauve)]/20 text-[var(--mauve)] border-[var(--mauve)]/30'
|
||||
: 'bg-[var(--surface0)] text-[var(--subtext0)] border-[var(--surface2)] hover:text-[var(--text)]'
|
||||
}`}
|
||||
style={{ borderRadius: 2 }}
|
||||
title={vimEnabled ? 'Vim mode ON' : 'Vim mode OFF'}
|
||||
>
|
||||
{vimEnabled ? 'VIM' : 'vim'}
|
||||
@@ -412,21 +491,23 @@ export default function Editor({ editSlug }: Props) {
|
||||
<button
|
||||
type="button"
|
||||
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
|
||||
? 'bg-blue/20 text-blue border-blue/30'
|
||||
: 'bg-surface0 text-subtext0 border-surface1 hover:text-text'
|
||||
? 'bg-[var(--blue)]/20 text-[var(--blue)] border-[var(--blue)]/30'
|
||||
: 'bg-[var(--surface0)] text-[var(--subtext0)] border-[var(--surface2)] hover:text-[var(--text)]'
|
||||
}`}
|
||||
style={{ borderRadius: 2 }}
|
||||
>
|
||||
{showPreview ? 'Hide Preview' : 'Show Preview'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
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>
|
||||
Assets
|
||||
Add image
|
||||
</button>
|
||||
</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" />
|
||||
|
||||
{/* 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
|
||||
@@ -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)]">
|
||||
<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">Asset library</h2>
|
||||
<p className="text-xs text-[var(--subtext0)] font-display italic mt-1">Click an asset to drop it into the work.</p>
|
||||
<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={() => 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>
|
||||
|
||||
Reference in New Issue
Block a user