102 lines
8.4 KiB
TypeScript
102 lines
8.4 KiB
TypeScript
'use client';
|
|
|
|
import React, { useEffect, useRef, useState } from 'react';
|
|
import type { FounderClubContext } from '../../lib/club-data';
|
|
import { sanitizePortalError } from '../../lib/club-data';
|
|
import { createWallPost, deleteWallPost, replyToWallPost, setWallPostPinned, subscribeClubWall, type WallPost } from '../../lib/club-community';
|
|
import { CommunityDialog, communityButton, communityInput } from './community-dialog';
|
|
|
|
function postDate(value: string): string {
|
|
if (!value) return 'Sending…';
|
|
const date = new Date(value);
|
|
return Number.isNaN(date.getTime()) ? 'Date unavailable' : date.toLocaleString();
|
|
}
|
|
|
|
export function FounderWall({ context }: { context: FounderClubContext }) {
|
|
const [posts, setPosts] = useState<WallPost[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [loadError, setLoadError] = useState<string | null>(null);
|
|
const [actionError, setActionError] = useState<string | null>(null);
|
|
const [content, setContent] = useState('');
|
|
const [pinned, setPinned] = useState(false);
|
|
const [replies, setReplies] = useState<Record<string, string>>({});
|
|
const [busy, setBusy] = useState<string | null>(null);
|
|
const busyRef = useRef(false);
|
|
const [removing, setRemoving] = useState<WallPost | null>(null);
|
|
const [retry, setRetry] = useState(0);
|
|
const [notice, setNotice] = useState('');
|
|
|
|
useEffect(() => {
|
|
setLoading(true); setLoadError(null); setPosts([]);
|
|
if (context.isDemoFixture) { setLoading(false); return; }
|
|
return subscribeClubWall(context.club.id, (items) => { setPosts(items); setLoading(false); }, (err) => {
|
|
setPosts([]); setLoading(false); setLoadError(sanitizePortalError(err, 'The club wall could not be loaded.'));
|
|
});
|
|
}, [context, retry]);
|
|
|
|
async function perform(key: string, action: () => Promise<void>, after: () => void) {
|
|
if (busyRef.current || context.isDemoFixture) return;
|
|
busyRef.current = true; setBusy(key); setActionError(null); setNotice('');
|
|
try { await action(); after(); setNotice('Changes saved.'); }
|
|
catch (err) { setActionError(sanitizePortalError(err, 'Your changes could not be saved. Please try again.')); }
|
|
finally { busyRef.current = false; setBusy(null); }
|
|
}
|
|
const disabled = context.isDemoFixture || !!busy || loading || !!loadError;
|
|
|
|
return (
|
|
<section className="space-y-6 max-w-5xl">
|
|
<div><p className="font-mono text-xs text-brand-cyan mb-2">CLUB MANAGEMENT / COMMUNITY</p>
|
|
<h1 className="font-headline text-3xl font-bold text-white">Club Wall</h1>
|
|
<p className="mt-2 text-on-surface-variant">Leadership announcements and conversations with your club.</p></div>
|
|
{context.isDemoFixture && <p className="text-brand-cyan">Development preview • changes are disabled.</p>}
|
|
<form className="rounded-2xl border border-brand-border bg-brand-card p-5 space-y-4" onSubmit={(event) => {
|
|
event.preventDefault();
|
|
void perform('compose', () => createWallPost(context.club.id, content, pinned), () => { setContent(''); setPinned(false); });
|
|
}}>
|
|
<label className="block font-semibold text-white">Post to your club
|
|
<textarea id="wall-post-content" value={content} maxLength={5000} required rows={4} onChange={(event) => setContent(event.target.value)}
|
|
className={`${communityInput} mt-3 resize-y`} placeholder="Share an announcement, ride briefing, or discussion…" /></label>
|
|
<div className="flex flex-wrap items-center justify-between gap-4">
|
|
<label className="flex gap-2 items-center text-on-surface-variant"><input type="checkbox" checked={pinned} onChange={(event) => setPinned(event.target.checked)} />Pin leadership announcement</label>
|
|
<button disabled={disabled || !content.trim()} className={`${communityButton} bg-primary-container text-on-primary-container font-semibold`}>{busy === 'compose' ? 'Publishing…' : 'Publish post'}</button>
|
|
</div>
|
|
</form>
|
|
{actionError && <p role="alert" className="rounded-xl border border-error/30 p-4 text-error">{actionError}</p>}
|
|
<p role="status" className="text-secondary">{notice}</p>
|
|
{loading ? <p role="status">Loading club wall…</p> : loadError ? <div className="space-y-3"><p role="alert" className="text-error">{loadError}</p><button className={communityButton} onClick={() => setRetry((value) => value + 1)}>Try again</button></div>
|
|
: posts.length === 0 ? <div className="rounded-2xl border border-dashed border-brand-border p-10 text-center"><span aria-hidden="true" className="material-symbols-outlined text-4xl text-brand-cyan">forum</span><h2 className="text-xl font-headline text-white mt-3">Start the conversation</h2><p className="text-on-surface-variant mt-2">No club posts yet. Share your first update above.</p></div>
|
|
: <div className="space-y-5">{posts.map((post) => <article key={post.id} aria-label={`Post by ${post.authorDisplayName}`} className={`rounded-2xl border bg-brand-card p-5 sm:p-6 ${post.isPinned ? 'border-brand-cyan/50' : 'border-brand-border'}`}>
|
|
{post.isPinned && <p className="font-mono text-xs text-brand-cyan mb-4">PINNED ANNOUNCEMENT</p>}
|
|
<header className="flex flex-wrap justify-between gap-3">
|
|
<div><p className="font-semibold text-white">{post.authorDisplayName} <span className="text-xs text-brand-cyan font-normal">{post.authorDisplayRank}</span></p>
|
|
<p className="text-xs text-outline mt-1">{post.authorHandle} · {postDate(post.createdAt)}</p></div>
|
|
<div className="flex gap-2">
|
|
<button className={communityButton} disabled={disabled} aria-label={`${post.isPinned ? 'Unpin' : 'Pin'} post by ${post.authorDisplayName}`} onClick={() => void perform(post.id, () => setWallPostPinned(context.club.id, post.id, !post.isPinned), () => {})}>{post.isPinned ? 'Unpin' : 'Pin'}</button>
|
|
<button className={`${communityButton} text-error`} disabled={disabled} aria-label={`Delete post by ${post.authorDisplayName}`} onClick={() => setRemoving(post)}>Delete</button>
|
|
</div>
|
|
</header>
|
|
<p className="my-5 whitespace-pre-wrap break-words leading-relaxed text-on-surface">{post.content}</p>
|
|
<p className="font-mono text-xs text-outline border-t border-brand-border pt-3">{post.comments.length} replies · {post.fistBumpCount} fist bumps</p>
|
|
{post.comments.length > 0 && <ol className="mt-4 space-y-3">{post.comments.map((comment, index) => <li key={`${comment.id}-${index}`} className="rounded-xl bg-brand-bg p-4 border-l-2 border-brand-border">
|
|
<p className="text-xs font-semibold text-white">{comment.authorDisplayName} <span className="font-normal text-brand-cyan">{comment.authorDisplayRank}</span></p>
|
|
<p className="mt-2 whitespace-pre-wrap break-words">{comment.content}</p><p className="mt-2 text-xs text-outline">{postDate(comment.createdAt)}</p>
|
|
</li>)}</ol>}
|
|
<form className="mt-4 flex flex-col sm:flex-row gap-3" onSubmit={(event) => {
|
|
event.preventDefault();
|
|
void perform(post.id, () => replyToWallPost(context.club.id, post.id, replies[post.id] || ''), () => setReplies((current) => ({ ...current, [post.id]: '' })));
|
|
}}>
|
|
<label className="flex-1"><span className="sr-only">Reply to {post.authorDisplayName}</span><textarea rows={2} required maxLength={2000} className={communityInput} placeholder="Write a reply…" value={replies[post.id] || ''} onChange={(event) => setReplies((current) => ({ ...current, [post.id]: event.target.value }))} /></label>
|
|
<button className={communityButton} disabled={disabled || !replies[post.id]?.trim() || post.comments.length >= 200}>Reply</button>
|
|
</form>
|
|
{post.comments.length >= 200 && <p className="text-xs text-outline mt-2">This thread has reached its reply limit.</p>}
|
|
</article>)}</div>}
|
|
<p className="text-xs text-outline">Showing the 50 most recent posts, with pinned announcements first.</p>
|
|
{removing && <CommunityDialog title="Delete wall post" busy={!!busy} onClose={() => setRemoving(null)}>
|
|
<p className="mb-4">Delete this post and its replies? This cannot be undone.</p>
|
|
<button className={`${communityButton} text-error`} disabled={!!busy} onClick={() => void perform(removing.id, () => deleteWallPost(context.club.id, removing.id), () => setRemoving(null))}>Confirm deletion</button>
|
|
{actionError && <p role="alert" className="mt-3 text-error">{actionError}</p>}
|
|
</CommunityDialog>}
|
|
</section>
|
|
);
|
|
}
|