Add guestbook
This commit is contained in:
parent
60986f8c81
commit
33b3e6f3c1
544
src/lib/components/Guestbook.svelte
Normal file
544
src/lib/components/Guestbook.svelte
Normal 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>
|
||||||
|
|
||||||
130
src/lib/server/guestbookStore.ts
Normal file
130
src/lib/server/guestbookStore.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -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">
|
<section class="grid items-center gap-10 py-12 md:py-20 lg:grid-cols-2">
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<h1 class="font-display text-4xl sm:text-5xl lg:text-6xl leading-tight">
|
<h1 class="font-display text-4xl sm:text-5xl lg:text-6xl leading-tight">
|
||||||
@ -21,27 +25,37 @@
|
|||||||
<div class="mt-1 text-2xl font-mono">Player History Comparison</div>
|
<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>
|
<div class="mt-1 text-sm text-muted">A vs B: songs A played that B has not</div>
|
||||||
</a>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="py-10">
|
<section class="py-12">
|
||||||
<div class="prose prose-invert max-w-none">
|
<div class="grid gap-10 xl:gap-14 lg:grid-cols-[minmax(0,2fr)_minmax(0,1fr)] items-start">
|
||||||
<h2 class="font-display tracking-widest">Latest Guides</h2>
|
<div class="space-y-6">
|
||||||
</div>
|
<div class="prose prose-invert max-w-none">
|
||||||
<div class="not-prose mt-4 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
<h2 class="font-display tracking-widest">Latest Guides</h2>
|
||||||
<a href="/guides/finding-new-songs" class="card-surface p-5 block">
|
</div>
|
||||||
<div class="text-sm text-muted">Guide</div>
|
<div class="not-prose grid gap-4 sm:grid-cols-2 lg:grid-cols-1 xl:grid-cols-2">
|
||||||
<h3 class="font-semibold mt-1">Finding New Songs (BeatLeader)</h3>
|
<a href="/guides/finding-new-songs" class="card-surface p-5 block">
|
||||||
<p class="mt-1 text-sm text-muted">Month-by-month search using unranked stars, tech rating, and friend filters.</p>
|
<div class="text-sm text-muted">Guide</div>
|
||||||
</a>
|
<h3 class="font-semibold mt-1">Finding New Songs (BeatLeader)</h3>
|
||||||
<a href="/guides/beatleader-auth" class="card-surface p-5 block">
|
<p class="mt-1 text-sm text-muted">Month-by-month search using unranked stars, tech rating, and friend filters.</p>
|
||||||
<div class="text-sm text-muted">Guide</div>
|
</a>
|
||||||
<h3 class="font-semibold mt-1">BeatLeader Authentication</h3>
|
<a href="/guides/beatleader-auth" class="card-surface p-5 block">
|
||||||
<p class="mt-1 text-sm text-muted">Connect BeatLeader and enable unranked stars in tools.</p>
|
<div class="text-sm text-muted">Guide</div>
|
||||||
</a>
|
<h3 class="font-semibold mt-1">BeatLeader Authentication</h3>
|
||||||
|
<p class="mt-1 text-sm text-muted">Connect BeatLeader and enable unranked stars in tools.</p>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Guestbook />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
221
src/routes/api/guestbook/+server.ts
Normal file
221
src/routes/api/guestbook/+server.ts
Normal 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 });
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
Loading…
x
Reference in New Issue
Block a user