This commit is contained in:
2026-04-07 21:24:39 +02:00
parent 15ff7afd1c
commit 66d4c81ced
4 changed files with 57 additions and 370 deletions
+10 -1
View File
@@ -42,4 +42,13 @@ const links = defineCollection({
}),
});
export const collections = { blog, coffee, links };
const guestbook = defineCollection({
type: "content",
schema: z.object({
display_name: z.string(),
date: z.coerce.date(),
website: z.string().url().optional(),
}),
});
export const collections = { blog, coffee, links, guestbook };
@@ -0,0 +1,5 @@
---
display_name: bunny
date: 2026-03-09
---
hii latte! i appreciate you always :3
+5
View File
@@ -0,0 +1,5 @@
---
display_name: Isy
date: 2026-03-10
---
hii latte! i appreciate you more :3
+34 -366
View File
@@ -1,22 +1,11 @@
---
export const prerender = false;
export const prerender = true;
import BaseLayout from '../layouts/BaseLayout.astro';
import { getApprovedEntries } from '../lib/guestbook';
import { getCollection } from 'astro:content';
const pageParam = parseInt(Astro.url.searchParams.get('page') ?? '1', 10);
const page = isNaN(pageParam) || pageParam < 1 ? 1 : pageParam;
const { entries, total, hasMore } = getApprovedEntries(page);
const submitted = Astro.url.searchParams.get('submitted') === 'true';
const errorParam = Astro.url.searchParams.get('error');
const errorMessages: Record<string, string> = {
rate_limit: 'You\'ve submitted too many messages recently. Please wait a while before trying again.',
invalid: 'Your submission could not be processed. Please check all fields and try again.',
};
const errorMsg = errorParam ? (errorMessages[errorParam] ?? decodeURIComponent(errorParam)) : null;
const rawEntries = await getCollection('guestbook');
const entries = rawEntries.sort((a, b) => b.data.date.getTime() - a.data.date.getTime());
---
<BaseLayout
@@ -31,180 +20,58 @@ const errorMsg = errorParam ? (errorMessages[errorParam] ?? decodeURIComponent(e
</p>
</header>
<!-- Submission form -->
<section class="card form-section" aria-label="Leave a message">
<details class="compose" open={submitted || Boolean(errorMsg)}>
<summary class="compose-toggle">
<span class="compose-toggle-text">Leave a message</span>
</summary>
<div class="compose-body">
<p class="form-note">
Messages are reviewed before being published. Please don't include personal or
sensitive information — this is a public guestbook.
<!-- Email info -->
<section class="card mail-section" aria-label="Leave a message">
<p class="mail-note">
Want to leave a message? Send an email to
<a href="mailto:latte@hiddenden.cafe">latte@hiddenden.cafe</a>
and it might end up here.
</p>
{submitted && (
<div class="alert alert-success" role="alert">
Thank you for your message! It will appear here once reviewed.
</div>
)}
{errorMsg && (
<div class="alert alert-error" role="alert">
{errorMsg}
</div>
)}
<form
method="post"
action="/api/guestbook/submit"
class="guestbook-form"
novalidate
>
<!-- Honeypot field: hidden from real users, filled by bots -->
<div class="hp-field" aria-hidden="true">
<label for="address">Address</label>
<input
type="text"
id="address"
name="address"
tabindex="-1"
autocomplete="off"
/>
</div>
<div class="field">
<label for="display_name">
Name or nickname <span class="required" aria-label="required">*</span>
</label>
<input
type="text"
id="display_name"
name="display_name"
maxlength="60"
required
autocomplete="nickname"
placeholder="your name or alias"
/>
</div>
<div class="field">
<label for="message">
Message <span class="required" aria-label="required">*</span>
</label>
<textarea
id="message"
name="message"
maxlength="1000"
required
rows="4"
placeholder="say hello, share a thought, leave a trail..."
></textarea>
<span class="field-hint">Plain text only. Max 1000 characters.</span>
</div>
<div class="field">
<label for="website">
Website <span class="optional">(optional)</span>
</label>
<input
type="url"
id="website"
name="website"
maxlength="200"
autocomplete="url"
placeholder="https://your-site.example"
/>
<span class="field-hint">Only https:// links. Leave blank if you don't have one.</span>
</div>
<div class="field consent-field">
<label class="consent-label">
<input
type="checkbox"
name="consent"
value="yes"
required
/>
<span>
I understand this message may be published publicly on this guestbook.
<span class="required" aria-label="required">*</span>
</span>
</label>
</div>
<div class="form-actions">
<button type="submit" class="btn-submit">send message</button>
</div>
</form>
</div>
</details>
</section>
<!-- Approved entries -->
<!-- Entries -->
<section class="entries-section" aria-labelledby="entries-heading">
<h2 id="entries-heading">
Messages
{total > 0 && <span class="entry-count">({total})</span>}
<span class="entry-count">({entries.length})</span>
</h2>
{entries.length === 0 ? (
<div class="empty-state card">
<p>No messages yet. Be the first to leave a note!</p>
</div>
) : (
<div class="entries-list">
{entries.map((entry) => (
{entries.map(async (entry) => {
const { Content } = await entry.render();
return (
<article class="entry card">
<header class="entry-header">
<span class="entry-name">
{entry.website ? (
{entry.data.website ? (
<a
href={entry.website}
href={entry.data.website}
rel="noopener noreferrer nofollow"
target="_blank"
>
{entry.display_name}
{entry.data.display_name}
</a>
) : (
entry.display_name
entry.data.display_name
)}
</span>
<time
class="entry-date"
datetime={entry.created_at}
title={entry.created_at}
datetime={entry.data.date.toISOString()}
title={entry.data.date.toISOString()}
>
{new Date(entry.created_at).toLocaleDateString('en-US', {
{entry.data.date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
})}
</time>
</header>
<p class="entry-message">{entry.message}</p>
<div class="entry-message"><Content /></div>
</article>
))}
);
})}
</div>
)}
<!-- Pagination -->
{(page > 1 || hasMore) && (
<nav class="pagination" aria-label="Guestbook pagination">
{page > 1 && (
<a href={`/guestbook?page=${page - 1}`} class="page-link">
← newer
</a>
)}
<span class="page-info">page {page}</span>
{hasMore && (
<a href={`/guestbook?page=${page + 1}`} class="page-link">
older →
</a>
)}
</nav>
)}
</section>
</div>
</BaseLayout>
@@ -237,8 +104,7 @@ const errorMsg = errorParam ? (errorMessages[errorParam] ?? decodeURIComponent(e
font-size: 0.9rem;
}
/* Form */
.form-section {
.mail-section {
background: var(--color-bg-light);
border: 1px solid var(--color-surface);
border-radius: 8px;
@@ -246,191 +112,18 @@ const errorMsg = errorParam ? (errorMessages[errorParam] ?? decodeURIComponent(e
margin-bottom: var(--space-lg);
}
.form-note {
.mail-note {
color: var(--color-text-dim);
font-size: 0.85rem;
margin-bottom: var(--space-md);
font-size: 0.88rem;
margin: 0;
}
.compose {
width: 100%;
}
.compose-toggle {
list-style: none;
cursor: pointer;
display: flex;
align-items: center;
gap: 10px;
color: var(--color-accent-bright);
font-size: 1.1rem;
font-weight: bold;
margin-bottom: var(--space-sm);
user-select: none;
}
.compose-toggle::-webkit-details-marker {
display: none;
}
.compose-toggle:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
border-radius: 4px;
}
.compose-toggle::before {
content: '+';
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.2em;
height: 1.2em;
border: 1px solid var(--color-accent);
border-radius: 4px;
.mail-note a {
color: var(--color-accent);
font-weight: normal;
line-height: 1;
}
.compose[open] .compose-toggle::before {
content: '';
}
.compose-body {
margin-top: var(--space-sm);
}
.guestbook-form {
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
/* Honeypot: visually hidden but accessible-compatible */
.hp-field {
position: absolute;
left: -9999px;
width: 1px;
height: 1px;
overflow: hidden;
}
.field {
display: flex;
flex-direction: column;
gap: 4px;
}
label {
font-size: 0.88rem;
color: var(--color-text-dim);
}
.required {
color: var(--color-warm);
margin-left: 2px;
}
.optional {
color: var(--color-surface);
font-size: 0.82rem;
}
input[type="text"],
input[type="url"],
textarea {
background: var(--color-bg);
border: 1px solid var(--color-surface);
border-radius: 4px;
color: var(--color-text);
font-family: var(--font-body);
font-size: 0.9rem;
padding: 8px 10px;
width: 100%;
transition: border-color 0.15s;
}
input:focus,
textarea:focus {
outline: none;
border-color: var(--color-accent);
}
textarea {
resize: vertical;
min-height: 100px;
}
.field-hint {
font-size: 0.78rem;
color: var(--color-text-dim);
}
.consent-field {
margin-top: var(--space-xs);
}
.consent-label {
display: flex;
align-items: flex-start;
gap: 8px;
cursor: pointer;
font-size: 0.88rem;
color: var(--color-text-dim);
}
.consent-label input[type="checkbox"] {
margin-top: 3px;
flex-shrink: 0;
accent-color: var(--color-accent);
width: auto;
}
.form-actions {
margin-top: var(--space-xs);
}
.btn-submit {
background: var(--color-accent);
color: var(--color-bg);
border: none;
border-radius: 4px;
padding: 8px 20px;
font-family: var(--font-body);
font-size: 0.9rem;
cursor: pointer;
transition: opacity 0.15s;
}
.btn-submit:hover {
opacity: 0.85;
}
.btn-submit:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
/* Alerts */
.alert {
padding: var(--space-sm);
border-radius: 4px;
margin-bottom: var(--space-sm);
border: 1px solid;
font-size: 0.88rem;
}
.alert-success {
border-color: var(--color-green);
color: var(--color-green);
background: color-mix(in srgb, var(--color-green) 10%, transparent);
}
.alert-error {
border-color: var(--color-warm);
color: var(--color-warm);
background: color-mix(in srgb, var(--color-warm) 10%, transparent);
.mail-note a:hover {
color: var(--color-accent-bright);
}
/* Entries */
@@ -485,37 +178,12 @@ const errorMsg = errorParam ? (errorMessages[errorParam] ?? decodeURIComponent(e
.entry-message {
color: var(--color-text);
font-size: 0.9rem;
white-space: pre-wrap;
word-break: break-word;
line-height: 1.5;
}
.empty-state {
background: var(--color-bg-light);
border: 1px solid var(--color-surface);
border-radius: 6px;
padding: var(--space-md);
color: var(--color-text-dim);
font-size: 0.9rem;
text-align: center;
}
/* Pagination */
.pagination {
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-md);
margin-top: var(--space-lg);
font-size: 0.88rem;
}
.page-link {
color: var(--color-accent);
}
.page-info {
color: var(--color-text-dim);
.entry-message p {
margin: 0;
}
@media (max-width: 600px) {