ui redesign, markdown fix + metadata and auth header
This commit is contained in:
@@ -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>,
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
© {new Date().getFullYear()} {siteConfig.title}
|
||||
</div>
|
||||
</footer>
|
||||
</body>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+20
-13
@@ -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' });
|
||||
|
||||
|
||||
@@ -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'],
|
||||
});
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
@@ -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' },
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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 => ({
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'&': '&',
|
||||
"'": ''',
|
||||
'"': '"',
|
||||
}[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',
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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); }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user