migrated to react
This commit is contained in:
@@ -0,0 +1,321 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { EditorView, keymap, placeholder as cmPlaceholder } from '@codemirror/view';
|
||||
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 { getPost, savePost, deletePost, getAssets, ApiError } from '../../../lib/api';
|
||||
import type { Asset } from '../../../lib/types';
|
||||
import AssetManager from './AssetManager';
|
||||
|
||||
interface Props {
|
||||
editSlug?: string;
|
||||
}
|
||||
|
||||
// CodeMirror theme matching the Catppuccin narlblog style
|
||||
const narlblogTheme = EditorView.theme({
|
||||
'&': {
|
||||
backgroundColor: 'var(--crust)',
|
||||
color: 'var(--text)',
|
||||
border: '1px solid var(--surface1)',
|
||||
borderRadius: '0.75rem',
|
||||
fontSize: '14px',
|
||||
},
|
||||
'.cm-content': {
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace',
|
||||
padding: '1rem',
|
||||
caretColor: 'var(--text)',
|
||||
},
|
||||
'.cm-cursor': { borderLeftColor: 'var(--text)' },
|
||||
'.cm-selectionBackground': { backgroundColor: 'var(--surface2) !important' },
|
||||
'&.cm-focused .cm-selectionBackground': { backgroundColor: 'var(--surface2) !important' },
|
||||
'.cm-activeLine': { backgroundColor: 'var(--surface0)' },
|
||||
'.cm-gutters': {
|
||||
backgroundColor: 'var(--mantle)',
|
||||
color: 'var(--overlay0)',
|
||||
border: 'none',
|
||||
},
|
||||
'.cm-activeLineGutter': { backgroundColor: 'var(--surface0)' },
|
||||
}, { dark: true });
|
||||
|
||||
export default function Editor({ editSlug }: Props) {
|
||||
const editorRef = useRef<HTMLDivElement>(null);
|
||||
const viewRef = useRef<EditorView | null>(null);
|
||||
const [slug, setSlug] = useState(editSlug || '');
|
||||
const [summary, setSummary] = useState('');
|
||||
const [originalSlug, setOriginalSlug] = useState(editSlug || '');
|
||||
const [alert, setAlert] = useState<{ msg: string; type: 'success' | 'error' } | null>(null);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [showAutocomplete, setShowAutocomplete] = useState(false);
|
||||
const [autocompleteAssets, setAutocompleteAssets] = useState<Asset[]>([]);
|
||||
const [autocompletePos, setAutocompletePos] = useState({ top: 0, left: 0 });
|
||||
|
||||
function showAlertMsg(msg: string, type: 'success' | 'error') {
|
||||
setAlert({ msg, type });
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
setTimeout(() => setAlert(null), 5000);
|
||||
}
|
||||
|
||||
// Initialize CodeMirror 6
|
||||
useEffect(() => {
|
||||
if (!editorRef.current || viewRef.current) return;
|
||||
|
||||
const state = EditorState.create({
|
||||
doc: '',
|
||||
extensions: [
|
||||
keymap.of([...defaultKeymap, indentWithTab]),
|
||||
markdown({ base: markdownLanguage, codeLanguages: languages }),
|
||||
EditorView.lineWrapping,
|
||||
narlblogTheme,
|
||||
cmPlaceholder('# Hello World\nWrite your markdown here...'),
|
||||
EditorView.updateListener.of(update => {
|
||||
if (!update.docChanged) return;
|
||||
// Check for autocomplete trigger
|
||||
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);
|
||||
}
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const view = new EditorView({ state, parent: editorRef.current });
|
||||
viewRef.current = view;
|
||||
|
||||
return () => { view.destroy(); viewRef.current = null; };
|
||||
}, []);
|
||||
|
||||
// Load existing post for editing
|
||||
useEffect(() => {
|
||||
if (!editSlug) return;
|
||||
getPost(editSlug).then(post => {
|
||||
if (post.summary) setSummary(post.summary);
|
||||
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]);
|
||||
|
||||
async function triggerAutocomplete(view: EditorView) {
|
||||
try {
|
||||
const assets = await getAssets();
|
||||
setAutocompleteAssets(assets.slice(0, 8));
|
||||
// Position near cursor
|
||||
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);
|
||||
}
|
||||
|
||||
function handleAssetSelect(asset: Asset) {
|
||||
insertAssetMarkdown(asset);
|
||||
setShowModal(false);
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
const content = viewRef.current?.state.doc.toString() || '';
|
||||
if (!slug || !content) {
|
||||
showAlertMsg('Title and content are required.', 'error');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await savePost({
|
||||
slug,
|
||||
old_slug: originalSlug || null,
|
||||
summary: summary || null,
|
||||
content,
|
||||
});
|
||||
showAlertMsg('Post saved!', 'success');
|
||||
setOriginalSlug(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(`Delete post "${target}" permanently?`)) return;
|
||||
try {
|
||||
await deletePost(target);
|
||||
window.location.href = '/admin';
|
||||
} catch {
|
||||
showAlertMsg('Error deleting post.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Close autocomplete on outside click
|
||||
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="text-red hover:bg-red/10 px-6 py-3 rounded-lg transition-colors font-bold border border-red/20">
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
<button onClick={handleSave} className="bg-mauve text-crust font-bold py-3 px-8 rounded-lg hover:bg-pink transition-all transform hover:scale-105 whitespace-nowrap">
|
||||
Save Post
|
||||
</button>
|
||||
{originalSlug && (
|
||||
<a
|
||||
href={`/posts/${originalSlug}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="bg-blue text-crust font-bold py-3 px-8 rounded-lg hover:bg-sky transition-all transform hover:scale-105 whitespace-nowrap inline-flex items-center justify-center gap-2"
|
||||
>
|
||||
<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 Post
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Slug */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-subtext1 mb-2">Post Title (URL identifier)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={slug}
|
||||
onChange={e => setSlug(e.target.value)}
|
||||
required
|
||||
placeholder="my-awesome-post"
|
||||
className="w-full bg-crust border border-surface1 rounded-lg px-4 py-3 text-text focus:outline-none focus:border-mauve transition-colors font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Summary */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-subtext1 mb-2">Custom Summary (Optional)</label>
|
||||
<textarea
|
||||
value={summary}
|
||||
onChange={e => setSummary(e.target.value)}
|
||||
rows={2}
|
||||
placeholder="A brief description of this post for the frontpage..."
|
||||
className="w-full bg-crust border border-surface1 rounded-lg px-4 py-3 text-text focus:outline-none focus:border-mauve transition-colors resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Editor */}
|
||||
<div className="relative">
|
||||
<div className="flex flex-col md:flex-row justify-between items-start md:items-end mb-2 gap-2">
|
||||
<label className="block text-sm font-medium text-subtext1 italic">Tip: Type '/' to browse your assets</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowModal(true)}
|
||||
className="text-sm bg-surface0 hover:bg-surface1 text-lavender px-4 py-2 rounded border border-surface1 transition-colors inline-flex items-center gap-2"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" 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>
|
||||
Asset Library
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div ref={editorRef} className="min-h-[500px]" />
|
||||
|
||||
{/* Autocomplete dropdown */}
|
||||
{showAutocomplete && autocompleteAssets.length > 0 && (
|
||||
<div
|
||||
className="absolute z-50 bg-mantle border border-surface1 rounded-lg shadow-2xl max-h-64 overflow-y-auto w-80"
|
||||
style={{ top: autocompletePos.top, left: autocompletePos.left }}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<div className="p-2 text-[10px] text-subtext0 uppercase border-b border-white/5 bg-crust/50">Assets Library</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-mauve/20 cursor-pointer text-sm truncate text-subtext1 hover:text-mauve flex items-center gap-3 transition-colors"
|
||||
>
|
||||
<div className="w-6 h-6 flex-shrink-0 bg-surface0 rounded flex items-center justify-center overflow-hidden">
|
||||
{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">{asset.name}</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Asset Modal */}
|
||||
{showModal && (
|
||||
<div className="fixed inset-0 z-[100] bg-crust/80 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">
|
||||
<header className="p-4 md:p-6 border-b border-white/5 flex justify-between items-center bg-surface0/20">
|
||||
<div>
|
||||
<h2 className="text-xl md:text-2xl font-bold text-mauve">Asset Library</h2>
|
||||
<p className="text-xs text-subtext0">Click 'Insert' to add an asset to your post.</p>
|
||||
</div>
|
||||
<button onClick={() => setShowModal(false)} className="p-2 text-subtext0 hover:text-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>
|
||||
</button>
|
||||
</header>
|
||||
<div className="p-4 md:p-6 overflow-y-auto flex-1 bg-base/50">
|
||||
<AssetManager mode="select" onSelect={handleAssetSelect} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user