commit
This commit is contained in:
+10
-1
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
display_name: Isy
|
||||
date: 2026-03-10
|
||||
---
|
||||
hii latte! i appreciate you more :3
|
||||
+34
-366
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user