This commit is contained in:
2026-03-25 16:02:35 +01:00
parent 587954b164
commit 34d7b42180
5 changed files with 114 additions and 60 deletions

View File

@@ -3,6 +3,40 @@ import Layout from '../../layouts/Layout.astro';
---
<Layout title="Post Editor">
<!-- CodeMirror Assets -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/codemirror.min.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/theme/catppuccin.min.css">
<script is:inline src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/codemirror.min.js"></script>
<script is:inline src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/mode/markdown/markdown.min.js"></script>
<script is:inline src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/keymap/vim.min.js"></script>
<style is:global>
.CodeMirror {
height: auto;
min-height: 400px;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
border-radius: 0.5rem;
background: var(--crust);
color: var(--text);
border: 1px solid var(--surface1);
padding: 1rem;
}
.CodeMirror-focused {
border-color: var(--mauve);
}
.CodeMirror-scroll {
min-height: 400px;
}
.cm-s-default .cm-header { color: var(--mauve); font-weight: bold; }
.cm-s-default .cm-string { color: var(--green); }
.cm-s-default .cm-link { color: var(--blue); text-decoration: underline; }
.cm-s-default .cm-url { color: var(--sky); }
.cm-s-default .cm-comment { color: var(--subtext0); font-style: italic; }
.cm-s-default .cm-quote { color: var(--peach); }
.cm-fat-cursor .CodeMirror-cursor { background: var(--text); }
.cm-animate-fat-cursor { background-color: var(--text); }
</style>
<div class="glass p-12 mb-12" id="editor-content" style="display: none;">
<header class="mb-12 border-b border-white/5 pb-12 flex justify-between items-center">
<div>
@@ -130,6 +164,16 @@ import Layout from '../../layouts/Layout.astro';
const autocomplete = document.getElementById('autocomplete');
const autocompleteList = document.getElementById('autocomplete-list');
// Initialize CodeMirror
// @ts-ignore
const editor = CodeMirror.fromTextArea(contentInput, {
mode: 'markdown',
theme: 'default',
lineWrapping: true,
keyMap: window.innerWidth > 768 ? 'vim' : 'default',
extraKeys: {"Enter": "newlineAndIndentContinueMarkdownList"}
});
let allAssets: {name: string, url: string}[] = [];
async function fetchAssets() {
@@ -204,26 +248,17 @@ import Layout from '../../layouts/Layout.astro';
const isImage = /\.(jpg|jpeg|png|webp|gif|svg)$/i.test(name);
const markdown = isImage ? `![${name}](${url})` : `[${name}](${url})`;
const startPos = contentInput.selectionStart;
const endPos = contentInput.selectionEnd;
const text = contentInput.value;
contentInput.value = text.substring(0, startPos) + markdown + text.substring(endPos);
const newCursor = startPos + markdown.length;
contentInput.focus();
contentInput.setSelectionRange(newCursor, newCursor);
const doc = editor.getDoc();
const cursor = doc.getCursor();
doc.replaceRange(markdown, cursor);
editor.focus();
}
// Autocomplete on '/' or '!'
contentInput?.addEventListener('input', (e) => {
const cursor = contentInput.selectionStart;
const textBefore = contentInput.value.substring(0, cursor);
const match = textBefore.match(/[\/!]$/);
if (match) {
// Autocomplete on '/' or '!' inside CodeMirror
editor.on('inputRead', (cm: any, change: any) => {
if (change.text && change.text.length === 1 && (change.text[0] === '/' || change.text[0] === '!')) {
showAutocomplete();
} else {
} else if (change.text && change.text[0] === ' ') {
hideAutocomplete();
}
});
@@ -234,18 +269,11 @@ import Layout from '../../layouts/Layout.astro';
if (!autocomplete || !autocompleteList) return;
const cursor = contentInput.selectionStart;
const lines = contentInput.value.substring(0, cursor).split('\n');
const currentLine = lines.length;
const currentCol = lines[lines.length - 1].length;
// Approximate position
const top = Math.min(contentInput.offsetHeight - 200, currentLine * 24);
const left = Math.min(contentInput.offsetWidth - 320, currentCol * 8);
const cursor = editor.cursorCoords(true, 'local');
autocomplete.classList.remove('hidden');
autocomplete.style.top = `${top + 60}px`;
autocomplete.style.left = `${left + 20}px`;
autocomplete.style.top = `${cursor.bottom + 10}px`;
autocomplete.style.left = `${cursor.left}px`;
autocompleteList.innerHTML = '';
allAssets.slice(0, 8).forEach(asset => {
@@ -259,10 +287,15 @@ import Layout from '../../layouts/Layout.astro';
<span class="truncate">${asset.name}</span>
`;
li.addEventListener('click', () => {
const start = contentInput.selectionStart - 1;
const end = contentInput.selectionStart;
contentInput.value = contentInput.value.substring(0, start) + contentInput.value.substring(end);
contentInput.setSelectionRange(start, start);
const doc = editor.getDoc();
const cursor = doc.getCursor();
const line = doc.getLine(cursor.line);
const triggerIndex = Math.max(line.lastIndexOf('/'), line.lastIndexOf('!'));
if (triggerIndex !== -1) {
doc.replaceRange('', {line: cursor.line, ch: triggerIndex}, cursor);
}
insertMarkdown(asset.name, asset.url);
hideAutocomplete();
});
@@ -274,6 +307,13 @@ import Layout from '../../layouts/Layout.astro';
autocomplete?.classList.add('hidden');
}
// Close autocomplete on click outside
window.addEventListener('click', (e) => {
if (!autocomplete?.contains(e.target as Node)) {
hideAutocomplete();
}
});
fileInput?.addEventListener('change', async (e) => {
const file = (e.target as HTMLInputElement).files?.[0];
if (!file) return;
@@ -303,7 +343,7 @@ import Layout from '../../layouts/Layout.astro';
});
saveBtn?.addEventListener('click', async () => {
const payload = { slug: slugInput.value, content: contentInput.value };
const payload = { slug: slugInput.value, content: editor.getValue() };
if (!payload.slug || !payload.content) {
showAlert('Slug and content are required.', 'error');
return;
@@ -345,9 +385,13 @@ import Layout from '../../layouts/Layout.astro';
slugInput.value = editSlug;
slugInput.disabled = true; // Protect slug on edit
delBtn?.classList.remove('hidden');
fetch(`/api/posts/${editSlug}`)
fetch(`/api/posts/${encodeURIComponent(editSlug)}`)
.then(res => res.json())
.then(data => { if (data.content) contentInput.value = data.content; })
.then(data => {
if (data.content) {
editor.setValue(data.content);
}
})
.catch(() => showAlert('Failed to load post.', 'error'));
}