Add guestbook with WebAuthn admin and SQLite
Introduce server-rendered guestbook and moderation portal. Persist data in SQLite (better-sqlite3); add WebAuthn YubiKey admin auth, rate-limiting, spam heuristics, and sanitization. Switch Docker image to run Node/standalone Astro (remove nginx), update docker-compose, Dockerfile, astro.config, and package.json. Add .env.example, docs/guestbook.md, gitignore updates, layouts, API routes, and supporting lib/components/pages for the feature.
This commit is contained in:
@@ -0,0 +1,200 @@
|
||||
---
|
||||
interface Props {
|
||||
title: string;
|
||||
}
|
||||
const { title } = Astro.props;
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>{title} — Cozy Den Admin</title>
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div class="header-inner">
|
||||
<span class="site-name">~/admin</span>
|
||||
<nav>
|
||||
<a href="/admin">moderation</a>
|
||||
<span aria-hidden="true">·</span>
|
||||
<a href="/guestbook">public view</a>
|
||||
<span aria-hidden="true">·</span>
|
||||
<form method="post" action="/api/admin/logout" style="display:inline">
|
||||
<button type="submit" class="logout-btn">logout</button>
|
||||
</form>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
<main>
|
||||
<slot />
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
<style is:global>
|
||||
:root {
|
||||
--color-bg: #1e1e2e;
|
||||
--color-bg-light: #313244;
|
||||
--color-surface: #45475a;
|
||||
--color-text: #cdd6f4;
|
||||
--color-text-dim: #a6adc8;
|
||||
--color-accent: #cba6f7;
|
||||
--color-accent-bright: #f5c2e7;
|
||||
--color-warm: #f38ba8;
|
||||
--color-green: #a6e3a1;
|
||||
--color-peach: #fab387;
|
||||
--font-body: "JetBrains Mono", "Fira Code", monospace;
|
||||
--space-xs: 0.5rem;
|
||||
--space-sm: 1rem;
|
||||
--space-md: 1.5rem;
|
||||
--space-lg: 2rem;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
--color-bg: #f6efe6;
|
||||
--color-bg-light: #efe1cf;
|
||||
--color-surface: #d8c2a8;
|
||||
--color-text: #35251a;
|
||||
--color-text-dim: #6b5442;
|
||||
--color-accent: #8b5e3c;
|
||||
--color-accent-bright: #a16c45;
|
||||
--color-warm: #b6794f;
|
||||
--color-green: #3f7c47;
|
||||
--color-peach: #b5693e;
|
||||
}
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
html {
|
||||
font-family: var(--font-body);
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
body { min-height: 100vh; line-height: 1.6; }
|
||||
|
||||
a {
|
||||
color: var(--color-accent-bright);
|
||||
text-decoration: none;
|
||||
}
|
||||
a:hover { text-decoration: underline; }
|
||||
a:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; }
|
||||
|
||||
header {
|
||||
background: var(--color-bg-light);
|
||||
border-bottom: 1px solid var(--color-surface);
|
||||
padding: var(--space-xs) var(--space-md);
|
||||
}
|
||||
|
||||
.header-inner {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-sm);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.site-name {
|
||||
color: var(--color-accent);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.logout-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-warm);
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
text-decoration: none;
|
||||
}
|
||||
.logout-btn:hover { text-decoration: underline; }
|
||||
|
||||
main {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
padding: var(--space-lg) var(--space-md);
|
||||
}
|
||||
|
||||
h1, h2, h3 { color: var(--color-accent-bright); line-height: 1.2; }
|
||||
h1 { font-size: 1.4rem; margin-bottom: var(--space-md); }
|
||||
h2 { font-size: 1.1rem; margin-bottom: var(--space-sm); }
|
||||
|
||||
.card {
|
||||
background: var(--color-bg-light);
|
||||
border: 1px solid var(--color-surface);
|
||||
border-radius: 6px;
|
||||
padding: var(--space-md);
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid currentColor;
|
||||
background: none;
|
||||
font-family: inherit;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.btn:hover { opacity: 0.8; }
|
||||
|
||||
.btn-approve { color: var(--color-green); }
|
||||
.btn-reject { color: var(--color-text-dim); }
|
||||
.btn-spam { color: var(--color-warm); }
|
||||
.btn-primary { color: var(--color-accent); }
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 3px;
|
||||
font-size: 0.78rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
.badge-pending { background: var(--color-peach); color: #1e1e2e; }
|
||||
.badge-approved { background: var(--color-green); color: #1e1e2e; }
|
||||
.badge-rejected { background: var(--color-surface); color: var(--color-text-dim); }
|
||||
.badge-spam { background: var(--color-warm); color: #1e1e2e; }
|
||||
|
||||
.meta { color: var(--color-text-dim); font-size: 0.82rem; }
|
||||
.message-text { white-space: pre-wrap; word-break: break-word; }
|
||||
.section-gap { margin-top: var(--space-lg); }
|
||||
|
||||
.alert {
|
||||
padding: var(--space-sm);
|
||||
border-radius: 4px;
|
||||
margin-bottom: var(--space-md);
|
||||
border: 1px solid;
|
||||
}
|
||||
.alert-error { border-color: var(--color-warm); color: var(--color-warm); }
|
||||
.alert-success { border-color: var(--color-green); color: var(--color-green); }
|
||||
|
||||
input, textarea, select {
|
||||
background: var(--color-bg);
|
||||
border: 1px solid var(--color-surface);
|
||||
border-radius: 4px;
|
||||
color: var(--color-text);
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
input:focus, textarea:focus { outline: 2px solid var(--color-accent); outline-offset: 2px; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user