plebsaber.stream/src/lib/components/Guestbook.svelte
2025-11-03 16:41:04 -08:00

545 lines
14 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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 markdrop 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>