Add guestbook

This commit is contained in:
pleb 2025-11-03 16:41:04 -08:00
parent 60986f8c81
commit 33b3e6f3c1
4 changed files with 925 additions and 16 deletions

View File

@ -0,0 +1,544 @@
<script lang="ts">
import { onMount } from 'svelte';
import PlayerCard from '$lib/components/PlayerCard.svelte';
type GuestbookAuthor = {
beatleaderId: string;
name: string | null;
avatar: string | null;
country: string | null;
rank: number | null;
techPp: number | null;
accPp: number | null;
passPp: number | null;
};
type GuestbookComment = {
id: string;
message: string;
author: GuestbookAuthor;
createdAt: number;
updatedAt: number | null;
};
type GuestbookViewer = {
beatleaderId: string;
name: string | null;
avatar: string | null;
};
type GuestbookPagination = {
total: number;
offset: number;
limit: number;
hasPrev: boolean;
hasNext: boolean;
};
type GuestbookResponse = {
viewer: GuestbookViewer | null;
comments: GuestbookComment[];
pagination?: GuestbookPagination | null;
};
let loading = false;
let error: string | null = null;
let comments: GuestbookComment[] = [];
let viewer: GuestbookViewer | null = null;
let newMessage = '';
let newMessageError: string | null = null;
let submittingNew = false;
let pagination: GuestbookPagination = { total: 0, offset: 0, limit: 5, hasPrev: false, hasNext: false };
const loginUrl = '/auth/beatleader/login';
const PAGE_SIZE = 5;
$: showingStart = pagination.total > 0 ? pagination.offset + 1 : 0;
$: showingEnd = pagination.total > 0 ? Math.min(pagination.offset + comments.length, pagination.total) : 0;
function normalizePagination(data: GuestbookResponse, fallbackOffset: number): GuestbookPagination {
const page = data.pagination ?? null;
const limit = typeof page?.limit === 'number' && Number.isFinite(page.limit) && page.limit > 0 ? page.limit : PAGE_SIZE;
const total = typeof page?.total === 'number' && page.total >= 0 ? page.total : Math.max(data.comments.length + fallbackOffset, 0);
const offset = typeof page?.offset === 'number' && page.offset >= 0 ? page.offset : fallbackOffset;
const hasPrev = page?.hasPrev ?? offset > 0;
const hasNext = page?.hasNext ?? offset + data.comments.length < total;
return { total, offset, limit, hasPrev, hasNext };
}
function formatTimestamp(ts: number): string {
const now = Date.now();
const diff = Math.max(0, now - ts);
const minute = 60 * 1000;
const hour = 60 * minute;
const day = 24 * hour;
const week = 7 * day;
const month = 30 * day;
const year = 365 * day;
if (diff < minute) {
const seconds = Math.floor(diff / 1000);
return seconds <= 1 ? 'just now' : `${seconds} seconds ago`;
}
if (diff < hour) {
const minutes = Math.floor(diff / minute);
return minutes === 1 ? 'a minute ago' : `${minutes} minutes ago`;
}
if (diff < day) {
const hours = Math.floor(diff / hour);
return hours === 1 ? 'an hour ago' : `${hours} hours ago`;
}
if (diff < week) {
const days = Math.floor(diff / day);
return days === 1 ? 'yesterday' : `${days} days ago`;
}
if (diff < month) {
const weeks = Math.floor(diff / week);
return weeks === 1 ? 'a week ago' : `${weeks} weeks ago`;
}
if (diff < year) {
const months = Math.floor(diff / month);
return months === 1 ? 'a month ago' : `${months} months ago`;
}
const years = Math.floor(diff / year);
return years === 1 ? 'a year ago' : `${years} years ago`;
}
async function fetchGuestbook(offset = 0): Promise<void> {
loading = true;
error = null;
try {
const params = new URLSearchParams({
limit: String(PAGE_SIZE),
offset: String(Math.max(0, Math.floor(offset)))
});
const res = await fetch(`/api/guestbook?${params.toString()}`);
if (!res.ok) {
const body = await res.json().catch(() => null);
const message = body?.error ?? `Failed to load guestbook (${res.status})`;
throw new Error(message);
}
const data = (await res.json()) as GuestbookResponse;
viewer = data.viewer ?? null;
comments = Array.isArray(data.comments) ? data.comments : [];
pagination = normalizePagination(data, Math.max(0, offset));
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to load guestbook.';
} finally {
loading = false;
}
}
async function postComment(message: string): Promise<void> {
const res = await fetch('/api/guestbook', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ message })
});
const payload = await res.json().catch(() => null);
if (!res.ok) {
const messageText = payload?.error ?? `Request failed (${res.status})`;
throw new Error(messageText);
}
const data = payload as GuestbookResponse;
viewer = data.viewer ?? viewer;
comments = Array.isArray(data.comments) ? data.comments : comments;
pagination = normalizePagination(data, 0);
}
async function deleteComment(id: string): Promise<void> {
const params = new URLSearchParams({
id,
limit: String(PAGE_SIZE),
offset: String(pagination.offset)
});
const res = await fetch(`/api/guestbook?${params.toString()}`, {
method: 'DELETE'
});
const payload = await res.json().catch(() => null);
if (!res.ok) {
const messageText = payload?.error ?? `Failed to delete comment (${res.status})`;
throw new Error(messageText);
}
const data = payload as GuestbookResponse;
comments = Array.isArray(data.comments) ? data.comments : comments;
pagination = normalizePagination(data, pagination.offset);
}
async function submitNewComment(): Promise<void> {
if (!viewer) {
newMessageError = 'You must be logged in to leave a comment.';
return;
}
const trimmed = newMessage.trim();
if (!trimmed) {
newMessageError = 'Please enter a message before submitting.';
return;
}
if (trimmed.length > 2000) {
newMessageError = 'Message exceeds the 2000 character limit.';
return;
}
submittingNew = true;
newMessageError = null;
try {
await postComment(trimmed);
newMessage = '';
} catch (err) {
newMessageError = err instanceof Error ? err.message : 'Failed to post comment.';
} finally {
submittingNew = false;
}
}
async function loadNewer(): Promise<void> {
if (loading || !pagination.hasPrev) return;
const nextOffset = Math.max(0, pagination.offset - pagination.limit);
await fetchGuestbook(nextOffset);
}
async function loadOlder(): Promise<void> {
if (loading || !pagination.hasNext) return;
const nextOffset = pagination.offset + pagination.limit;
await fetchGuestbook(nextOffset);
}
onMount(() => {
void fetchGuestbook(0);
});
</script>
<section class="guestbook-section">
<div class="guestbook-header">
<h2 class="font-display tracking-widest">Guestbook</h2>
</div>
{#if viewer}
<form class="guestbook-form" on:submit|preventDefault={submitNewComment}>
<textarea
id="guestbook-new-message"
class="input"
bind:value={newMessage}
maxlength={2000}
rows={4}
placeholder="Leave your mark—drop a note in the guestbook!"
></textarea>
{#if newMessageError}
<div class="form-error">{newMessageError}</div>
{/if}
<div class="form-actions">
<button class="btn-neon" type="submit" disabled={submittingNew}>
{submittingNew ? 'Posting…' : 'Post Comment'}
</button>
</div>
</form>
{:else}
<div class="guestbook-login">
<p>
<a class="underline" href={loginUrl}>Log in with BeatLeader</a> to leave a comment.
</p>
</div>
{/if}
{#if loading}
<div class="guestbook-status">Loading comments…</div>
{:else if error}
<div class="guestbook-error">{error}</div>
{:else if comments.length === 0}
<div class="guestbook-empty">No comments yet. Be the first to say hello!</div>
{:else}
<ul class="guestbook-threads">
{#each comments as comment (comment.id)}
<li class="guestbook-thread">
<article class="guestbook-comment">
<div class="comment-header">
<PlayerCard
name={comment.author.name ?? 'Unknown'}
country={comment.author.country}
rank={null}
showRank={false}
avatar={comment.author.avatar}
width="100%"
avatarSize={56}
techPp={comment.author.techPp}
accPp={comment.author.accPp}
passPp={comment.author.passPp}
playerId={comment.author.beatleaderId}
gradientId={`guestbook-${comment.id}`}
/>
</div>
<p class="comment-body">{comment.message}</p>
<div class="comment-meta">
<div class="comment-meta-left">
{#if viewer?.beatleaderId === comment.author.beatleaderId}
<button
class="comment-delete"
type="button"
on:click={async () => {
try {
await deleteComment(comment.id);
} catch (err) {
console.error(err);
error = err instanceof Error ? err.message : 'Failed to delete comment.';
}
}}
disabled={loading}
>
Delete
</button>
{/if}
</div>
<span class="comment-meta-right">{formatTimestamp(comment.createdAt)}</span>
</div>
</article>
</li>
{/each}
</ul>
{/if}
<div class="guestbook-pagination">
<span class="guestbook-range">
{#if pagination.total > 0}
Showing {showingStart}{showingEnd} of {pagination.total}
{:else}
No comments yet
{/if}
</span>
<div class="guestbook-pagination-buttons">
<button
class="guestbook-nav-button"
type="button"
on:click={loadNewer}
disabled={loading || !pagination.hasPrev}
>
Newer
</button>
<button
class="guestbook-nav-button"
type="button"
on:click={loadOlder}
disabled={loading || !pagination.hasNext}
>
Older
</button>
</div>
</div>
</section>
<style>
.guestbook-section {
border-radius: 1rem;
padding: 0.625rem;
background: linear-gradient(160deg, rgba(15, 23, 42, 0.85), rgba(4, 7, 17, 0.9));
border: 1px solid rgba(148, 163, 184, 0.08);
box-shadow: 0 20px 45px rgba(2, 6, 23, 0.35);
}
.guestbook-header {
margin-bottom: 1.5rem;
}
.guestbook-form {
display: flex;
flex-direction: column;
gap: 0.75rem;
margin-bottom: 2rem;
width: 100%;
}
.input {
width: 100%;
border-radius: 0.75rem;
padding: 0.85rem 1rem;
border: 1px solid rgba(148, 163, 184, 0.15);
background: rgba(15, 23, 42, 0.6);
color: rgba(226, 232, 240, 0.95);
resize: vertical;
min-height: 120px;
}
.input:focus {
outline: none;
border-color: rgba(34, 211, 238, 0.45);
box-shadow: 0 0 0 2px rgba(34, 211, 238, 0.15);
}
.form-error {
color: rgba(248, 113, 113, 0.9);
font-size: 0.8rem;
}
.form-actions {
display: flex;
gap: 0.75rem;
align-items: center;
justify-content: flex-end;
}
.guestbook-login {
padding: 1rem 1.25rem;
border-radius: 0.75rem;
border: 1px dashed rgba(148, 163, 184, 0.25);
background: rgba(15, 23, 42, 0.4);
color: rgba(148, 163, 184, 0.9);
margin-bottom: 2rem;
}
.guestbook-status,
.guestbook-error,
.guestbook-empty {
font-size: 0.95rem;
color: rgba(148, 163, 184, 0.85);
padding: 1rem 0;
}
.guestbook-error {
color: rgba(248, 113, 113, 0.9);
}
.guestbook-threads {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.guestbook-thread {
display: flex;
flex-direction: column;
gap: 1rem;
}
.guestbook-comment {
border: 1px solid rgba(148, 163, 184, 0.12);
border-radius: 1rem;
padding: 1.25rem;
background: rgba(8, 12, 24, 0.65);
backdrop-filter: blur(8px);
display: grid;
grid-template-rows: auto 1fr auto;
gap: 0.9rem;
}
.comment-header {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.comment-meta {
font-size: 0.75rem;
color: rgba(148, 163, 184, 0.7);
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.75rem;
}
.comment-meta-left {
display: flex;
align-items: center;
gap: 0.5rem;
}
.comment-meta-right {
display: inline-flex;
align-items: center;
justify-content: flex-end;
min-width: 6rem;
text-align: right;
}
.comment-delete {
background: none;
border: none;
color: rgba(248, 113, 113, 0.85);
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.08em;
cursor: pointer;
padding: 0.25rem 0;
}
.comment-delete:hover:not(:disabled) {
color: rgba(248, 113, 113, 1);
}
.comment-delete:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.comment-body {
font-size: 0.95rem;
line-height: 1.5;
color: rgba(226, 232, 240, 0.92);
white-space: pre-wrap;
}
.guestbook-pagination {
margin-top: 1.75rem;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
}
.guestbook-range {
font-size: 0.8rem;
color: rgba(148, 163, 184, 0.75);
letter-spacing: 0.04em;
}
.guestbook-pagination-buttons {
display: flex;
gap: 0.5rem;
}
.guestbook-nav-button {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.5rem 0.95rem;
border-radius: 9999px;
border: 1px solid rgba(148, 163, 184, 0.25);
background: rgba(15, 23, 42, 0.55);
color: rgba(226, 232, 240, 0.88);
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.08em;
transition: border-color 0.2s ease, color 0.2s ease, background 0.2s ease;
}
.guestbook-nav-button:hover:not(:disabled) {
border-color: rgba(34, 211, 238, 0.45);
color: rgba(34, 211, 238, 0.95);
background: rgba(15, 23, 42, 0.7);
}
.guestbook-nav-button:disabled {
opacity: 0.45;
cursor: not-allowed;
}
@media (max-width: 640px) {
.guestbook-section {
padding: 1.75rem;
}
.comment-meta {
font-size: 0.7rem;
}
}
</style>

View File

@ -0,0 +1,130 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import { randomUUID } from 'node:crypto';
export type GuestbookAuthor = {
beatleaderId: string;
name: string | null;
avatar: string | null;
country: string | null;
rank: number | null;
techPp: number | null;
accPp: number | null;
passPp: number | null;
};
export type GuestbookComment = {
id: string;
message: string;
author: GuestbookAuthor;
createdAt: number;
updatedAt: number | null;
};
type GuestbookFileData = {
comments: GuestbookComment[];
};
const DATA_DIR = '.data';
const GUESTBOOK_FILE = 'guestbook.json';
const EMPTY_DATA: GuestbookFileData = { comments: [] };
const CONTROL_CHARS_REGEX = /[\u0000-\u001F\u007F]/g;
const HTML_TAG_REGEX = /<[^>]*>/g;
const JAVASCRIPT_PROTOCOL_REGEX = /javascript:/gi;
function sanitizeGuestbookMessage(value: string): string {
if (!value) return '';
const withoutTags = value.replace(HTML_TAG_REGEX, '');
const withoutControl = withoutTags.replace(CONTROL_CHARS_REGEX, ' ');
const withoutJsProtocol = withoutControl.replace(JAVASCRIPT_PROTOCOL_REGEX, '');
return withoutJsProtocol.trim();
}
function ensureDataDir(): void {
if (!fs.existsSync(DATA_DIR)) {
fs.mkdirSync(DATA_DIR, { recursive: true });
}
}
function resolveGuestbookPath(): string {
return path.join(process.cwd(), DATA_DIR, GUESTBOOK_FILE);
}
function loadGuestbookFile(): GuestbookFileData {
try {
const filePath = resolveGuestbookPath();
if (!fs.existsSync(filePath)) {
return { ...EMPTY_DATA, comments: [] };
}
const raw = fs.readFileSync(filePath, 'utf-8');
const parsed = JSON.parse(raw);
if (!parsed || typeof parsed !== 'object') {
return { ...EMPTY_DATA, comments: [] };
}
const comments = Array.isArray((parsed as GuestbookFileData).comments)
? ((parsed as GuestbookFileData).comments as GuestbookComment[])
: [];
return { comments };
} catch (error) {
console.error('Failed to read guestbook store', error);
return { ...EMPTY_DATA, comments: [] };
}
}
function persistGuestbookFile(data: GuestbookFileData): void {
try {
ensureDataDir();
fs.writeFileSync(resolveGuestbookPath(), JSON.stringify(data, null, 2), 'utf-8');
} catch (error) {
console.error('Failed to persist guestbook store', error);
}
}
export function listGuestbookComments(): GuestbookComment[] {
const data = loadGuestbookFile();
return [...data.comments]
.map((comment) => ({ ...comment, message: sanitizeGuestbookMessage(comment.message) }))
.sort((a, b) => b.createdAt - a.createdAt);
}
export function addGuestbookComment(input: {
message: string;
author: GuestbookAuthor;
}): GuestbookComment {
const data = loadGuestbookFile();
const sanitizedMessage = sanitizeGuestbookMessage(input.message);
const comment: GuestbookComment = {
id: randomUUID(),
message: sanitizedMessage,
author: input.author,
createdAt: Date.now(),
updatedAt: null
};
data.comments.push(comment);
persistGuestbookFile(data);
return comment;
}
export function getGuestbookComments(): GuestbookComment[] {
return listGuestbookComments();
}
export function deleteGuestbookComment(id: string, requesterBeatleaderId: string): boolean {
if (!id) return false;
const data = loadGuestbookFile();
const index = data.comments.findIndex(
(comment) => comment.id === id && comment.author.beatleaderId === requesterBeatleaderId
);
if (index === -1) return false;
data.comments.splice(index, 1);
persistGuestbookFile(data);
return true;
}

View File

@ -1,3 +1,7 @@
<script lang="ts">
import Guestbook from '$lib/components/Guestbook.svelte';
</script>
<section class="grid items-center gap-10 py-12 md:py-20 lg:grid-cols-2">
<div class="space-y-6">
<h1 class="font-display text-4xl sm:text-5xl lg:text-6xl leading-tight">
@ -21,17 +25,23 @@
<div class="mt-1 text-2xl font-mono">Player History Comparison</div>
<div class="mt-1 text-sm text-muted">A vs B: songs A played that B has not</div>
</a>
<a href="/tools/playlist-discovery" class="rounded-lg bg-black/30 ring-1 ring-white/10 p-4 block hover:ring-white/20 transition">
<div class="text-sm text-muted">Tool</div>
<div class="mt-1 text-2xl font-mono">Playlist Discovery</div>
<div class="mt-1 text-sm text-muted">Experimental UX for browsing new playlists on BeatSaver</div>
</a>
</div>
</div>
</div>
</section>
<section class="py-10">
<section class="py-12">
<div class="grid gap-10 xl:gap-14 lg:grid-cols-[minmax(0,2fr)_minmax(0,1fr)] items-start">
<div class="space-y-6">
<div class="prose prose-invert max-w-none">
<h2 class="font-display tracking-widest">Latest Guides</h2>
</div>
<div class="not-prose mt-4 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<div class="not-prose grid gap-4 sm:grid-cols-2 lg:grid-cols-1 xl:grid-cols-2">
<a href="/guides/finding-new-songs" class="card-surface p-5 block">
<div class="text-sm text-muted">Guide</div>
<h3 class="font-semibold mt-1">Finding New Songs (BeatLeader)</h3>
@ -43,6 +53,10 @@
<p class="mt-1 text-sm text-muted">Connect BeatLeader and enable unranked stars in tools.</p>
</a>
</div>
</div>
<Guestbook />
</div>
</section>

View File

@ -0,0 +1,221 @@
import type { RequestHandler } from '@sveltejs/kit';
import {
addGuestbookComment,
deleteGuestbookComment,
getGuestbookComments,
type GuestbookAuthor
} from '$lib/server/guestbookStore';
import { getSession } from '$lib/server/sessionStore';
const MAX_COMMENT_LENGTH = 2000;
const PLAYER_ENDPOINT = 'https://api.beatleader.com/player/';
const DEFAULT_PAGE_SIZE = 5;
const MAX_PAGE_SIZE = 50;
type GuestbookViewer = {
beatleaderId: string;
name: string | null;
avatar: string | null;
};
type GuestbookResponse = {
viewer: GuestbookViewer | null;
comments: ReturnType<typeof getGuestbookComments>;
pagination: {
total: number;
offset: number;
limit: number;
hasPrev: boolean;
hasNext: boolean;
};
};
function mapSessionToViewer(session: ReturnType<typeof getSession>): GuestbookViewer | null {
if (!session) return null;
return {
beatleaderId: session.beatleaderId,
name: session.name ?? null,
avatar: session.avatar ?? null
};
}
async function buildAuthor(session: NonNullable<ReturnType<typeof getSession>>, fetchFn: typeof fetch): Promise<GuestbookAuthor> {
const base: GuestbookAuthor = {
beatleaderId: session.beatleaderId,
name: session.name ?? null,
avatar: session.avatar ?? null,
country: null,
rank: null,
techPp: null,
accPp: null,
passPp: null
};
try {
const res = await fetchFn(`${PLAYER_ENDPOINT}${encodeURIComponent(session.beatleaderId)}?stats=true`);
if (!res.ok) return base;
const data = (await res.json()) as Record<string, unknown>;
return {
beatleaderId: session.beatleaderId,
name: typeof data.name === 'string' ? (data.name as string) : base.name,
avatar: typeof data.avatar === 'string' ? (data.avatar as string) : base.avatar,
country: typeof data.country === 'string' ? (data.country as string) : base.country,
rank: typeof data.rank === 'number' ? (data.rank as number) : base.rank,
techPp: typeof data.techPp === 'number' ? (data.techPp as number) : base.techPp,
accPp: typeof data.accPp === 'number' ? (data.accPp as number) : base.accPp,
passPp: typeof data.passPp === 'number' ? (data.passPp as number) : base.passPp
};
} catch (error) {
console.error('Failed to load BeatLeader profile for guestbook author', error);
return base;
}
}
function sanitizeMessage(value: unknown): { ok: true; message: string } | { ok: false; error: string } {
if (typeof value !== 'string') {
return { ok: false, error: 'Message must be a string' };
}
const trimmed = value.trim();
if (!trimmed) {
return { ok: false, error: 'Message cannot be empty' };
}
if (trimmed.length > MAX_COMMENT_LENGTH) {
return { ok: false, error: `Message exceeds ${MAX_COMMENT_LENGTH} characters` };
}
return { ok: true, message: trimmed };
}
function parseLimit(value: string | null): number {
if (!value) return DEFAULT_PAGE_SIZE;
const parsed = Math.floor(Number(value));
if (!Number.isFinite(parsed) || parsed <= 0) return DEFAULT_PAGE_SIZE;
return Math.min(MAX_PAGE_SIZE, parsed);
}
function parseOffset(value: string | null): number {
if (!value) return 0;
const parsed = Math.floor(Number(value));
if (!Number.isFinite(parsed) || parsed <= 0) return 0;
return parsed;
}
function clampOffset(offset: number, total: number): number {
if (total <= 0) return 0;
const safeOffset = Math.max(0, Math.floor(offset));
const maxOffset = Math.max(0, total - 1);
return Math.min(safeOffset, maxOffset);
}
function buildGuestbookPayload(
session: ReturnType<typeof getSession>,
limit: number,
offset: number
): GuestbookResponse {
const allComments = getGuestbookComments();
const total = allComments.length;
const safeLimit = Math.max(1, Math.min(MAX_PAGE_SIZE, Math.floor(limit)));
const safeOffset = clampOffset(offset, total);
const pageComments = allComments.slice(safeOffset, safeOffset + safeLimit);
return {
viewer: mapSessionToViewer(session),
comments: pageComments,
pagination: {
total,
offset: safeOffset,
limit: safeLimit,
hasPrev: safeOffset > 0,
hasNext: safeOffset + safeLimit < total
}
};
}
function jsonResponse(payload: GuestbookResponse, init?: ResponseInit): Response {
return new Response(JSON.stringify(payload), {
headers: { 'content-type': 'application/json' },
...init
});
}
export const GET: RequestHandler = ({ cookies, url }) => {
const session = getSession(cookies);
const limit = parseLimit(url.searchParams.get('limit'));
const offset = parseOffset(url.searchParams.get('offset'));
return jsonResponse(buildGuestbookPayload(session, limit, offset));
};
export const POST: RequestHandler = async ({ request, cookies, fetch }) => {
const session = getSession(cookies);
if (!session) {
return new Response(
JSON.stringify({ error: 'Unauthorized', login: '/auth/beatleader/login' }),
{ status: 401, headers: { 'content-type': 'application/json' } }
);
}
let body: unknown;
try {
body = await request.json();
} catch {
return new Response(JSON.stringify({ error: 'Invalid JSON body' }), {
status: 400,
headers: { 'content-type': 'application/json' }
});
}
const sanitizedMessage = sanitizeMessage((body as Record<string, unknown>)?.message);
if (!sanitizedMessage.ok) {
return new Response(JSON.stringify({ error: sanitizedMessage.error }), {
status: 400,
headers: { 'content-type': 'application/json' }
});
}
const { message } = sanitizedMessage;
try {
const author = await buildAuthor(session, fetch);
addGuestbookComment({ message, author });
} catch (error) {
const msg = error instanceof Error ? error.message : 'Failed to save comment';
return new Response(JSON.stringify({ error: msg }), {
status: 400,
headers: { 'content-type': 'application/json' }
});
}
return jsonResponse(buildGuestbookPayload(session, DEFAULT_PAGE_SIZE, 0), { status: 201 });
};
export const DELETE: RequestHandler = ({ url, cookies }) => {
const session = getSession(cookies);
if (!session) {
return new Response(
JSON.stringify({ error: 'Unauthorized', login: '/auth/beatleader/login' }),
{ status: 401, headers: { 'content-type': 'application/json' } }
);
}
const id = url.searchParams.get('id') || null;
if (!id) {
return new Response(JSON.stringify({ error: 'Missing comment id' }), {
status: 400,
headers: { 'content-type': 'application/json' }
});
}
const success = deleteGuestbookComment(id, session.beatleaderId);
if (!success) {
return new Response(JSON.stringify({ error: 'Comment not found or not owned by you' }), {
status: 404,
headers: { 'content-type': 'application/json' }
});
}
const limit = parseLimit(url.searchParams.get('limit'));
const offset = parseOffset(url.searchParams.get('offset'));
return jsonResponse(buildGuestbookPayload(session, limit, offset), { status: 200 });
};