545 lines
14 KiB
Svelte
545 lines
14 KiB
Svelte
<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>
|
||
|