VIM!!!
This commit is contained in:
@@ -258,8 +258,18 @@ async fn list_posts(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
|||||||
let path = entry.path();
|
let path = entry.path();
|
||||||
if path.extension().and_then(|e| e.to_str()) == Some("md") {
|
if path.extension().and_then(|e| e.to_str()) == Some("md") {
|
||||||
if let Some(slug) = path.file_stem().and_then(|s| s.to_str()) {
|
if let Some(slug) = path.file_stem().and_then(|s| s.to_str()) {
|
||||||
|
let mut excerpt = String::new();
|
||||||
|
if let Ok(content) = fs::read_to_string(&path) {
|
||||||
|
// Strip basic markdown hashes and get first ~200 chars
|
||||||
|
let clean_content = content.replace("#", "").replace("\n", " ");
|
||||||
|
excerpt = clean_content.chars().take(200).collect::<String>();
|
||||||
|
if clean_content.len() > 200 {
|
||||||
|
excerpt.push_str("...");
|
||||||
|
}
|
||||||
|
}
|
||||||
posts.push(PostInfo {
|
posts.push(PostInfo {
|
||||||
slug: slug.to_string(),
|
slug: slug.to_string(),
|
||||||
|
excerpt: excerpt.trim().to_string(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,40 @@ import Layout from '../../layouts/Layout.astro';
|
|||||||
---
|
---
|
||||||
|
|
||||||
<Layout title="Post Editor">
|
<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;">
|
<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">
|
<header class="mb-12 border-b border-white/5 pb-12 flex justify-between items-center">
|
||||||
<div>
|
<div>
|
||||||
@@ -130,6 +164,16 @@ import Layout from '../../layouts/Layout.astro';
|
|||||||
const autocomplete = document.getElementById('autocomplete');
|
const autocomplete = document.getElementById('autocomplete');
|
||||||
const autocompleteList = document.getElementById('autocomplete-list');
|
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}[] = [];
|
let allAssets: {name: string, url: string}[] = [];
|
||||||
|
|
||||||
async function fetchAssets() {
|
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 isImage = /\.(jpg|jpeg|png|webp|gif|svg)$/i.test(name);
|
||||||
const markdown = isImage ? `` : `[${name}](${url})`;
|
const markdown = isImage ? `` : `[${name}](${url})`;
|
||||||
|
|
||||||
const startPos = contentInput.selectionStart;
|
const doc = editor.getDoc();
|
||||||
const endPos = contentInput.selectionEnd;
|
const cursor = doc.getCursor();
|
||||||
const text = contentInput.value;
|
doc.replaceRange(markdown, cursor);
|
||||||
|
editor.focus();
|
||||||
contentInput.value = text.substring(0, startPos) + markdown + text.substring(endPos);
|
|
||||||
|
|
||||||
const newCursor = startPos + markdown.length;
|
|
||||||
contentInput.focus();
|
|
||||||
contentInput.setSelectionRange(newCursor, newCursor);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Autocomplete on '/' or '!'
|
// Autocomplete on '/' or '!' inside CodeMirror
|
||||||
contentInput?.addEventListener('input', (e) => {
|
editor.on('inputRead', (cm: any, change: any) => {
|
||||||
const cursor = contentInput.selectionStart;
|
if (change.text && change.text.length === 1 && (change.text[0] === '/' || change.text[0] === '!')) {
|
||||||
const textBefore = contentInput.value.substring(0, cursor);
|
|
||||||
const match = textBefore.match(/[\/!]$/);
|
|
||||||
|
|
||||||
if (match) {
|
|
||||||
showAutocomplete();
|
showAutocomplete();
|
||||||
} else {
|
} else if (change.text && change.text[0] === ' ') {
|
||||||
hideAutocomplete();
|
hideAutocomplete();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -234,18 +269,11 @@ import Layout from '../../layouts/Layout.astro';
|
|||||||
|
|
||||||
if (!autocomplete || !autocompleteList) return;
|
if (!autocomplete || !autocompleteList) return;
|
||||||
|
|
||||||
const cursor = contentInput.selectionStart;
|
const cursor = editor.cursorCoords(true, 'local');
|
||||||
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);
|
|
||||||
|
|
||||||
autocomplete.classList.remove('hidden');
|
autocomplete.classList.remove('hidden');
|
||||||
autocomplete.style.top = `${top + 60}px`;
|
autocomplete.style.top = `${cursor.bottom + 10}px`;
|
||||||
autocomplete.style.left = `${left + 20}px`;
|
autocomplete.style.left = `${cursor.left}px`;
|
||||||
|
|
||||||
autocompleteList.innerHTML = '';
|
autocompleteList.innerHTML = '';
|
||||||
allAssets.slice(0, 8).forEach(asset => {
|
allAssets.slice(0, 8).forEach(asset => {
|
||||||
@@ -259,10 +287,15 @@ import Layout from '../../layouts/Layout.astro';
|
|||||||
<span class="truncate">${asset.name}</span>
|
<span class="truncate">${asset.name}</span>
|
||||||
`;
|
`;
|
||||||
li.addEventListener('click', () => {
|
li.addEventListener('click', () => {
|
||||||
const start = contentInput.selectionStart - 1;
|
const doc = editor.getDoc();
|
||||||
const end = contentInput.selectionStart;
|
const cursor = doc.getCursor();
|
||||||
contentInput.value = contentInput.value.substring(0, start) + contentInput.value.substring(end);
|
const line = doc.getLine(cursor.line);
|
||||||
contentInput.setSelectionRange(start, start);
|
const triggerIndex = Math.max(line.lastIndexOf('/'), line.lastIndexOf('!'));
|
||||||
|
|
||||||
|
if (triggerIndex !== -1) {
|
||||||
|
doc.replaceRange('', {line: cursor.line, ch: triggerIndex}, cursor);
|
||||||
|
}
|
||||||
|
|
||||||
insertMarkdown(asset.name, asset.url);
|
insertMarkdown(asset.name, asset.url);
|
||||||
hideAutocomplete();
|
hideAutocomplete();
|
||||||
});
|
});
|
||||||
@@ -274,6 +307,13 @@ import Layout from '../../layouts/Layout.astro';
|
|||||||
autocomplete?.classList.add('hidden');
|
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) => {
|
fileInput?.addEventListener('change', async (e) => {
|
||||||
const file = (e.target as HTMLInputElement).files?.[0];
|
const file = (e.target as HTMLInputElement).files?.[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
@@ -303,7 +343,7 @@ import Layout from '../../layouts/Layout.astro';
|
|||||||
});
|
});
|
||||||
|
|
||||||
saveBtn?.addEventListener('click', async () => {
|
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) {
|
if (!payload.slug || !payload.content) {
|
||||||
showAlert('Slug and content are required.', 'error');
|
showAlert('Slug and content are required.', 'error');
|
||||||
return;
|
return;
|
||||||
@@ -345,9 +385,13 @@ import Layout from '../../layouts/Layout.astro';
|
|||||||
slugInput.value = editSlug;
|
slugInput.value = editSlug;
|
||||||
slugInput.disabled = true; // Protect slug on edit
|
slugInput.disabled = true; // Protect slug on edit
|
||||||
delBtn?.classList.remove('hidden');
|
delBtn?.classList.remove('hidden');
|
||||||
fetch(`/api/posts/${editSlug}`)
|
fetch(`/api/posts/${encodeURIComponent(editSlug)}`)
|
||||||
.then(res => res.json())
|
.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'));
|
.catch(() => showAlert('Failed to load post.', 'error'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ function formatSlug(slug: string) {
|
|||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div class="grid md:grid-cols-2 lg:grid-cols-3 gap-4 md:gap-6">
|
<div class="flex flex-col space-y-6">
|
||||||
{error && (
|
{error && (
|
||||||
<div class="glass p-4 md:p-6 text-red text-center border-red/20 text-sm md:text-base">
|
<div class="glass p-4 md:p-6 text-red text-center border-red/20 text-sm md:text-base">
|
||||||
{error}
|
{error}
|
||||||
@@ -58,17 +58,17 @@ function formatSlug(slug: string) {
|
|||||||
|
|
||||||
{posts.map((post) => (
|
{posts.map((post) => (
|
||||||
<a href={`/posts/${post.slug}`} class="group block">
|
<a href={`/posts/${post.slug}`} class="group block">
|
||||||
<article class="glass p-5 md:p-8 transition-all hover:scale-[1.01] hover:bg-surface0/80 active:scale-[0.99] h-full flex flex-col justify-between">
|
<article class="glass p-5 md:p-8 transition-all hover:scale-[1.01] hover:bg-surface0/80 active:scale-[0.99] flex flex-col md:flex-row justify-between md:items-center gap-4 md:gap-6">
|
||||||
<div>
|
<div class="flex-1">
|
||||||
<h2 class="text-xl md:text-2xl font-bold text-lavender group-hover:text-mauve transition-colors mb-1 md:mb-2 line-clamp-2">
|
<h2 class="text-xl md:text-3xl font-bold text-lavender group-hover:text-mauve transition-colors mb-2 md:mb-3">
|
||||||
{formatSlug(post.slug)}
|
{formatSlug(post.slug)}
|
||||||
</h2>
|
</h2>
|
||||||
<p class="text-subtext0 text-sm md:text-base line-clamp-3">
|
<p class="text-subtext1 text-sm md:text-base leading-relaxed line-clamp-3">
|
||||||
Read more about {formatSlug(post.slug)}...
|
{post.excerpt || `Read more about ${formatSlug(post.slug)}...`}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-mauve opacity-0 group-hover:opacity-100 transition-opacity self-end mt-4">
|
<div class="text-mauve opacity-0 group-hover:opacity-100 transition-opacity self-end md:self-auto shrink-0 hidden md:block">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="md:w-6 md:h-6"><path d="M5 12h14"/><path d="m12 5 7 7-7 7"/></svg>
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="md:w-8 md:h-8"><path d="M5 12h14"/><path d="m12 5 7 7-7 7"/></svg>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -40,23 +40,23 @@ function formatSlug(slug: string) {
|
|||||||
---
|
---
|
||||||
|
|
||||||
<Layout title={post ? formatSlug(post.slug) : 'Post'}>
|
<Layout title={post ? formatSlug(post.slug) : 'Post'}>
|
||||||
<article class="glass p-12 mb-12 animate-in fade-in slide-in-from-bottom-4 duration-700">
|
<article class="glass p-6 md:p-12 mb-8 md:mb-12 animate-in fade-in slide-in-from-bottom-4 duration-700">
|
||||||
<header class="mb-12 border-b border-white/5 pb-12">
|
<header class="mb-8 md:mb-12 border-b border-white/5 pb-8 md:pb-12">
|
||||||
<a href="/" class="text-blue hover:text-sky transition-colors mb-8 inline-flex items-center gap-2 group">
|
<a href="/" class="text-blue hover:text-sky transition-colors mb-6 md:mb-8 inline-flex items-center gap-2 group text-sm md:text-base">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="transition-transform group-hover:-translate-x-1"><path d="m15 18-6-6 6-6"/></svg>
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="md:w-5 md:h-5 transition-transform group-hover:-translate-x-1"><path d="m15 18-6-6 6-6"/></svg>
|
||||||
Back to list
|
Back to list
|
||||||
</a>
|
</a>
|
||||||
{post && (
|
{post && (
|
||||||
<div class="flex justify-between items-start mt-4">
|
<div class="flex flex-col md:flex-row md:justify-between md:items-start mt-2 md:mt-4 gap-4">
|
||||||
<h1 class="text-5xl font-extrabold text-mauve">
|
<h1 class="text-3xl md:text-5xl font-extrabold text-mauve">
|
||||||
{formatSlug(post.slug)}
|
{formatSlug(post.slug)}
|
||||||
</h1>
|
</h1>
|
||||||
<a
|
<a
|
||||||
href={`/admin/editor?edit=${post.slug}`}
|
href={`/admin/editor?edit=${post.slug}`}
|
||||||
id="edit-btn"
|
id="edit-btn"
|
||||||
class="hidden bg-surface0 hover:bg-surface1 text-blue px-4 py-2 rounded border border-surface1 transition-colors items-center gap-2"
|
class="hidden bg-surface0 hover:bg-surface1 text-blue px-3 py-1.5 md:px-4 md:py-2 rounded border border-surface1 transition-colors items-center gap-2 text-sm md:text-base self-start"
|
||||||
>
|
>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/><path d="m15 5 4 4"/></svg>
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="md:w-4 md:h-4"><path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/><path d="m15 5 4 4"/></svg>
|
||||||
Edit
|
Edit
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -85,17 +85,17 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Typography styles for Markdown */
|
/* Typography styles for Markdown */
|
||||||
.prose h1 { @apply text-4xl font-bold mb-6 text-mauve; }
|
.prose h1 { @apply text-3xl md:text-4xl font-bold mb-4 md:mb-6 text-mauve; }
|
||||||
.prose h2 { @apply text-3xl font-semibold mb-4 mt-8 text-lavender; }
|
.prose h2 { @apply text-2xl md:text-3xl font-semibold mb-3 md:mb-4 mt-6 md:mt-8 text-lavender; }
|
||||||
.prose h3 { @apply text-2xl font-medium mb-3 mt-6 text-blue; }
|
.prose h3 { @apply text-xl md:text-2xl font-medium mb-2 md:mb-3 mt-4 md:mt-6 text-blue; }
|
||||||
.prose p { @apply mb-4 leading-relaxed; }
|
.prose p { @apply mb-3 md:mb-4 leading-relaxed text-sm md:text-base; }
|
||||||
.prose a { @apply text-blue hover:text-sky underline underline-offset-4 decoration-2 decoration-blue/30 hover:decoration-sky transition-colors; }
|
.prose a { @apply text-blue hover:text-sky underline underline-offset-4 decoration-2 decoration-blue/30 hover:decoration-sky transition-colors; }
|
||||||
.prose ul { @apply list-disc list-inside mb-4; }
|
.prose ul { @apply list-disc list-inside mb-3 md:mb-4 text-sm md:text-base; }
|
||||||
.prose ol { @apply list-decimal list-inside mb-4; }
|
.prose ol { @apply list-decimal list-inside mb-3 md:mb-4 text-sm md:text-base; }
|
||||||
.prose blockquote { @apply border-l-4 border-surface2 pl-4 italic text-subtext0 my-6; }
|
.prose blockquote { @apply border-l-4 border-surface2 pl-4 italic text-subtext0 my-4 md:my-6 text-sm md:text-base; }
|
||||||
.prose pre { @apply bg-crust p-4 rounded-xl overflow-x-auto border border-white/5 my-6; }
|
.prose pre { @apply bg-crust p-3 md:p-4 rounded-xl overflow-x-auto border border-white/5 my-4 md:my-6 text-xs md:text-sm; }
|
||||||
.prose code { @apply bg-surface0 px-1.5 py-0.5 rounded text-sm font-mono text-peach; }
|
.prose code { @apply bg-surface0 px-1.5 py-0.5 rounded text-xs md:text-sm font-mono text-peach; }
|
||||||
.prose img { @apply max-w-full h-auto rounded-xl shadow-lg border border-white/5 my-8; }
|
.prose img { @apply max-w-full h-auto rounded-xl shadow-lg border border-white/5 my-6 md:my-8; }
|
||||||
|
|
||||||
.glass {
|
.glass {
|
||||||
background-color: color-mix(in srgb, var(--surface0) 60%, transparent);
|
background-color: color-mix(in srgb, var(--surface0) 60%, transparent);
|
||||||
|
|||||||
Reference in New Issue
Block a user