ui redesign, markdown fix + metadata and auth header

This commit is contained in:
2026-05-09 05:09:07 +02:00
parent 7f8a66f360
commit bc6a34cf1f
42 changed files with 3093 additions and 517 deletions
+34 -6
View File
@@ -1,25 +1,53 @@
---
interface Props {
slug: string;
date: string;
excerpt?: string;
tags?: string[];
draft?: boolean;
readingTime: number;
formatSlug: (slug: string) => string;
}
const { slug, excerpt, formatSlug } = Astro.props;
const { slug, date, excerpt, tags = [], draft = false, readingTime, formatSlug } = Astro.props;
const formattedDate = new Date(date).toLocaleDateString('en-US', {
year: 'numeric', month: 'short', day: 'numeric'
});
---
<a href={`/posts/${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] flex flex-col md:flex-row justify-between md:items-center gap-4 md:gap-6">
<div class="flex-1">
<h2 class="text-xl md:text-3xl font-bold text-lavender group-hover:text-mauve transition-colors mb-2 md:mb-3">
<article class="glass p-5 md:p-8 transition-colors hover:bg-surface0/80 flex flex-col md:flex-row justify-between md:items-center gap-4 md:gap-6">
<div class="flex-1 min-w-0">
<div class="flex flex-wrap items-center gap-x-3 gap-y-1 mb-2 text-xs text-subtext0">
<time datetime={date}>{formattedDate}</time>
<span class="opacity-50">·</span>
<span>{readingTime} min read</span>
{draft && (
<>
<span class="opacity-50">·</span>
<span class="text-peach uppercase tracking-wide font-semibold">Draft</span>
</>
)}
</div>
<h2 class="text-xl md:text-2xl font-semibold text-lavender group-hover:text-mauve transition-colors mb-2">
{formatSlug(slug)}
</h2>
<p class="text-text text-sm md:text-base leading-relaxed line-clamp-3" style="color: var(--text) !important;">
<p class="text-subtext1 text-sm md:text-base leading-relaxed line-clamp-2">
{excerpt || `Read more about ${formatSlug(slug)}...`}
</p>
{tags.length > 0 && (
<div class="flex flex-wrap gap-2 mt-3">
{tags.map(tag => (
<span class="text-[10px] uppercase tracking-wider px-2 py-0.5 rounded-full bg-surface0 text-subtext0 border border-surface1">
{tag}
</span>
))}
</div>
)}
</div>
<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="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>
<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"><path d="M5 12h14"/><path d="m12 5 7 7-7 7"/></svg>
</div>
</article>
</a>
@@ -1,61 +0,0 @@
import { useEffect } from 'react';
import hljs from 'highlight.js';
import katex from 'katex';
import 'katex/dist/katex.min.css';
function renderMath(element: HTMLElement) {
const delimiters = [
{ left: '$$', right: '$$', display: true },
{ left: '$', right: '$', display: false },
{ left: '\\(', right: '\\)', display: false },
{ left: '\\[', right: '\\]', display: true },
];
const walk = (node: Node) => {
if (node.nodeType === Node.ELEMENT_NODE) {
const el = node as HTMLElement;
if (el.tagName === 'CODE' || el.tagName === 'PRE') return;
for (const child of Array.from(el.childNodes)) walk(child);
} else if (node.nodeType === Node.TEXT_NODE) {
const text = node.textContent || '';
for (const { left, right, display } of delimiters) {
const idx = text.indexOf(left);
if (idx === -1) continue;
const end = text.indexOf(right, idx + left.length);
if (end === -1) continue;
const tex = text.slice(idx + left.length, end);
try {
const rendered = katex.renderToString(tex, { displayMode: display, throwOnError: false });
const span = document.createElement('span');
span.innerHTML = rendered;
const range = document.createRange();
range.setStart(node, idx);
range.setEnd(node, end + right.length);
range.deleteContents();
range.insertNode(span);
} catch { /* skip invalid tex */ }
return;
}
}
};
for (let i = 0; i < 3; i++) walk(element);
}
interface Props {
containerId: string;
}
export default function PostEnhancer({ containerId }: Props) {
useEffect(() => {
const el = document.getElementById(containerId);
if (!el) return;
renderMath(el);
el.querySelectorAll<HTMLElement>('pre code').forEach((block) => {
hljs.highlightElement(block);
});
}, [containerId]);
return null;
}
@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useRef } from 'react';
const THEMES = [
{ value: 'mocha', label: 'Mocha' },
@@ -19,10 +19,23 @@ export default function ThemeSwitcher({ defaultTheme = 'mocha' }: Props) {
}
return defaultTheme;
});
const [toast, setToast] = useState<string | null>(null);
const isFirst = useRef(true);
useEffect(() => {
document.documentElement.className = theme;
const html = document.documentElement;
THEMES.forEach(t => html.classList.remove(t.value));
html.classList.add(theme);
localStorage.setItem('user-theme', theme);
if (isFirst.current) {
isFirst.current = false;
return;
}
const label = THEMES.find(t => t.value === theme)?.label ?? theme;
setToast(`Theme: ${label}`);
const id = setTimeout(() => setToast(null), 1200);
return () => clearTimeout(id);
}, [theme]);
return (
@@ -30,15 +43,17 @@ export default function ThemeSwitcher({ defaultTheme = 'mocha' }: Props) {
<select
value={theme}
onChange={(e) => setTheme(e.target.value)}
className="appearance-none bg-surface0/50 text-text border border-surface1 rounded-lg px-3 py-1.5 text-xs focus:outline-none focus:border-mauve transition-all cursor-pointer hover:bg-surface0 pr-8 shadow-sm"
aria-label="Theme"
className="appearance-none bg-surface0/50 text-text border border-surface1 rounded-lg px-3 py-1.5 text-xs focus:outline-none focus:border-mauve transition-colors cursor-pointer hover:bg-surface0 pr-8 shadow-sm"
>
{THEMES.map(t => (
<option key={t.value} value={t.value}>{t.label}</option>
))}
</select>
<div className="pointer-events-none absolute inset-y-0 right-0 flex items-center px-2 text-subtext0">
<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="m6 9 6 6 6-9"/></svg>
<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="m6 9 6 6 6-6"/></svg>
</div>
{toast && <div className="toast" role="status">{toast}</div>}
</div>
);
}
@@ -85,7 +85,7 @@ export default function AssetManager({ mode = 'manage', onSelect }: Props) {
) : (
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-5 gap-4">
{assets.map(asset => (
<div key={asset.name} className="group relative aspect-square bg-crust rounded-xl overflow-hidden border border-white/5 transition-all hover:scale-105 shadow-lg flex flex-col">
<div key={asset.name} className="group relative aspect-square bg-crust rounded-xl overflow-hidden border border-white/5 hover:border-mauve/40 transition-colors shadow-lg flex flex-col">
<div className="flex-1 overflow-hidden bg-surface0/20 relative cursor-pointer">
{isImage(asset.name) ? (
<img src={asset.url} className="w-full h-full object-cover opacity-80 group-hover:opacity-100 transition-opacity" alt={asset.name} />
@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, type ReactElement } from 'react';
import { useAuth } from '../../../stores/auth';
import { getPosts, deletePost, ApiError } from '../../../lib/api';
import type { Post } from '../../../lib/types';
@@ -35,7 +35,6 @@ export default function Dashboard() {
function handleLogout() {
logout();
window.location.href = '/';
}
return (
@@ -94,7 +93,7 @@ export default function Dashboard() {
);
}
const ICONS: Record<string, JSX.Element> = {
const ICONS: Record<string, ReactElement> = {
write: <svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 1 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>,
assets: <svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" 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>,
settings: <svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/></svg>,
+61 -64
View File
@@ -7,9 +7,7 @@ import { defaultKeymap, indentWithTab } from '@codemirror/commands';
import { vim } from '@replit/codemirror-vim';
import { search, searchKeymap } from '@codemirror/search';
import { closeBrackets } from '@codemirror/autocomplete';
import { marked } from 'marked';
import hljs from 'highlight.js';
import katex from 'katex';
import { renderMarkdown } from '../../../lib/markdown';
import { getPost, savePost, deletePost, getAssets, ApiError } from '../../../lib/api';
import type { Asset } from '../../../lib/types';
import AssetManager from './AssetManager';
@@ -58,43 +56,6 @@ const narlblogTheme = EditorView.theme({
},
}, { dark: true });
function renderMathInElement(element: HTMLElement) {
const delimiters = [
{ left: '$$', right: '$$', display: true },
{ left: '$', right: '$', display: false },
{ left: '\\(', right: '\\)', display: false },
{ left: '\\[', right: '\\]', display: true },
];
const walk = (node: Node) => {
if (node.nodeType === Node.ELEMENT_NODE) {
const el = node as HTMLElement;
if (el.tagName === 'CODE' || el.tagName === 'PRE') return;
for (const child of Array.from(el.childNodes)) walk(child);
} else if (node.nodeType === Node.TEXT_NODE) {
const text = node.textContent || '';
for (const { left, right, display } of delimiters) {
const idx = text.indexOf(left);
if (idx === -1) continue;
const end = text.indexOf(right, idx + left.length);
if (end === -1) continue;
const tex = text.slice(idx + left.length, end);
try {
const rendered = katex.renderToString(tex, { displayMode: display, throwOnError: false });
const span = document.createElement('span');
span.innerHTML = rendered;
const range = document.createRange();
range.setStart(node, idx);
range.setEnd(node, end + right.length);
range.deleteContents();
range.insertNode(span);
} catch { /* skip */ }
return;
}
}
};
for (let i = 0; i < 3; i++) walk(element);
}
// Compartment for hot-swapping vim mode without recreating the editor
const vimCompartment = new Compartment();
@@ -104,8 +65,12 @@ export default function Editor({ editSlug }: Props) {
const previewRef = useRef<HTMLDivElement>(null);
const previewTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const updatePreviewRef = useRef<() => void>(() => {});
const today = new Date().toISOString().slice(0, 10);
const [slug, setSlug] = useState(editSlug || '');
const [date, setDate] = useState(today);
const [summary, setSummary] = useState('');
const [tagsInput, setTagsInput] = useState('');
const [draft, setDraft] = useState(false);
const [originalSlug, setOriginalSlug] = useState(editSlug || '');
const [alert, setAlert] = useState<{ msg: string; type: 'success' | 'error' } | null>(null);
const [showModal, setShowModal] = useState(false);
@@ -126,19 +91,7 @@ export default function Editor({ editSlug }: Props) {
const updatePreview = useCallback(() => {
if (!showPreview || !viewRef.current || !previewRef.current) return;
const content = viewRef.current.state.doc.toString();
const result = marked.parse(content);
if (typeof result === 'string') {
previewRef.current.innerHTML = result;
} else {
result.then(h => { if (previewRef.current) previewRef.current.innerHTML = h; });
}
requestAnimationFrame(() => {
if (!previewRef.current) return;
renderMathInElement(previewRef.current);
previewRef.current.querySelectorAll<HTMLElement>('pre code').forEach(block => {
hljs.highlightElement(block);
});
});
previewRef.current.innerHTML = renderMarkdown(content);
}, [showPreview]);
useEffect(() => { updatePreviewRef.current = updatePreview; }, [updatePreview]);
@@ -194,6 +147,9 @@ export default function Editor({ editSlug }: Props) {
if (!editSlug) return;
getPost(editSlug).then(post => {
if (post.summary) setSummary(post.summary);
if (post.date) setDate(post.date);
if (post.tags?.length) setTagsInput(post.tags.join(', '));
setDraft(!!post.draft);
if (post.content && viewRef.current) {
viewRef.current.dispatch({
changes: { from: 0, to: viewRef.current.state.doc.length, insert: post.content },
@@ -257,11 +213,18 @@ export default function Editor({ editSlug }: Props) {
showAlertMsg('Title and content are required.', 'error');
return;
}
const tags = tagsInput
.split(',')
.map(t => t.trim())
.filter(Boolean);
try {
await savePost({
slug,
old_slug: originalSlug || null,
date,
summary: summary || null,
tags,
draft,
content,
});
showAlertMsg('Post saved!', 'success');
@@ -323,17 +286,51 @@ export default function Editor({ editSlug }: Props) {
</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"
/>
{/* Slug + Date */}
<div className="grid grid-cols-1 md:grid-cols-[1fr_180px] gap-4">
<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>
<div>
<label className="block text-sm font-medium text-subtext1 mb-2">Date</label>
<input
type="date"
value={date}
onChange={e => setDate(e.target.value)}
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>
</div>
{/* Tags + Draft */}
<div className="grid grid-cols-1 md:grid-cols-[1fr_auto] gap-4 md:items-end">
<div>
<label className="block text-sm font-medium text-subtext1 mb-2">Tags (comma-separated)</label>
<input
type="text"
value={tagsInput}
onChange={e => setTagsInput(e.target.value)}
placeholder="rust, astro, design"
className="w-full bg-crust border border-surface1 rounded-lg px-4 py-3 text-text focus:outline-none focus:border-mauve transition-colors"
/>
</div>
<label className="flex items-center gap-2 px-4 py-3 bg-crust border border-surface1 rounded-lg cursor-pointer hover:border-peach/40 transition-colors select-none">
<input
type="checkbox"
checked={draft}
onChange={e => setDraft(e.target.checked)}
className="accent-peach"
/>
<span className="text-sm font-medium text-subtext1">Draft</span>
</label>
</div>
{/* Summary */}
+31 -6
View File
@@ -1,15 +1,30 @@
import { useState } from 'react';
import { login, ApiError } from '../../../lib/api';
import { useAuth } from '../../../stores/auth';
export default function Login() {
const [value, setValue] = useState('');
const setToken = useAuth(s => s.setToken);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const setLoggedIn = useAuth(s => s.setLoggedIn);
function handleSubmit(e: React.FormEvent) {
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
if (value.trim()) {
setToken(value.trim());
const token = value.trim();
if (!token) return;
setBusy(true);
setError(null);
try {
await login(token);
setLoggedIn(true);
window.location.href = '/admin';
} catch (e) {
if (e instanceof ApiError && e.status === 401) {
setError('Invalid token.');
} else {
setError('Login failed. Try again.');
}
setBusy(false);
}
}
@@ -27,12 +42,22 @@ export default function Login() {
required
value={value}
onChange={e => setValue(e.target.value)}
autoComplete="current-password"
className="w-full bg-crust border border-surface1 rounded-lg px-4 py-3 text-text focus:outline-none focus:border-mauve transition-colors"
placeholder="••••••••••••"
/>
</div>
<button type="submit" className="w-full bg-mauve text-crust font-bold py-3 rounded-lg hover:bg-pink transition-colors">
Login
{error && (
<p className="text-sm text-red bg-red/10 border border-red/20 rounded-lg px-3 py-2">
{error}
</p>
)}
<button
type="submit"
disabled={busy}
className="w-full bg-mauve text-crust font-bold py-3 rounded-lg hover:bg-pink transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
>
{busy ? 'Logging in...' : 'Login'}
</button>
</form>
</div>