748 lines
26 KiB
TypeScript
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>
|
|
);
|
|
}
|