dnd-hub/apps/web/src/pages/DashboardPage.tsx
2026-03-16 22:15:15 -04:00

748 lines
26 KiB
TypeScript

import { useCallback, useEffect, useMemo, useState } from "react";
import { Link } from "react-router-dom";
import { api } from "../api/client";
import { useToast } from "../contexts/ToastContext";
interface BlackoutEntry {
userId: number;
username: string;
reason: string | null;
}
interface ScheduleEntry {
date: string;
gameNightId: number;
campaignId: number | null;
campaignName: string | null;
blackouts: BlackoutEntry[];
campaignDmUserIds: number[];
}
interface Campaign {
id: number;
name: string;
}
interface Member {
userId: number;
username: string;
avatarUrl: string | null;
role: string;
}
interface DashboardData {
role: string;
myUserId: number;
defaultGameDay: number | null; // 0=Sun … 6=Sat
members: Member[];
entries: ScheduleEntry[];
myCampaigns: Campaign[];
allCampaigns: Campaign[];
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
function parseLocalDate(dateStr: string): Date {
const [y, m, d] = dateStr.split("-").map(Number);
return new Date(y, m - 1, d);
}
function formatDate(dateStr: string) {
return parseLocalDate(dateStr).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
});
}
function formatDateLong(dateStr: string) {
return parseLocalDate(dateStr).toLocaleDateString("en-US", {
weekday: "long",
month: "long",
day: "numeric",
});
}
function getWeekday(dateStr: string) {
return parseLocalDate(dateStr).toLocaleDateString("en-US", { weekday: "long" });
}
function todayStr() {
return new Date().toISOString().slice(0, 10);
}
function toLocalDateStr(d: Date) {
return [
d.getFullYear(),
String(d.getMonth() + 1).padStart(2, "0"),
String(d.getDate()).padStart(2, "0"),
].join("-");
}
/** Returns the next unscheduled date that falls on defaultDay (0=Sun…6=Sat). */
function nextDefaultNight(defaultDay: number, scheduledDates: Set<string>): string {
const d = new Date();
d.setHours(0, 0, 0, 0);
// Advance to the nearest upcoming occurrence of defaultDay (today counts)
const daysUntil = (defaultDay - d.getDay() + 7) % 7;
d.setDate(d.getDate() + daysUntil);
// Skip weeks that are already scheduled
while (scheduledDates.has(toLocalDateStr(d))) {
d.setDate(d.getDate() + 7);
}
return toLocalDateStr(d);
}
// ─── Tonight hero card ────────────────────────────────────────────────────────
function TonightCard({ entry }: { entry: ScheduleEntry | undefined }) {
if (!entry) {
return (
<div className="tonight-card" style={{ marginBottom: "24px" }}>
<div className="tonight-eyebrow">Tonight</div>
<div className="tonight-empty">No session scheduled for tonight.</div>
</div>
);
}
return (
<div className="tonight-card" style={{ marginBottom: "24px" }}>
<div className="tonight-eyebrow">Tonight · {formatDateLong(entry.date)}</div>
{entry.campaignName ? (
entry.campaignId ? (
<Link to={`/campaigns/${entry.campaignId}`} className="tonight-campaign tonight-campaign-link">
{entry.campaignName}
</Link>
) : (
<div className="tonight-campaign">{entry.campaignName}</div>
)
) : (
<div className="tonight-empty">No campaign selected yet.</div>
)}
{entry.blackouts.length > 0 && (
<div style={{ marginTop: "14px", display: "flex", flexWrap: "wrap", gap: "6px" }}>
{entry.blackouts.map((b) => (
<span key={b.userId} className="avail-chip" title={b.reason ?? undefined}>
{b.username} out
</span>
))}
</div>
)}
</div>
);
}
// ─── Avatar stack ─────────────────────────────────────────────────────────────
const MAX_AVATARS = 8;
function AvatarStack({
members,
blackoutUserIds,
campaignDmUserIds,
allIn,
}: {
members: Member[];
blackoutUserIds: Set<number>;
campaignDmUserIds: Set<number>;
allIn: boolean;
}) {
const visible = members.slice(0, MAX_AVATARS);
const overflow = members.length - MAX_AVATARS;
return (
<div className="avatar-stack">
<div
className={`avatar-stack-group${allIn ? " all-in" : ""}`}
title={allIn ? "Everyone's available" : undefined}
>
{visible.map((m) => {
const isOut = blackoutUserIds.has(m.userId);
const isDm = campaignDmUserIds.has(m.userId);
return (
<div
key={m.userId}
className={`avatar-wrap${isOut ? " out" : ""}`}
title={`${m.username}${isDm ? " (DM)" : ""}${isOut ? " — out" : ""}`}
>
{isDm && <span className="avatar-crown">👑</span>}
{m.avatarUrl ? (
<img
src={m.avatarUrl}
className={`avatar-bubble${isOut ? " out" : " in"}`}
alt={m.username}
/>
) : (
<div className={`avatar-bubble avatar-fallback${isOut ? " out" : " in"}`}>
{m.username[0].toUpperCase()}
</div>
)}
</div>
);
})}
{overflow > 0 && (
<div className="avatar-overflow" title={`${overflow} more`}>
+{overflow}
</div>
)}
{allIn && <span className="avatar-all-in-check"></span>}
</div>
</div>
);
}
// ─── Tab nav icons ────────────────────────────────────────────────────────────
function IconTabBoard() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="3" width="7" height="7" /><rect x="14" y="3" width="7" height="7" />
<rect x="14" y="14" width="7" height="7" /><rect x="3" y="14" width="7" height="7" />
</svg>
);
}
function IconTabCharacters() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
);
}
function IconTabRecaps() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polygon points="23 7 16 12 23 17 23 7" />
<rect x="1" y="5" width="15" height="14" rx="2" ry="2" />
</svg>
);
}
function IconTabWiki() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M4 19.5A2.5 2.5 0 016.5 17H20" />
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 014 19.5v-15A2.5 2.5 0 016.5 2z" />
</svg>
);
}
function IconTabNotes() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
<line x1="10" y1="9" x2="8" y2="9" />
</svg>
);
}
// ─── Schedule row ─────────────────────────────────────────────────────────────
interface ScheduleRowProps {
entry: ScheduleEntry;
role: string;
myUserId: number;
members: Member[];
availableCampaigns: Campaign[];
expanded: boolean;
saving: boolean;
onToggle: () => void;
onAssign: (date: string, campaignId: number) => void;
onUnassign: (date: string) => void;
onBlackout: (date: string) => void;
onRemoveBlackout: (date: string) => void;
}
function ScheduleRow({
entry,
role,
myUserId,
members,
availableCampaigns,
expanded,
saving,
onToggle,
onAssign,
onUnassign,
onBlackout,
onRemoveBlackout,
}: ScheduleRowProps) {
const canManage = role === "dm" || role === "admin";
const myBlackout = entry.blackouts.find((b) => b.userId === myUserId);
const blackoutUserIds = new Set(entry.blackouts.map((b) => b.userId));
const campaignDmUserIds = new Set(entry.campaignDmUserIds);
return (
<div className={`schedule-row${expanded ? " expanded" : ""}`}>
<div className="schedule-row-header" onClick={onToggle}>
<div style={{ flexShrink: 0 }}>
<div className="schedule-date">{formatDate(entry.date)}</div>
<div className="schedule-weekday">{getWeekday(entry.date)}</div>
</div>
{/* Avatar stack fills the middle */}
<AvatarStack
members={members}
blackoutUserIds={blackoutUserIds}
campaignDmUserIds={campaignDmUserIds}
allIn={entry.blackouts.length === 0}
/>
<div className="flex items-center gap-3" style={{ flexShrink: 0 }}>
{entry.campaignId ? (
<Link
to={`/campaigns/${entry.campaignId}`}
className="schedule-campaign selected schedule-campaign-link"
onClick={e => e.stopPropagation()}
title={`Open ${entry.campaignName}`}
>
{entry.campaignName}
</Link>
) : (
<span className="schedule-campaign">No campaign</span>
)}
<span className="schedule-chevron">{expanded ? "▲" : "▼"}</span>
</div>
</div>
{expanded && (
<div className="schedule-expand">
{/* Campaign + Availability — combined row */}
<div className="expand-row">
<div className="expand-section" style={{ flex: 1 }}>
<div className="expand-label">Campaign</div>
{canManage ? (
availableCampaigns.length > 0 ? (
<div className="flex items-center gap-2" style={{ flexWrap: "wrap" }}>
<select
className="form-input form-input-sm"
value={entry.campaignId ?? ""}
disabled={saving}
onChange={(e) => {
if (e.target.value) onAssign(entry.date, Number(e.target.value));
}}
>
<option value=""> select campaign </option>
{availableCampaigns.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
{entry.campaignId && (
<button
className="btn btn-danger btn-sm"
onClick={() => onUnassign(entry.date)}
disabled={saving}
>
Remove
</button>
)}
</div>
) : (
<span className="text-muted" style={{ fontSize: "13px" }}>
No campaigns yet {" "}
<a href="/campaigns" style={{ color: "var(--teal)" }}>
create one first
</a>
</span>
)
) : entry.campaignName ? (
<span style={{ fontSize: "14px", fontWeight: 600, color: "var(--teal)" }}>
{entry.campaignName}
</span>
) : (
<span className="text-muted" style={{ fontSize: "13px" }}>No campaign assigned</span>
)}
</div>
<div className="expand-section">
<div className="expand-label">My Availability</div>
<label className={`avail-toggle${saving ? " disabled" : ""}`}>
<input
type="checkbox"
checked={!myBlackout}
disabled={saving}
onChange={() =>
myBlackout ? onRemoveBlackout(entry.date) : onBlackout(entry.date)
}
/>
<span className="avail-toggle-track" />
<span className="avail-toggle-label">
{myBlackout ? "I'm Out" : "I'm In"}
</span>
</label>
</div>
</div>
{/* Others out */}
{entry.blackouts.filter((b) => b.userId !== myUserId).length > 0 && (
<div className="avail-row">
{entry.blackouts
.filter((b) => b.userId !== myUserId)
.map((b) => (
<span key={b.userId} className="avail-chip" title={b.reason ?? undefined}>
{b.username} out
</span>
))}
</div>
)}
{/* Quick-nav tab buttons — only when a campaign is assigned */}
{entry.campaignId && (
<div className="expand-section">
<div className="expand-label" style={{ textAlign: "center" }}>Quick Nav</div>
<div className="schedule-tab-links">
<Link to={`/campaigns/${entry.campaignId}?tab=board`} className="schedule-tab-btn" title="Board">
<IconTabBoard /><span>Board</span>
</Link>
<Link to={`/campaigns/${entry.campaignId}?tab=characters`} className="schedule-tab-btn" title="Characters">
<IconTabCharacters /><span>Characters</span>
</Link>
<Link to={`/campaigns/${entry.campaignId}?tab=recaps`} className="schedule-tab-btn" title="Recaps">
<IconTabRecaps /><span>Recaps</span>
</Link>
<Link to={`/campaigns/${entry.campaignId}?tab=wiki`} className="schedule-tab-btn" title="Wiki">
<IconTabWiki /><span>Wiki</span>
</Link>
<Link to={`/campaigns/${entry.campaignId}?tab=notes`} className="schedule-tab-btn" title="My Notes">
<IconTabNotes /><span>My Notes</span>
</Link>
</div>
</div>
)}
</div>
)}
</div>
);
}
// ─── Right panel ──────────────────────────────────────────────────────────────
const ROLE_ORDER = ["admin", "dm", "pending_dm", "player"];
function roleBadgeClass(role: string) {
if (role === "admin") return "badge badge-admin";
if (role === "dm") return "badge badge-dm";
if (role === "pending_dm") return "badge badge-pending";
return "badge badge-archived";
}
function RightPanel({
data,
saving,
onRemoveBlackout,
}: {
data: DashboardData;
saving: string | null;
onRemoveBlackout: (date: string) => void;
}) {
const myBlackouts = data.entries.filter((e) =>
e.blackouts.some((b) => b.userId === data.myUserId)
);
const sortedMembers = [...data.members].sort(
(a, b) =>
ROLE_ORDER.indexOf(a.role) - ROLE_ORDER.indexOf(b.role) ||
a.username.localeCompare(b.username)
);
return (
<div style={{ display: "flex", flexDirection: "column", gap: "16px" }}>
{/* Dates I'm Out */}
<div className="card">
<div className="card-header">
<span className="card-title">Dates I'm Out</span>
{myBlackouts.length > 0 && (
<span className="badge badge-pending">{myBlackouts.length}</span>
)}
</div>
<div className="card-body">
{myBlackouts.length === 0 ? (
<p style={{ fontSize: "13px", color: "var(--text-muted)", lineHeight: 1.6 }}>
All clear — you're in for every session.
</p>
) : (
<div style={{ display: "flex", flexDirection: "column" }}>
{myBlackouts.map((e, i) => (
<div
key={e.date}
className="flex items-center justify-between"
style={{
padding: "6px 0",
borderBottom:
i < myBlackouts.length - 1 ? "1px solid var(--border)" : "none",
}}
>
<div>
<div style={{ fontSize: "13px", fontWeight: 600 }}>
{formatDate(e.date)}
</div>
<div style={{ fontSize: "11px", color: "var(--text-dim)" }}>
{getWeekday(e.date)}
</div>
</div>
<button
className="btn btn-secondary btn-sm"
onClick={() => onRemoveBlackout(e.date)}
disabled={saving !== null}
>
I'm back in
</button>
</div>
))}
</div>
)}
</div>
</div>
{/* Guild roster */}
<div className="card">
<div className="card-header">
<span className="card-title">Players</span>
<span className="badge badge-archived">{sortedMembers.length}</span>
</div>
<div className="card-body" style={{ padding: "4px 20px 8px" }}>
{sortedMembers.map((m) => (
<div key={m.userId} className="roster-row">
{m.avatarUrl ? (
<img src={m.avatarUrl} className="roster-avatar" alt={m.username} />
) : (
<div className="roster-avatar-fallback">{m.username[0]}</div>
)}
<span className="roster-name">{m.username}</span>
<span className={roleBadgeClass(m.role)}>
{m.role === "pending_dm" ? "pending" : m.role}
</span>
</div>
))}
</div>
</div>
</div>
);
}
// ─── Dashboard page ───────────────────────────────────────────────────────────
export function DashboardPage() {
const toast = useToast();
const [data, setData] = useState<DashboardData | null>(null);
const [expandedDate, setExpandedDate] = useState<string | null>(null);
const [saving, setSaving] = useState<string | null>(null);
const [error, setError] = useState("");
const [addDate, setAddDate] = useState("");
const [addCampaignId, setAddCampaignId] = useState("");
const [creating, setCreating] = useState(false);
const load = useCallback(() => {
api<DashboardData>("/dashboard")
.then(setData)
.catch(() => {
toast.addToast("Failed to load dashboard. Please try again.", "error");
});
}, [toast]);
useEffect(() => {
load();
}, [load]);
async function run(key: string, fn: () => Promise<unknown>) {
setSaving(key);
setError("");
try {
await fn();
load();
} catch (e) {
toast.addToast("Operation failed. Please try again.", "error");
} finally {
setSaving(null);
}
}
function assignCampaign(date: string, campaignId: number) {
run(`assign:${date}`, () =>
api(`/game-nights/${date}/select-campaign`, {
method: "POST",
body: JSON.stringify({ campaignId }),
})
).then(() => {
toast.addToast(`Campaign assigned to ${formatDate(date)}`, "success");
});
}
function unassignCampaign(date: string) {
run(`unassign:${date}`, () =>
api(`/game-nights/${date}/campaign`, { method: "DELETE" })
).then(() => {
toast.addToast("Campaign removed", "success");
});
}
function addBlackout(date: string) {
run(`blackout:${date}`, () =>
api(`/game-nights/${date}/blackout`, { method: "POST", body: JSON.stringify({}) })
).then(() => {
toast.addToast(`You're marked as out on ${formatDate(date)}`, "info");
});
}
function removeBlackout(date: string) {
run(`rmblackout:${date}`, () =>
api(`/game-nights/${date}/blackout`, { method: "DELETE" })
).then(() => {
toast.addToast(`You're back in for ${formatDate(date)}`, "success");
});
}
async function createNight() {
const dateToUse = addDate || suggestedDate;
if (!dateToUse) return;
setCreating(true);
setError("");
try {
const body: Record<string, unknown> = { date: dateToUse };
if (addCampaignId) body.campaignId = Number(addCampaignId);
await api("/game-nights", { method: "POST", body: JSON.stringify(body) });
setAddDate(""); // clears any manual override; suggestedDate advances automatically on reload
setAddCampaignId("");
load();
toast.addToast(`Game night scheduled for ${formatDate(dateToUse)}`, "success");
} catch (e) {
toast.addToast("Failed to schedule game night. Please try again.", "error");
} finally {
setCreating(false);
}
}
// Auto-suggest the next unscheduled occurrence of the default game day
// Must be before any early return to satisfy Rules of Hooks
const suggestedDate = useMemo(() => {
if (!data || data.defaultGameDay == null) return "";
const scheduled = new Set(data.entries.map((e) => e.date));
return nextDefaultNight(data.defaultGameDay, scheduled);
}, [data]);
if (!data) {
return (
<div className="page">
<div style={{ display: "flex", justifyContent: "center", padding: "60px" }}>
<span className="spinner" style={{ width: "24px", height: "24px" }} />
</div>
</div>
);
}
const today = todayStr();
const todayEntry = data.entries.find((e) => e.date === today);
const upcomingEntries = data.entries.filter((e) => e.date >= today);
const role = data.role;
const canManage = role === "dm" || role === "admin";
const availableCampaigns = role === "admin" ? data.allCampaigns : data.myCampaigns;
// The displayed value: user override takes precedence, else the suggestion
const displayDate = addDate || suggestedDate;
return (
<div className="page">
<div className="page-header">
<h1 className="page-title">Dashboard</h1>
<p className="page-subtitle">Upcoming sessions and shared availability</p>
</div>
<div className="dashboard-layout">
{/* ── Left column: tonight + schedule ── */}
<div>
<TonightCard entry={todayEntry} />
<div className="card">
<div className="card-header">
<span className="card-title">Upcoming Schedule</span>
{upcomingEntries.length > 0 && (
<span className="badge badge-pending">{upcomingEntries.length}</span>
)}
</div>
<div className="schedule-list">
{upcomingEntries.length === 0 ? (
<div className="empty-state">
<div className="empty-state-icon">📅</div>
<div className="empty-state-title">Nothing scheduled yet</div>
{canManage && (
<div className="empty-state-text">
Add a date below to schedule your next session.
</div>
)}
</div>
) : (
upcomingEntries.map((entry) => (
<ScheduleRow
key={entry.date}
entry={entry}
role={role}
myUserId={data.myUserId}
members={data.members}
availableCampaigns={availableCampaigns}
expanded={expandedDate === entry.date}
saving={saving !== null && saving.endsWith(`:${entry.date}`)}
onToggle={() =>
setExpandedDate(expandedDate === entry.date ? null : entry.date)
}
onAssign={assignCampaign}
onUnassign={unassignCampaign}
onBlackout={addBlackout}
onRemoveBlackout={removeBlackout}
/>
))
)}
</div>
{/* Add game night — DM and admin only */}
{canManage && (
<div className="add-night-form">
<div className="add-night-label">Add a game night</div>
<div className="flex items-center gap-2" style={{ flexWrap: "wrap" }}>
<input
type="date"
className="form-input"
style={{ maxWidth: "180px" }}
value={displayDate}
onChange={(e) => setAddDate(e.target.value)}
/>
{availableCampaigns.length > 0 && (
<select
className="form-input"
style={{ maxWidth: "240px" }}
value={addCampaignId}
onChange={(e) => setAddCampaignId(e.target.value)}
>
<option value="">campaign (optional)</option>
{availableCampaigns.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
)}
<button
className="btn btn-primary"
onClick={createNight}
disabled={!displayDate || creating}
>
{creating ? <span className="spinner" /> : "Schedule"}
</button>
</div>
</div>
)}
</div>
</div>
{/* ── Right column: availability + roster ── */}
<RightPanel data={data} saving={saving} onRemoveBlackout={removeBlackout} />
</div>
</div>
);
}