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>
+27 -16
View File
@@ -10,10 +10,29 @@ const { title, wide = false } = Astro.props;
---
<Layout title={title} wide={wide}>
<div class="glass p-6 md:p-12 mb-12" id="admin-content" style="display: none;">
<Fragment slot="head">
<script is:inline>
(function () {
try {
var authed = document.cookie.split(';').some(function (c) {
return c.trim().indexOf('admin_session=1') === 0;
});
if (!authed) {
window.location.replace('/admin/login');
return;
}
document.documentElement.classList.add('admin-authed');
} catch (e) {
window.location.replace('/admin/login');
}
})();
</script>
</Fragment>
<div class="glass p-6 md:p-12 mb-12" id="admin-content">
<header class="mb-8 md:mb-12 border-b border-white/5 pb-8 md:pb-12 flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
<div>
<a id="back-link" href="/admin" class="text-blue hover:text-sky transition-colors mb-4 md:mb-8 inline-flex items-center gap-2 group text-sm md:text-base" style="color: var(--blue) !important;">
<a id="back-link" href="/admin" class="text-blue hover:text-sky transition-colors mb-4 md:mb-8 inline-flex items-center gap-2 group text-sm md:text-base">
<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
</a>
@@ -32,20 +51,12 @@ const { title, wide = false } = Astro.props;
</div>
<script>
const token = localStorage.getItem('admin_token');
if (!token) {
window.location.href = '/admin/login';
} else {
const content = document.getElementById('admin-content');
if (content) content.style.display = 'block';
const backLink = document.getElementById('back-link');
if (backLink && document.referrer && document.referrer.includes(window.location.host) && !document.referrer.includes('/admin/login')) {
backLink.addEventListener('click', (ev) => {
ev.preventDefault();
window.history.back();
});
}
const backLink = document.getElementById('back-link');
if (backLink && document.referrer && document.referrer.includes(window.location.host) && !document.referrer.includes('/admin/login')) {
backLink.addEventListener('click', (ev) => {
ev.preventDefault();
window.history.back();
});
}
</script>
</Layout>
+41 -17
View File
@@ -1,13 +1,20 @@
---
import '../styles/global.css';
import 'katex/dist/katex.min.css';
import 'highlight.js/styles/atom-one-dark.css';
import '@fontsource-variable/inter';
import '@fontsource-variable/jetbrains-mono';
import ThemeSwitcher from '../components/react/ThemeSwitcher';
interface Props {
title: string;
wide?: boolean;
description?: string;
image?: string;
type?: 'website' | 'article';
}
const { title, wide = false } = Astro.props;
const { title, wide = false, description, image, type = 'website' } = Astro.props;
const API_URL = process.env.PUBLIC_API_URL || 'http://backend:3000';
@@ -29,28 +36,42 @@ try {
console.error("Failed to fetch config:", e);
}
const fullTitle = `${title} | ${siteConfig.title}`;
---
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width" />
<link rel="icon" href={siteConfig.favicon} />
<meta name="generator" content={Astro.generator} />
<title>{title} | {siteConfig.title}</title>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width" />
<link rel="icon" href={siteConfig.favicon} />
<meta name="generator" content={Astro.generator} />
<title>{fullTitle}</title>
{description && <meta name="description" content={description} />}
<meta property="og:title" content={fullTitle} />
{description && <meta property="og:description" content={description} />}
<meta property="og:type" content={type} />
<meta property="og:site_name" content={siteConfig.title} />
<meta property="og:url" content={Astro.url.href} />
{image && <meta property="og:image" content={image} />}
<meta name="twitter:card" content={image ? 'summary_large_image' : 'summary'} />
<meta name="twitter:title" content={fullTitle} />
{description && <meta name="twitter:description" content={description} />}
{image && <meta name="twitter:image" content={image} />}
<link rel="alternate" type="application/rss+xml" title={siteConfig.title} href="/feed.xml" />
{siteConfig.custom_css && <style set:html={siteConfig.custom_css} />}
<script is:inline define:vars={{ defaultTheme: siteConfig.theme }}>
const savedTheme = localStorage.getItem('user-theme') || defaultTheme;
document.documentElement.className = savedTheme;
document.documentElement.classList.add(savedTheme);
</script>
</head>
<body class="bg-base text-text selection:bg-surface2 selection:text-text">
<!-- Dynamic Mesh Gradient Background -->
<div class="fixed inset-0 z-[-1] overflow-hidden bg-base text-text">
<div class="absolute top-[-10%] left-[-10%] w-[60%] h-[50%] rounded-full bg-mauve/15 blur-[120px] opacity-70 animate-pulse" style="animation-duration: 10s;"></div>
<div class="absolute bottom-[-10%] right-[-10%] w-[60%] h-[60%] rounded-full bg-blue/15 blur-[120px] opacity-60 animate-pulse" style="animation-duration: 15s;"></div>
<div class="absolute top-[30%] right-[10%] w-[40%] h-[40%] rounded-full bg-teal/10 blur-[100px] opacity-50 animate-pulse" style="animation-duration: 12s;"></div>
<slot name="head" />
</head>
<body class="bg-base text-text selection:bg-surface2 selection:text-text">
<!-- Static Mesh Gradient Background -->
<div class="fixed inset-0 z-[-1] overflow-hidden bg-base pointer-events-none">
<div class="absolute top-[-10%] left-[-10%] w-[55%] h-[45%] rounded-full bg-mauve/10 blur-[110px] opacity-60"></div>
<div class="absolute bottom-[-10%] right-[-10%] w-[55%] h-[55%] rounded-full bg-blue/10 blur-[110px] opacity-50"></div>
<div class="absolute top-[30%] right-[10%] w-[35%] h-[35%] rounded-full bg-teal/8 blur-[90px] opacity-40"></div>
</div>
<nav class="max-w-6xl mx-auto px-4 md:px-6 py-4 md:py-8">
@@ -70,14 +91,17 @@ try {
</nav>
<main class={`mx-auto px-4 md:px-6 py-4 md:py-8 ${wide ? 'max-w-[95vw]' : 'max-w-6xl'}`}>
<slot />
<slot />
</main>
<footer class="max-w-6xl mx-auto px-4 md:px-6 py-8 md:py-12 text-center text-xs md:text-sm text-subtext1 border-t border-white/5 mt-8 md:mt-12">
<p class="mb-2">{siteConfig.footer}</p>
<div class="text-xs text-subtext0 mb-2">
<a href="/feed.xml" class="hover:text-mauve transition-colors">RSS</a>
</div>
<div class="text-subtext0 opacity-50">
&copy; {new Date().getFullYear()} {siteConfig.title}
</div>
</footer>
</body>
</body>
</html>
+20 -13
View File
@@ -1,25 +1,19 @@
import type { Post, SiteConfig, Asset } from './types';
function getToken(): string | null {
if (typeof window === 'undefined') return null;
return localStorage.getItem('admin_token');
}
async function apiFetch<T>(path: string, options: RequestInit = {}): Promise<T> {
const headers: Record<string, string> = {
...(options.headers as Record<string, string> || {}),
};
const token = getToken();
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
if (!(options.body instanceof FormData)) {
headers['Content-Type'] = headers['Content-Type'] || 'application/json';
}
const res = await fetch(`/api${path}`, { ...options, headers });
const res = await fetch(`/api${path}`, {
...options,
headers,
credentials: 'same-origin',
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
@@ -37,11 +31,24 @@ export class ApiError extends Error {
}
}
// Auth
export const login = (token: string) =>
apiFetch<void>('/auth/login', { method: 'POST', body: JSON.stringify({ token }) });
export const logout = () => apiFetch<void>('/auth/logout', { method: 'POST' });
export const checkSession = () => apiFetch<void>('/auth/me');
// Posts
export const getPosts = () => apiFetch<Post[]>('/posts');
export const getPost = (slug: string) => apiFetch<Post>(`/posts/${encodeURIComponent(slug)}`);
export const savePost = (data: { slug: string; old_slug?: string | null; summary?: string | null; content: string }) =>
apiFetch<Post>('/posts', { method: 'POST', body: JSON.stringify(data) });
export const savePost = (data: {
slug: string;
old_slug?: string | null;
date?: string;
summary?: string | null;
tags?: string[];
draft?: boolean;
content: string;
}) => apiFetch<Post>('/posts', { method: 'POST', body: JSON.stringify(data) });
export const deletePost = (slug: string) =>
apiFetch<void>(`/posts/${encodeURIComponent(slug)}`, { method: 'DELETE' });
+35
View File
@@ -0,0 +1,35 @@
import { Marked } from 'marked';
import markedKatex from 'marked-katex-extension';
import { markedHighlight } from 'marked-highlight';
import { gfmHeadingId } from 'marked-gfm-heading-id';
import hljs from 'highlight.js';
import DOMPurify from 'isomorphic-dompurify';
const renderer = new Marked()
.setOptions({ gfm: true, breaks: false })
.use(gfmHeadingId())
.use(
markedHighlight({
langPrefix: 'hljs language-',
highlight(code, lang) {
const language = lang && hljs.getLanguage(lang) ? lang : 'plaintext';
return hljs.highlight(code, { language }).value;
},
}),
)
.use(markedKatex({ throwOnError: false, nonStandard: true }));
const KATEX_TAGS = [
'math', 'annotation', 'semantics', 'mrow', 'mi', 'mo', 'mn', 'mtext',
'msup', 'msub', 'msubsup', 'mfrac', 'msqrt', 'mroot', 'mover', 'munder',
'munderover', 'mtable', 'mtr', 'mtd', 'mspace', 'mstyle', 'mphantom',
'mpadded', 'menclose',
];
export function renderMarkdown(src: string): string {
const html = renderer.parse(src, { async: false }) as string;
return DOMPurify.sanitize(html, {
ADD_TAGS: KATEX_TAGS,
ADD_ATTR: ['aria-hidden', 'style', 'id', 'class', 'encoding', 'mathvariant', 'displaystyle', 'scriptlevel'],
});
}
+4
View File
@@ -1,8 +1,12 @@
export interface Post {
slug: string;
date: string;
content: string;
summary?: string;
excerpt?: string;
tags: string[];
draft: boolean;
reading_time: number;
}
export interface SiteConfig {
+22
View File
@@ -0,0 +1,22 @@
---
import Layout from '../layouts/Layout.astro';
---
<Layout title="Not found" description="The page you're looking for doesn't exist.">
<div class="glass p-8 md:p-16 text-center max-w-2xl mx-auto mt-8 md:mt-16">
<p class="text-7xl md:text-8xl font-extrabold bg-clip-text text-transparent bg-gradient-to-r from-mauve via-blue to-teal mb-4">
404
</p>
<h1 class="text-2xl md:text-3xl font-semibold text-text mb-3">Page not found</h1>
<p class="text-subtext1 mb-8">
The page you're looking for has moved, been deleted, or never existed.
</p>
<a
href="/"
class="inline-flex items-center gap-2 bg-mauve text-crust font-semibold px-5 py-2.5 rounded-lg hover:bg-pink transition-colors"
>
<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="m12 19-7-7 7-7"/><path d="M19 12H5"/></svg>
Back to home
</a>
</div>
</Layout>
+17 -32
View File
@@ -1,57 +1,45 @@
import type { APIRoute } from 'astro';
const FORBIDDEN_HEADERS = new Set([
'host', 'connection', 'content-length', 'transfer-encoding', 'origin', 'referer',
]);
export const ALL: APIRoute = async ({ request, params }) => {
const API_URL = process.env.PUBLIC_API_URL || 'http://backend:3000';
const path = params.path;
if (!path) {
return new Response(JSON.stringify({ error: 'Missing path' }), { status: 400 });
}
// Ensure path is properly encoded
const url = new URL(`${API_URL}/api/${path}`);
const requestUrl = new URL(request.url);
url.search = requestUrl.search;
const headers = new Headers();
// Filter headers to avoid conflicts.
const forbiddenHeaders = ['host', 'connection', 'content-length', 'transfer-encoding', 'origin', 'referer'];
request.headers.forEach((value, key) => {
if (!forbiddenHeaders.includes(key.toLowerCase())) {
if (!FORBIDDEN_HEADERS.has(key.toLowerCase())) {
headers.set(key, value);
}
});
console.log(`\n[Proxy Req] ${request.method} -> ${url.toString()}`);
console.log(`[Proxy Req] Auth Header Present:`, headers.has('authorization'));
console.log(`[Proxy Req] Content-Type:`, headers.get('content-type'));
try {
const fetchOptions: RequestInit = {
method: request.method,
headers: headers,
headers,
};
// Safely handle body for mutating requests
if (request.method !== 'GET' && request.method !== 'HEAD') {
// Clone the request to safely access the body stream
const reqClone = request.clone();
// For DELETE requests, check if a body actually exists before attaching it
// Some fetch implementations fail if a body is provided for DELETE
if (request.method !== 'DELETE' || reqClone.body) {
fetchOptions.body = reqClone.body;
// Required by Node.js fetch when body is a ReadableStream
// @ts-ignore
fetchOptions.duplex = 'half';
}
const reqClone = request.clone();
if (request.method !== 'DELETE' || reqClone.body) {
fetchOptions.body = reqClone.body;
// @ts-ignore — required by Node fetch when body is a stream
fetchOptions.duplex = 'half';
}
}
const response = await fetch(url.toString(), fetchOptions);
console.log(`[Proxy Res] Backend returned ${response.status}`);
const responseHeaders = new Headers();
response.headers.forEach((value, key) => {
responseHeaders.set(key, value);
@@ -59,16 +47,13 @@ export const ALL: APIRoute = async ({ request, params }) => {
return new Response(response.body, {
status: response.status,
headers: responseHeaders
headers: responseHeaders,
});
} catch (e) {
console.error(`[Proxy Error] ${request.method} ${url}:`, e);
return new Response(JSON.stringify({
error: 'Proxy connection failed',
details: e instanceof Error ? e.message : String(e)
}), {
console.error(`[Proxy] ${request.method} ${url} failed:`, e);
return new Response(JSON.stringify({ error: 'Proxy connection failed' }), {
status: 502,
headers: { 'Content-Type': 'application/json' }
headers: { 'Content-Type': 'application/json' },
});
}
};
+87
View File
@@ -0,0 +1,87 @@
import type { APIRoute } from 'astro';
interface PostInfo {
slug: string;
date: string;
summary?: string;
excerpt?: string;
tags: string[];
draft: boolean;
}
interface SiteConfig {
title: string;
subtitle: string;
}
function escapeXml(s: string): string {
return s.replace(/[<>&'"]/g, c => ({
'<': '&lt;',
'>': '&gt;',
'&': '&amp;',
"'": '&apos;',
'"': '&quot;',
}[c]!));
}
function formatSlug(slug: string): string {
return slug
.split('-')
.map(w => w.charAt(0).toUpperCase() + w.slice(1))
.join(' ');
}
export const GET: APIRoute = async ({ site }) => {
const API_URL = process.env.PUBLIC_API_URL || 'http://backend:3000';
const origin = site?.toString().replace(/\/$/, '') || '';
let posts: PostInfo[] = [];
let config: SiteConfig = { title: 'Narlblog', subtitle: 'A clean, modern blog' };
try {
const [pr, cr] = await Promise.all([
fetch(`${API_URL}/api/posts`),
fetch(`${API_URL}/api/config`),
]);
if (pr.ok) posts = await pr.json();
if (cr.ok) config = await cr.json();
} catch (e) {
console.error('feed.xml backend fetch failed', e);
}
const items = posts
.filter(p => !p.draft)
.map(p => {
const url = `${origin}/posts/${p.slug}`;
const description = p.summary || p.excerpt || '';
const pubDate = new Date(p.date).toUTCString();
const categories = p.tags.map(t => ` <category>${escapeXml(t)}</category>`).join('\n');
return ` <item>
<title>${escapeXml(formatSlug(p.slug))}</title>
<link>${escapeXml(url)}</link>
<guid isPermaLink="true">${escapeXml(url)}</guid>
<pubDate>${pubDate}</pubDate>
<description>${escapeXml(description)}</description>
${categories}
</item>`;
})
.join('\n');
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title>${escapeXml(config.title)}</title>
<link>${escapeXml(origin)}</link>
<description>${escapeXml(config.subtitle)}</description>
<language>en</language>
<atom:link href="${escapeXml(origin)}/feed.xml" rel="self" type="application/rss+xml" />
${items}
</channel>
</rss>`;
return new Response(xml, {
headers: {
'Content-Type': 'application/xml; charset=utf-8',
'Cache-Control': 'public, max-age=600',
},
});
};
+13 -5
View File
@@ -6,7 +6,11 @@ const API_URL = process.env.PUBLIC_API_URL || 'http://localhost:3000';
interface Post {
slug: string;
date: string;
excerpt?: string;
tags: string[];
draft: boolean;
reading_time: number;
}
let posts: Post[] = [];
@@ -46,7 +50,7 @@ function formatSlug(slug: string) {
}
---
<Layout title="Home">
<Layout title="Home" description={siteConfig.welcome_subtitle}>
<div class="space-y-6 md:space-y-8">
<section class="text-center py-6 md:py-12">
<h1 class="text-3xl md:text-5xl font-extrabold mb-3 md:mb-4 pb-2 md:pb-4 leading-tight bg-clip-text text-transparent bg-gradient-to-r from-mauve via-blue to-teal">
@@ -71,10 +75,14 @@ function formatSlug(slug: string) {
)}
{posts.map((post) => (
<PostCard
slug={post.slug}
excerpt={post.excerpt}
formatSlug={formatSlug}
<PostCard
slug={post.slug}
date={post.date}
excerpt={post.excerpt}
tags={post.tags}
draft={post.draft}
readingTime={post.reading_time}
formatSlug={formatSlug}
/>
))}
</div>
+45 -18
View File
@@ -1,14 +1,22 @@
---
import Layout from '../../layouts/Layout.astro';
import PostEnhancer from '../../components/react/PostEnhancer';
import { marked } from 'marked';
import { renderMarkdown } from '../../lib/markdown';
const { slug } = Astro.params;
const API_URL = (typeof process !== 'undefined' ? process.env.PUBLIC_API_URL : import.meta.env.PUBLIC_API_URL) || 'http://localhost:3000';
interface PostDetail {
slug: string;
date: string;
content: string;
summary?: string;
tags: string[];
draft: boolean;
reading_time: number;
}
function formatDate(d: string) {
return new Date(d).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
}
let post: PostDetail | null = null;
@@ -19,7 +27,7 @@ try {
const response = await fetch(`${API_URL}/api/posts/${slug}`);
if (response.ok) {
post = await response.json();
html = await marked.parse(post!.content);
html = renderMarkdown(post!.content);
} else {
error = 'Post not found';
}
@@ -34,10 +42,14 @@ function formatSlug(s: string) {
return s.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
}
const isAdmin = Astro.cookies.has('admin_token');
const isAdmin = Astro.cookies.get('admin_session')?.value === '1';
---
<Layout title={post ? formatSlug(post.slug) : 'Post'}>
<Layout
title={post ? formatSlug(post.slug) : 'Post'}
description={post?.summary}
type="article"
>
<article class="glass p-6 md:p-12 mb-8 md:mb-12 animate-in fade-in slide-in-from-bottom-4 duration-700">
{error && (
<div class="text-red text-center py-12">
@@ -58,30 +70,45 @@ const isAdmin = Astro.cookies.has('admin_token');
Back to list
</a>
<div class="flex flex-col md:flex-row md:justify-between md:items-start mt-2 md:mt-4 gap-4">
<h1 class="text-3xl md:text-5xl font-extrabold text-mauve">
{formatSlug(post.slug)}
</h1>
<div class="flex-1 min-w-0">
<h1 class="text-3xl md:text-5xl font-extrabold text-mauve mb-3">
{formatSlug(post.slug)}
</h1>
<div class="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-subtext0">
<time datetime={post.date}>{formatDate(post.date)}</time>
<span class="opacity-50">·</span>
<span>{post.reading_time} min read</span>
{post.draft && (
<>
<span class="opacity-50">·</span>
<span class="text-peach uppercase tracking-wide font-semibold">Draft</span>
</>
)}
{post.tags?.length > 0 && (
<>
<span class="opacity-50">·</span>
<div class="flex flex-wrap gap-1.5">
{post.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>
<a
id="edit-link"
href={`/admin/editor?edit=${post.slug}`}
class="bg-surface0 hover:bg-surface1 text-blue px-3 py-1.5 md:px-4 md:py-2 rounded border border-surface1 transition-colors inline-flex items-center gap-2 text-sm md:text-base self-start hidden"
style="color: var(--blue);"
class={`bg-surface0 hover:bg-surface1 text-blue px-3 py-1.5 md:px-4 md:py-2 rounded border border-surface1 transition-colors inline-flex items-center gap-2 text-sm md:text-base self-start ${isAdmin ? '' : 'hidden'}`}
>
<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
</a>
</div>
</header>
<div id="post-content" class="prose max-w-none" set:html={html} />
<PostEnhancer client:only="react" containerId="post-content" />
<div id="post-content" class="prose" set:html={html} />
</>
)}
</article>
</Layout>
<script>
if (localStorage.getItem('admin_token')) {
const el = document.getElementById('edit-link');
if (el) el.classList.remove('hidden');
}
</script>
+18 -11
View File
@@ -1,19 +1,26 @@
import { create } from 'zustand';
interface AuthState {
token: string | null;
setToken: (token: string) => void;
logout: () => void;
loggedIn: boolean;
setLoggedIn: (v: boolean) => void;
logout: () => Promise<void>;
}
function readSessionCookie(): boolean {
if (typeof document === 'undefined') return false;
return document.cookie.split(';').some(c => c.trim().startsWith('admin_session=1'));
}
export const useAuth = create<AuthState>((set) => ({
token: typeof window !== 'undefined' ? localStorage.getItem('admin_token') : null,
setToken: (token: string) => {
localStorage.setItem('admin_token', token);
set({ token });
},
logout: () => {
localStorage.removeItem('admin_token');
set({ token: null });
loggedIn: readSessionCookie(),
setLoggedIn: (v: boolean) => set({ loggedIn: v }),
logout: async () => {
try {
await fetch('/api/auth/logout', { method: 'POST' });
} catch { /* still clear local state */ }
set({ loggedIn: false });
if (typeof window !== 'undefined') {
window.location.href = '/admin/login';
}
},
}));
+235 -65
View File
@@ -1,8 +1,8 @@
@import "tailwindcss";
/*
* NARLBLOG PROFESSIONAL THEME ENGINE
* All UI components are automatically linked to these tokens.
/*
* NARLBLOG THEME ENGINE
* All UI components automatically pick up these tokens.
*/
@theme {
@@ -32,6 +32,9 @@
--color-pink: var(--pink);
--color-flamingo: var(--flamingo);
--color-rosewater: var(--rosewater);
--font-sans: 'Inter Variable', system-ui, -apple-system, 'Segoe UI', sans-serif;
--font-mono: 'JetBrains Mono Variable', ui-monospace, 'SF Mono', Menlo, monospace;
}
:root, .mocha {
@@ -70,12 +73,12 @@
--flamingo: #eebebe; --rosewater: #f2d5cf;
}
/* Redesigned light themes for better contrast */
/* Light themes — darkened secondary text for WCAG AA against base. */
.latte {
--crust: #dce0e8; --mantle: #e6e9ef; --base: #eff1f5;
--surface0: #ccd0da; --surface1: #bcc0cc; --surface2: #acb0be;
--overlay0: #9ca0b0; --overlay1: #8c8fa1; --overlay2: #7c7f93;
--text: #1e1e2e; --subtext0: #4c4f69; --subtext1: #5c5f77;
--overlay0: #7c7f93; --overlay1: #6c6f85; --overlay2: #5c5f77;
--text: #1e1e2e; --subtext0: #3c3f59; --subtext1: #4c4f69;
--blue: #1e66f5; --lavender: #7287fd; --sapphire: #209fb5;
--sky: #04a5e5; --teal: #179299; --green: #40a02b;
--yellow: #df8e1d; --peach: #fe640b; --maroon: #e64553;
@@ -88,77 +91,244 @@
--surface0: #cbd5e1; --surface1: #94a3b8; --surface2: #64748b;
--overlay0: #475569; --overlay1: #334155; --overlay2: #1e293b;
--text: #0f172a; --subtext0: #1e293b; --subtext1: #334155;
--blue: #5cdbdf; --lavender: #8ab4f8; --sapphire: #38bdf8;
--sky: #0ea5e9; --teal: #2dd4bf; --green: #34d399;
--yellow: #fcd34d; --peach: #fbbf24; --maroon: #f43f5e;
--red: #ef4444; --mauve: #f0498b; --pink: #ec4899;
--flamingo: #f472b6; --rosewater: #fda4af;
--blue: #0284c7; --lavender: #6366f1; --sapphire: #0ea5e9;
--sky: #0284c7; --teal: #0d9488; --green: #16a34a;
--yellow: #ca8a04; --peach: #ea580c; --maroon: #be123c;
--red: #dc2626; --mauve: #9333ea; --pink: #db2777;
--flamingo: #be185d; --rosewater: #b91c1c;
}
html {
font-family: var(--font-sans);
}
body {
background-color: var(--base);
color: var(--text) !important;
color: var(--text);
min-height: 100vh;
transition: background-color 0.3s ease, color 0.3s ease;
-webkit-font-smoothing: antialiased;
}
/* Professional Typography */
.prose { color: var(--text) !important; }
.prose h1 { @apply text-3xl md:text-4xl font-bold mb-4 md:mb-6; color: var(--mauve) !important; }
.prose h2 { @apply text-2xl md:text-3xl font-semibold mb-3 md:mb-4 mt-6 md:mt-8; color: var(--lavender) !important; }
.prose h3 { @apply text-xl md:text-2xl font-medium mb-2 md:mb-3 mt-4 md:mt-6; color: var(--blue) !important; }
.prose p { @apply mb-3 md:mb-4 leading-relaxed text-sm md:text-base; color: var(--text) !important; }
.prose blockquote { @apply border-l-4 border-surface2 pl-4 italic my-4 md:my-6; color: var(--subtext0) !important; }
.prose pre { @apply p-3 md:p-4 rounded-xl overflow-x-auto border border-white/5 my-4 md:my-6 text-xs md:text-sm; background-color: var(--crust) !important; }
.prose code { @apply bg-surface0 px-1.5 py-0.5 rounded text-xs md:text-sm font-mono; color: var(--peach) !important; }
.prose pre code { background-color: transparent !important; padding: 0 !important; border-radius: 0 !important; color: inherit !important; }
.prose img { @apply max-w-full h-auto rounded-xl shadow-lg border border-white/5 my-6 md:my-8; }
.prose a { color: var(--blue) !important; text-decoration: underline; text-underline-offset: 2px; transition: color 0.2s; }
.prose a:hover { color: var(--sky) !important; }
.prose ul { @apply list-disc pl-6 mb-4 space-y-1 text-sm md:text-base; color: var(--text) !important; }
.prose ol { @apply list-decimal pl-6 mb-4 space-y-1 text-sm md:text-base; color: var(--text) !important; }
.prose li { @apply leading-relaxed; color: var(--text) !important; }
.prose li > ul, .prose li > ol { @apply mt-1 mb-0; }
.prose ul ul { list-style-type: circle; }
.prose ul ul ul { list-style-type: square; }
.prose hr { @apply my-8 border-0; border-top: 1px solid var(--surface2); }
.prose h4 { @apply text-lg md:text-xl font-medium mb-2 mt-4; color: var(--sapphire) !important; }
.prose h5 { @apply text-base md:text-lg font-medium mb-1 mt-3; color: var(--teal) !important; }
.prose h6 { @apply text-sm md:text-base font-medium mb-1 mt-3; color: var(--subtext1) !important; }
.prose strong { color: var(--text) !important; font-weight: 700; }
.prose em { color: var(--subtext1) !important; }
.prose del { color: var(--overlay1) !important; text-decoration: line-through; }
.prose li input[type="checkbox"] { @apply mr-2 accent-blue; vertical-align: middle; }
.prose li:has(input[type="checkbox"]) { list-style: none; margin-left: -1.5rem; }
code, pre, kbd, samp {
font-family: var(--font-mono);
}
/* Professional Table Styles */
.prose table { @apply w-full mb-8 border-collapse overflow-hidden rounded-xl; border: 1px solid var(--surface1); }
.prose thead { @apply bg-surface0/50; }
.prose th { @apply px-4 py-3 text-left font-bold text-sm md:text-base border-b border-surface1; color: var(--mauve) !important; }
.prose td { @apply px-4 py-2 border-b border-surface1 text-sm md:text-base; color: var(--text) !important; }
.prose tr:last-child td { @apply border-b-0; }
.prose tr:nth-child(even) { @apply bg-surface0/20; }
/* Dynamic UI Components */
.glass {
/* Prose — readable column, calm hierarchy */
.prose {
color: var(--text);
max-width: 70ch;
line-height: 1.7;
font-size: 1rem;
}
.prose h1 {
font-size: clamp(1.75rem, 1.4rem + 1.5vw, 2.5rem);
font-weight: 700;
color: var(--mauve);
margin: 0 0 1rem;
line-height: 1.2;
}
.prose h2 {
font-size: clamp(1.4rem, 1.2rem + 0.8vw, 1.875rem);
font-weight: 600;
color: var(--text);
margin: 2.5rem 0 1rem;
padding-bottom: 0.4rem;
border-bottom: 1px solid color-mix(in srgb, var(--surface2) 60%, transparent);
line-height: 1.3;
}
.prose h3 {
font-size: 1.35rem;
font-weight: 600;
color: var(--text);
margin: 2rem 0 0.75rem;
line-height: 1.35;
}
.prose h4 {
font-size: 1.15rem;
font-weight: 600;
color: var(--text);
margin: 1.5rem 0 0.5rem;
}
.prose h5 {
font-size: 1rem;
font-weight: 600;
color: var(--subtext1);
text-transform: uppercase;
letter-spacing: 0.05em;
margin: 1.5rem 0 0.5rem;
}
.prose h6 {
font-size: 0.9rem;
font-weight: 600;
color: var(--subtext0);
margin: 1rem 0 0.5rem;
}
.prose p {
margin: 0 0 1.1rem;
}
.prose blockquote {
border-left: 3px solid var(--mauve);
padding: 0.25rem 0 0.25rem 1.1rem;
margin: 1.5rem 0;
color: var(--subtext1);
font-style: italic;
}
.prose pre {
padding: 1rem 1.1rem;
border-radius: 0.75rem;
overflow-x: auto;
border: 1px solid color-mix(in srgb, var(--surface1) 70%, transparent);
margin: 1.5rem 0;
background-color: var(--crust);
font-size: 0.9rem;
line-height: 1.55;
}
.prose code {
background-color: color-mix(in srgb, var(--surface0) 80%, transparent);
backdrop-filter: blur(16px);
border: 1px solid color-mix(in srgb, var(--text) 15%, transparent);
box-shadow: 0 10px 30px -10px rgba(0, 0, 0, 0.3);
padding: 0.15rem 0.4rem;
border-radius: 0.3rem;
font-size: 0.9em;
color: var(--peach);
}
.prose pre code {
background: transparent;
padding: 0;
border-radius: 0;
color: inherit;
font-size: inherit;
}
.prose img {
max-width: 100%;
height: auto;
border-radius: 0.75rem;
margin: 1.75rem 0;
border: 1px solid color-mix(in srgb, var(--surface1) 50%, transparent);
}
.prose a {
color: var(--blue);
text-decoration: underline;
text-underline-offset: 2px;
text-decoration-thickness: 1px;
transition: color 0.15s;
}
.prose a:hover {
color: var(--sky);
}
.prose ul, .prose ol {
margin: 0 0 1.1rem;
padding-left: 1.5rem;
}
.prose ul { list-style: disc; }
.prose ol { list-style: decimal; }
.prose ul ul { list-style: circle; }
.prose ul ul ul { list-style: square; }
.prose li { margin: 0.25rem 0; }
.prose hr {
margin: 2.5rem 0;
border: 0;
border-top: 1px solid color-mix(in srgb, var(--surface2) 70%, transparent);
}
.prose strong { color: var(--text); font-weight: 700; }
.prose em { color: inherit; font-style: italic; }
.prose del { color: var(--overlay1); text-decoration: line-through; }
.prose li input[type="checkbox"] { margin-right: 0.5rem; accent-color: var(--blue); vertical-align: middle; }
.prose li:has(> input[type="checkbox"]) { list-style: none; margin-left: -1.25rem; }
/* GFM tables */
.prose table {
width: 100%;
margin: 1.75rem 0;
border-collapse: collapse;
border-radius: 0.5rem;
overflow: hidden;
border: 1px solid var(--surface1);
font-size: 0.9rem;
}
.prose thead { background-color: color-mix(in srgb, var(--surface0) 60%, transparent); }
.prose th {
padding: 0.6rem 0.9rem;
text-align: left;
font-weight: 600;
color: var(--text);
border-bottom: 1px solid var(--surface1);
}
.prose td {
padding: 0.5rem 0.9rem;
border-bottom: 1px solid color-mix(in srgb, var(--surface1) 60%, transparent);
}
.prose tr:last-child td { border-bottom: 0; }
.prose tr:nth-child(even) td { background-color: color-mix(in srgb, var(--surface0) 25%, transparent); }
/* Glass surface */
.glass {
background-color: color-mix(in srgb, var(--surface0) 75%, transparent);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid color-mix(in srgb, var(--text) 10%, transparent);
box-shadow: 0 8px 24px -12px rgba(0, 0, 0, 0.25);
border-radius: 1rem;
}
.cm-s-narlblog.CodeMirror {
background: var(--crust) !important;
color: var(--text) !important;
border: 1px solid var(--surface1);
/* Don't double-blur nested glass surfaces */
.glass .glass {
background-color: color-mix(in srgb, var(--surface0) 50%, transparent);
backdrop-filter: none;
-webkit-backdrop-filter: none;
box-shadow: none;
}
.cm-s-narlblog .cm-header { color: var(--mauve) !important; }
.cm-s-narlblog .cm-string { color: var(--green) !important; }
.cm-s-narlblog .cm-keyword { color: var(--mauve) !important; font-weight: bold; }
.cm-s-narlblog .CodeMirror-cursor { border-left-color: var(--text) !important; }
.hljs { color: var(--text) !important; background: transparent !important; }
.hljs-keyword, .hljs-selector-tag { color: var(--mauve) !important; font-weight: bold; }
.hljs-string { color: var(--green) !important; }
.hljs-comment { color: var(--subtext0) !important; font-style: italic; }
/* hljs token colors — driven by theme tokens */
.hljs { color: var(--text); background: transparent; }
.hljs-keyword, .hljs-selector-tag, .hljs-built_in { color: var(--mauve); font-weight: 600; }
.hljs-string, .hljs-attr { color: var(--green); }
.hljs-number, .hljs-literal { color: var(--peach); }
.hljs-comment, .hljs-quote { color: var(--overlay1); font-style: italic; }
.hljs-title, .hljs-section, .hljs-name { color: var(--blue); }
.hljs-type, .hljs-class .hljs-title { color: var(--yellow); }
.hljs-variable, .hljs-template-variable { color: var(--red); }
/* KaTeX inherits prose color */
.katex { color: var(--text); }
/* Admin auth gate — set by inline head script before paint */
html:not(.admin-authed) #admin-content { display: none; }
.admin-authed #admin-content { display: block; }
/* Skeleton loader */
.skeleton {
background: linear-gradient(
90deg,
color-mix(in srgb, var(--surface0) 50%, transparent) 0%,
color-mix(in srgb, var(--surface1) 50%, transparent) 50%,
color-mix(in srgb, var(--surface0) 50%, transparent) 100%
);
background-size: 200% 100%;
animation: skeleton-shimmer 1.5s ease-in-out infinite;
border-radius: 0.5rem;
}
@keyframes skeleton-shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
/* Toast */
.toast {
position: fixed;
bottom: 1.5rem;
left: 50%;
transform: translateX(-50%);
background: var(--surface0);
border: 1px solid var(--surface1);
color: var(--text);
padding: 0.65rem 1.1rem;
border-radius: 0.6rem;
box-shadow: 0 8px 24px -8px rgba(0,0,0,0.4);
font-size: 0.85rem;
z-index: 200;
animation: toast-in 0.2s ease;
}
@keyframes toast-in {
from { opacity: 0; transform: translate(-50%, 8px); }
to { opacity: 1; transform: translate(-50%, 0); }
}