From 9c913adacded99ee9e3099b61b30c3ffae12db84 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Sat, 9 May 2026 10:40:58 +0200 Subject: [PATCH] layout redesign --- .../src/components/react/DeletePostButton.tsx | 86 +++++ .../src/components/react/LogoutButton.tsx | 45 +++ frontend/src/components/react/PostList.tsx | 306 ++++++++---------- frontend/src/components/react/Search.tsx | 263 +++++++++++++++ frontend/src/layouts/Layout.astro | 21 +- frontend/src/pages/index.astro | 48 ++- frontend/src/pages/posts/[slug].astro | 159 +++++---- frontend/src/styles/global.css | 13 +- 8 files changed, 712 insertions(+), 229 deletions(-) create mode 100644 frontend/src/components/react/DeletePostButton.tsx create mode 100644 frontend/src/components/react/LogoutButton.tsx create mode 100644 frontend/src/components/react/Search.tsx diff --git a/frontend/src/components/react/DeletePostButton.tsx b/frontend/src/components/react/DeletePostButton.tsx new file mode 100644 index 0000000..45ac22e --- /dev/null +++ b/frontend/src/components/react/DeletePostButton.tsx @@ -0,0 +1,86 @@ +import { useState } from 'react'; +import { deletePost } from '../../lib/api'; + +interface Props { + slug: string; + title: string; + variant?: 'icon' | 'full'; +} + +export default function DeletePostButton({ slug, title, variant = 'full' }: Props) { + const [busy, setBusy] = useState(false); + + async function handleClick() { + if (busy) return; + if (!window.confirm(`Delete "${title}"? This cannot be undone.`)) return; + setBusy(true); + try { + await deletePost(slug); + window.location.href = '/'; + } catch (e) { + window.alert(`Failed to delete: ${e instanceof Error ? e.message : 'unknown error'}`); + setBusy(false); + } + } + + if (variant === 'icon') { + return ( + + ); + } + + return ( + + ); +} diff --git a/frontend/src/components/react/LogoutButton.tsx b/frontend/src/components/react/LogoutButton.tsx new file mode 100644 index 0000000..489b711 --- /dev/null +++ b/frontend/src/components/react/LogoutButton.tsx @@ -0,0 +1,45 @@ +import { useState } from 'react'; +import { logout as apiLogout } from '../../lib/api'; + +export default function LogoutButton() { + const [busy, setBusy] = useState(false); + + async function handleClick() { + if (busy) return; + setBusy(true); + try { + await apiLogout(); + } catch { + /* clear UI anyway */ + } + window.location.href = '/'; + } + + return ( + + ); +} diff --git a/frontend/src/components/react/PostList.tsx b/frontend/src/components/react/PostList.tsx index 7374491..580735d 100644 --- a/frontend/src/components/react/PostList.tsx +++ b/frontend/src/components/react/PostList.tsx @@ -1,4 +1,5 @@ -import { useMemo, useState } from 'react'; +import { useState } from 'react'; +import { deletePost } from '../../lib/api'; interface Post { slug: string; @@ -12,6 +13,7 @@ interface Post { interface Props { posts: Post[]; + isAdmin?: boolean; } function formatSlug(slug: string) { @@ -30,178 +32,154 @@ function formatDate(date: string) { }); } -export default function PostList({ posts }: Props) { - const [query, setQuery] = useState(''); - const [activeTags, setActiveTags] = useState>(new Set()); +export default function PostList({ posts: initialPosts, isAdmin = false }: Props) { + const [posts, setPosts] = useState(initialPosts); + const [deleting, setDeleting] = useState(null); - const allTags = useMemo(() => { - const set = new Set(); - for (const p of posts) for (const t of p.tags || []) set.add(t); - return Array.from(set).sort((a, b) => a.localeCompare(b)); - }, [posts]); + async function handleDelete(slug: string, title: string) { + if (deleting) return; + if (!window.confirm(`Delete "${title}"? This cannot be undone.`)) return; + setDeleting(slug); + try { + await deletePost(slug); + setPosts(p => p.filter(x => x.slug !== slug)); + } catch (e) { + window.alert(`Failed to delete: ${e instanceof Error ? e.message : 'unknown error'}`); + } finally { + setDeleting(null); + } + } - const filtered = useMemo(() => { - const q = query.trim().toLowerCase(); - return posts.filter(p => { - if (activeTags.size > 0) { - for (const t of activeTags) if (!p.tags?.includes(t)) return false; - } - if (!q) return true; - const hay = `${p.title || formatSlug(p.slug)} ${p.excerpt || ''}`.toLowerCase(); - return hay.includes(q); - }); - }, [posts, query, activeTags]); - - function toggleTag(tag: string) { - setActiveTags(prev => { - const next = new Set(prev); - if (next.has(tag)) next.delete(tag); - else next.add(tag); - return next; - }); + if (posts.length === 0) { + return ( +
+ No posts yet. +
+ ); } return ( -
-
-
- - setQuery(e.target.value)} - placeholder="Search posts…" - aria-label="Search posts" - className="w-full bg-crust border border-surface1 rounded-lg pl-9 pr-3 py-2 text-sm md:text-base text-text placeholder:text-subtext0 focus:outline-none focus:border-mauve transition-colors" - /> -
- {allTags.length > 0 && ( -
- {allTags.map(tag => { - const active = activeTags.has(tag); - return ( - - ); - })} - {activeTags.size > 0 && ( - - )} -
- )} -
- - {filtered.length === 0 ? ( -
- No posts match. -
- ) : ( -
- {filtered.map(post => { - const displayTitle = post.title || formatSlug(post.slug); - return ( -
- -
- + + + {post.tags && post.tags.length > 0 && ( +
+ {post.tags.map(tag => ( + + {tag} + + ))} +
+ )} + + {isAdmin && ( +
+ e.stopPropagation()} + title="Edit post" + aria-label={`Edit ${displayTitle}`} + className="p-1.5 rounded-md bg-surface0/80 hover:bg-blue/20 text-subtext0 hover:text-blue border border-surface1 transition-colors" + > + + + + - {post.tags && post.tags.length > 0 && ( -
- {post.tags.map(tag => { - const active = activeTags.has(tag); - return ( - - ); - })} -
- )} -
- ); - })} -
- )} + +
+ )} + + ); + })} ); } diff --git a/frontend/src/components/react/Search.tsx b/frontend/src/components/react/Search.tsx new file mode 100644 index 0000000..7e97679 --- /dev/null +++ b/frontend/src/components/react/Search.tsx @@ -0,0 +1,263 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import { getPosts } from '../../lib/api'; + +interface Post { + slug: string; + date: string; + title?: string; + excerpt?: string; + tags: string[]; + draft: boolean; + reading_time: number; +} + +function formatSlug(slug: string) { + if (!slug) return ''; + return slug + .split('-') + .map(w => w.charAt(0).toUpperCase() + w.slice(1)) + .join(' '); +} + +function formatDate(date: string) { + return new Date(date).toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + }); +} + +export default function Search() { + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(''); + const [posts, setPosts] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [activeIdx, setActiveIdx] = useState(0); + const inputRef = useRef(null); + const listRef = useRef(null); + + // Global Cmd/Ctrl+K + Esc listener + useEffect(() => { + function onKey(e: KeyboardEvent) { + const isMac = navigator.platform.toLowerCase().includes('mac'); + const mod = isMac ? e.metaKey : e.ctrlKey; + if (mod && e.key.toLowerCase() === 'k') { + e.preventDefault(); + setOpen(o => !o); + } else if (e.key === 'Escape' && open) { + setOpen(false); + } + } + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [open]); + + // Lazy fetch posts on first open + useEffect(() => { + if (!open || posts !== null || loading) return; + setLoading(true); + getPosts() + .then(p => setPosts(p as Post[])) + .catch(e => setError(e instanceof Error ? e.message : 'Failed to load')) + .finally(() => setLoading(false)); + }, [open, posts, loading]); + + // Focus input on open + useEffect(() => { + if (open) { + setQuery(''); + setActiveIdx(0); + requestAnimationFrame(() => inputRef.current?.focus()); + } + }, [open]); + + // Lock body scroll when open + useEffect(() => { + if (!open) return; + const prev = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + return () => { document.body.style.overflow = prev; }; + }, [open]); + + const results = useMemo(() => { + if (!posts) return []; + const q = query.trim().toLowerCase(); + if (!q) return posts; + return posts.filter(p => { + const hay = `${p.title || formatSlug(p.slug)} ${p.excerpt || ''} ${p.tags?.join(' ') || ''}`.toLowerCase(); + return hay.includes(q); + }); + }, [posts, query]); + + // Reset active index when results change + useEffect(() => { + setActiveIdx(0); + }, [query, posts]); + + function navigate(slug: string) { + window.location.href = `/posts/${encodeURIComponent(slug)}`; + } + + function onInputKey(e: React.KeyboardEvent) { + if (e.key === 'ArrowDown') { + e.preventDefault(); + setActiveIdx(i => Math.min(i + 1, Math.max(0, results.length - 1))); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + setActiveIdx(i => Math.max(i - 1, 0)); + } else if (e.key === 'Enter' && results[activeIdx]) { + e.preventDefault(); + navigate(results[activeIdx].slug); + } + } + + // Scroll active result into view + useEffect(() => { + if (!open || !listRef.current) return; + const el = listRef.current.children[activeIdx] as HTMLElement | undefined; + el?.scrollIntoView({ block: 'nearest' }); + }, [activeIdx, open]); + + return ( + <> + + + {open && ( +
setOpen(false)} + > + + )} + + ); +} diff --git a/frontend/src/layouts/Layout.astro b/frontend/src/layouts/Layout.astro index 10cdc06..7911524 100644 --- a/frontend/src/layouts/Layout.astro +++ b/frontend/src/layouts/Layout.astro @@ -5,6 +5,8 @@ import 'highlight.js/styles/atom-one-dark.css'; import '@fontsource-variable/inter'; import '@fontsource-variable/jetbrains-mono'; import ThemeSwitcher from '../components/react/ThemeSwitcher'; +import Search from '../components/react/Search'; +import LogoutButton from '../components/react/LogoutButton'; interface Props { title: string; @@ -17,6 +19,7 @@ interface Props { const { title, wide = false, description, image, type = 'website' } = Astro.props; const API_URL = process.env.PUBLIC_API_URL || 'http://backend:3000'; +const isAdmin = Astro.cookies.get('admin_session')?.value === '1'; let siteConfig = { title: "Narlblog", @@ -82,8 +85,22 @@ const fullTitle = `${title} | ${siteConfig.title}`;

{siteConfig.subtitle}

-
- Home +
+ Home + + {isAdmin && ( + + )}
diff --git a/frontend/src/pages/index.astro b/frontend/src/pages/index.astro index 92946ae..cbed5eb 100644 --- a/frontend/src/pages/index.astro +++ b/frontend/src/pages/index.astro @@ -41,6 +41,8 @@ try { error = `Could not connect to backend at ${API_URL}: ${e instanceof Error ? e.message : String(e)}${cause ? ' (Cause: ' + (cause.message || cause.code || JSON.stringify(cause)) + ')' : ''}`; console.error(error); } + +const isAdmin = Astro.cookies.get('admin_session')?.value === '1'; --- @@ -52,6 +54,32 @@ try {

{siteConfig.welcome_subtitle}

+ + {isAdmin && ( + + )}
@@ -60,14 +88,24 @@ try { {error}
)} - - {posts.length === 0 && !error && ( -
-

No posts found yet. Add some .md files to the data/posts directory!

+ + {posts.length === 0 && !error && !isAdmin && ( +
+

No posts yet — check back soon.

)} - {posts.length > 0 && } + {posts.length === 0 && !error && isAdmin && ( +
+

No posts yet. Write your first one.

+ + + New Post + +
+ )} + + {posts.length > 0 && }
diff --git a/frontend/src/pages/posts/[slug].astro b/frontend/src/pages/posts/[slug].astro index 2c3271c..3068711 100644 --- a/frontend/src/pages/posts/[slug].astro +++ b/frontend/src/pages/posts/[slug].astro @@ -1,5 +1,6 @@ --- import Layout from '../../layouts/Layout.astro'; +import DeletePostButton from '../../components/react/DeletePostButton'; import { renderMarkdown } from '../../lib/markdown'; const { slug } = Astro.params; @@ -44,72 +45,120 @@ function formatSlug(s: string) { } const isAdmin = Astro.cookies.get('admin_session')?.value === '1'; +const displayTitle = post ? (post.title || formatSlug(post.slug)) : 'Post'; --- -
- {error && ( -
-

{error}

- Return home -
- )} + {/* Reading progress bar */} + - {post && ( - <> -
- - - Back to list - -
-
-

- {post.title || formatSlug(post.slug)} -

-
- - · - {post.reading_time} min read - {post.draft && ( - <> - · - Draft - - )} - {post.tags?.length > 0 && ( - <> - · -
- {post.tags.map(tag => ( - {tag} - ))} -
- - )} -
-
+ {error && ( +
+

{error}

+ + + Back home + +
+ )} + + {post && ( +
-
- - )} -
-
+ )} + + {/* Hero header — centered title + meta */} +
+
+ + · + {post.reading_time} min read + {post.draft && ( + <> + · + Draft + + )} +
+

+ {displayTitle} +

+ {post.summary && ( +

+ {post.summary} +

+ )} + {post.tags?.length > 0 && ( +
+ {post.tags.map(tag => ( + {tag} + ))} +
+ )} +
+ +
+ + {/* Body */} +
+ + {/* Footer separator + back link */} + + + )} + + + diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css index 61b24c3..14ca3ac 100644 --- a/frontend/src/styles/global.css +++ b/frontend/src/styles/global.css @@ -117,9 +117,16 @@ code, pre, kbd, samp { /* Prose — readable column, calm hierarchy */ .prose { color: var(--text); - max-width: 70ch; - line-height: 1.7; - font-size: 1rem; + max-width: 72ch; + margin-left: auto; + margin-right: auto; + line-height: 1.75; + font-size: 1.05rem; +} +@media (min-width: 768px) { + .prose { + font-size: 1.0625rem; + } } .prose h1 { font-size: clamp(1.75rem, 1.4rem + 1.5vw, 2.5rem);