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
|
||||||
+37
-369
@@ -1,22 +1,11 @@
|
|||||||
---
|
---
|
||||||
export const prerender = false;
|
export const prerender = true;
|
||||||
|
|
||||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
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 rawEntries = await getCollection('guestbook');
|
||||||
const page = isNaN(pageParam) || pageParam < 1 ? 1 : pageParam;
|
const entries = rawEntries.sort((a, b) => b.data.date.getTime() - a.data.date.getTime());
|
||||||
|
|
||||||
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;
|
|
||||||
---
|
---
|
||||||
|
|
||||||
<BaseLayout
|
<BaseLayout
|
||||||
@@ -31,180 +20,58 @@ const errorMsg = errorParam ? (errorMessages[errorParam] ?? decodeURIComponent(e
|
|||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<!-- Submission form -->
|
<!-- Email info -->
|
||||||
<section class="card form-section" aria-label="Leave a message">
|
<section class="card mail-section" aria-label="Leave a message">
|
||||||
<details class="compose" open={submitted || Boolean(errorMsg)}>
|
<p class="mail-note">
|
||||||
<summary class="compose-toggle">
|
Want to leave a message? Send an email to
|
||||||
<span class="compose-toggle-text">Leave a message</span>
|
<a href="mailto:latte@hiddenden.cafe">latte@hiddenden.cafe</a>
|
||||||
</summary>
|
and it might end up here.
|
||||||
|
</p>
|
||||||
<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.
|
|
||||||
</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>
|
</section>
|
||||||
|
|
||||||
<!-- Approved entries -->
|
<!-- Entries -->
|
||||||
<section class="entries-section" aria-labelledby="entries-heading">
|
<section class="entries-section" aria-labelledby="entries-heading">
|
||||||
<h2 id="entries-heading">
|
<h2 id="entries-heading">
|
||||||
Messages
|
Messages
|
||||||
{total > 0 && <span class="entry-count">({total})</span>}
|
<span class="entry-count">({entries.length})</span>
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
{entries.length === 0 ? (
|
<div class="entries-list">
|
||||||
<div class="empty-state card">
|
{entries.map(async (entry) => {
|
||||||
<p>No messages yet. Be the first to leave a note!</p>
|
const { Content } = await entry.render();
|
||||||
</div>
|
return (
|
||||||
) : (
|
|
||||||
<div class="entries-list">
|
|
||||||
{entries.map((entry) => (
|
|
||||||
<article class="entry card">
|
<article class="entry card">
|
||||||
<header class="entry-header">
|
<header class="entry-header">
|
||||||
<span class="entry-name">
|
<span class="entry-name">
|
||||||
{entry.website ? (
|
{entry.data.website ? (
|
||||||
<a
|
<a
|
||||||
href={entry.website}
|
href={entry.data.website}
|
||||||
rel="noopener noreferrer nofollow"
|
rel="noopener noreferrer nofollow"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
>
|
>
|
||||||
{entry.display_name}
|
{entry.data.display_name}
|
||||||
</a>
|
</a>
|
||||||
) : (
|
) : (
|
||||||
entry.display_name
|
entry.data.display_name
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
<time
|
<time
|
||||||
class="entry-date"
|
class="entry-date"
|
||||||
datetime={entry.created_at}
|
datetime={entry.data.date.toISOString()}
|
||||||
title={entry.created_at}
|
title={entry.data.date.toISOString()}
|
||||||
>
|
>
|
||||||
{new Date(entry.created_at).toLocaleDateString('en-US', {
|
{entry.data.date.toLocaleDateString('en-US', {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
month: 'short',
|
month: 'short',
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
})}
|
})}
|
||||||
</time>
|
</time>
|
||||||
</header>
|
</header>
|
||||||
<p class="entry-message">{entry.message}</p>
|
<div class="entry-message"><Content /></div>
|
||||||
</article>
|
</article>
|
||||||
))}
|
);
|
||||||
</div>
|
})}
|
||||||
)}
|
</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>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</BaseLayout>
|
</BaseLayout>
|
||||||
@@ -237,8 +104,7 @@ const errorMsg = errorParam ? (errorMessages[errorParam] ?? decodeURIComponent(e
|
|||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Form */
|
.mail-section {
|
||||||
.form-section {
|
|
||||||
background: var(--color-bg-light);
|
background: var(--color-bg-light);
|
||||||
border: 1px solid var(--color-surface);
|
border: 1px solid var(--color-surface);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
@@ -246,191 +112,18 @@ const errorMsg = errorParam ? (errorMessages[errorParam] ?? decodeURIComponent(e
|
|||||||
margin-bottom: var(--space-lg);
|
margin-bottom: var(--space-lg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-note {
|
.mail-note {
|
||||||
color: var(--color-text-dim);
|
color: var(--color-text-dim);
|
||||||
font-size: 0.85rem;
|
font-size: 0.88rem;
|
||||||
margin-bottom: var(--space-md);
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.compose {
|
.mail-note a {
|
||||||
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;
|
|
||||||
color: var(--color-accent);
|
color: var(--color-accent);
|
||||||
font-weight: normal;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.compose[open] .compose-toggle::before {
|
.mail-note a:hover {
|
||||||
content: '−';
|
color: var(--color-accent-bright);
|
||||||
}
|
|
||||||
|
|
||||||
.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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Entries */
|
/* Entries */
|
||||||
@@ -485,37 +178,12 @@ const errorMsg = errorParam ? (errorMessages[errorParam] ?? decodeURIComponent(e
|
|||||||
.entry-message {
|
.entry-message {
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
white-space: pre-wrap;
|
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-state {
|
.entry-message p {
|
||||||
background: var(--color-bg-light);
|
margin: 0;
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
@media (max-width: 600px) {
|
||||||
|
|||||||
Reference in New Issue
Block a user