Updates to pages

This commit is contained in:
2026-04-04 18:31:28 -04:00
parent 070337577c
commit ca95582d92
42 changed files with 2831 additions and 640 deletions

View File

@@ -1,371 +1,50 @@
import { useState, useEffect, useRef } from "react";
import type { GridMap, ListedMap, PublicAccess, Tool, UserInfo } from "./types";
import type { GridHandle } from "./components/Grid";
import { api, auth } from "./api";
import { useState } from "react";
import { Routes, Route, Navigate } from "react-router-dom";
import { useAuth } from "./context/AuthContext";
import Header from "./components/Header";
import ControlPanel from "./components/ControlPanel";
import ColorPanel from "./components/ColorPanel";
import Grid from "./components/Grid";
import LoginModal from "./components/LoginModal";
import AccountPanel from "./components/AccountPanel";
import FloatingMapControls from "./components/FloatingMapControls";
import NewMapModal from "./components/NewMapModal";
import EditMapModal from "./components/EditMapModal";
import MapListModal from "./components/MapListModal";
import "./components/Modal.css";
import MapPage from "./pages/MapPage";
import AccountPage from "./pages/AccountPage";
import AdminPage from "./pages/AdminPage";
import "./App.css";
const DEFAULT_COLORS = [
"#6b7280",
"#92400e",
"#15803d",
"#1d4ed8",
"#7c3aed",
"#dc2626",
"#ca8a04",
"#0f172a",
"#f9fafb",
];
function getMapIdFromUrl(): string | null {
const match = window.location.pathname.match(/^\/map\/([^/]+)/);
return match ? decodeURIComponent(match[1]) : null;
}
function getQueryParam(key: string): string | null {
return new URLSearchParams(window.location.search).get(key);
}
function removeQueryParam(key: string) {
const url = new URL(window.location.href);
url.searchParams.delete(key);
window.history.replaceState(
null,
"",
url.pathname + (url.search !== "?" ? url.search : ""),
);
}
export default function App() {
// ── Auth state ──
const [user, setUser] = useState<UserInfo | null>(null);
const [authLoading, setAuthLoading] = useState(true);
const { user, authLoading } = useAuth();
const [mapTitle, setMapTitle] = useState<string | null>(null);
// ── Map state ──
const [maps, setMaps] = useState<ListedMap[]>([]);
const [selectedId, setSelectedId] = useState<string | null>(getMapIdFromUrl);
/** Info for maps accessed via URL that aren't in the user's list (e.g. public maps). */
const [directMapInfo, setDirectMapInfo] = useState<GridMap | null>(null);
/** True when the current selectedId returned 403 (no access). */
const [accessDenied, setAccessDenied] = useState(false);
const [accessRequestSent, setAccessRequestSent] = useState(false);
// ── Tool + color ──
const [tool, setTool] = useState<Tool>("pan");
const [activeColor, setActiveColor] = useState(DEFAULT_COLORS[0]);
const [mapColors, setMapColors] = useState<string[]>(DEFAULT_COLORS);
const gridRef = useRef<GridHandle>(null);
// ── Modal visibility ──
const [showLoginModal, setShowLoginModal] = useState(false);
const [showAccountPanel, setShowAccountPanel] = useState(false);
const [showNewMap, setShowNewMap] = useState(false);
const [showEditMap, setShowEditMap] = useState(false);
const [showMapList, setShowMapList] = useState(false);
// ── Derived ──
const selectedMapFromList = maps.find((m) => m.id === selectedId) ?? null;
const selectedMapInfo: GridMap | ListedMap | null =
selectedMapFromList ?? directMapInfo;
const isOwner =
user !== null &&
selectedMapInfo !== null &&
selectedMapInfo.owner_id === user.id;
// ── On mount: load session + handle OAuth errors ──
useEffect(() => {
auth.me().then((u) => {
setUser(u);
setAuthLoading(false);
});
const error = getQueryParam("error");
if (error) {
console.error("OAuth error:", error);
removeQueryParam("error");
}
}, []);
// ── Load map list after auth resolves ──
useEffect(() => {
if (!authLoading) {
api.listMaps().then(setMaps).catch(console.error);
}
}, [user, authLoading]);
// ── Direct fetch for URL-accessed maps not in the user's list ──
useEffect(() => {
if (!selectedId || authLoading) {
setDirectMapInfo(null);
setAccessDenied(false);
return;
}
const inList = maps.some((m) => m.id === selectedId);
if (inList) {
setDirectMapInfo(null);
setAccessDenied(false);
return;
}
setDirectMapInfo(null);
setAccessDenied(false);
setAccessRequestSent(false);
api
.getMap(selectedId)
.then((state) => {
setDirectMapInfo(state.map);
})
.catch((err) => {
const msg = err instanceof Error ? err.message : String(err);
if (msg.startsWith("403")) {
setAccessDenied(true);
} else {
// 404 or unknown — clear invalid URL
setSelectedId(null);
window.history.replaceState(null, "", "/map");
}
});
}, [selectedId, maps, authLoading]); // eslint-disable-line react-hooks/exhaustive-deps
// ── Keep URL in sync ──
useEffect(() => {
const path = selectedId ? `/map/${encodeURIComponent(selectedId)}` : "/map";
window.history.replaceState(null, "", path);
}, [selectedId]);
// ── Reset palette + access state when map deselected ──
useEffect(() => {
if (!selectedId) {
setMapColors(DEFAULT_COLORS);
setActiveColor(DEFAULT_COLORS[0]);
setAccessRequestSent(false);
}
}, [selectedId]);
// ── Handlers ──
async function handleCreate(name: string, publicAccess: PublicAccess) {
const m = await api.createMap(name, publicAccess);
// Optimistically add to list as an owner entry
const listed: ListedMap = {
...m,
owner_username: user!.username,
user_role: "owner",
is_favorited: false,
};
setMaps((prev) => [listed, ...prev]);
setSelectedId(m.id);
}
async function handleDelete() {
if (!selectedId) return;
if (!confirm("Delete this map? This cannot be undone.")) return;
try {
await api.deleteMap(selectedId);
setMaps((prev) => prev.filter((m) => m.id !== selectedId));
setSelectedId(null);
} catch (err) {
console.error("Failed to delete map", err);
}
}
function handleMapUpdated(updated: GridMap) {
setMaps((prev) =>
prev.map((m) =>
m.id === updated.id
? {
...m,
name: updated.name,
public_access: updated.public_access,
updated_at: updated.updated_at,
}
: m,
),
);
if (directMapInfo?.id === updated.id) {
setDirectMapInfo(updated);
}
}
function handleColorsLoaded(colors: string[]) {
setMapColors(colors);
setActiveColor((prev) => (colors.includes(prev) ? prev : colors[0]));
}
function handleColorsChange(colors: string[]) {
setMapColors(colors);
gridRef.current?.sendColorUpdate(colors);
}
async function handleUserRefresh() {
const u = await auth.me();
setUser(u);
}
async function handleRequestAccess(role: "viewer" | "editor") {
if (!selectedId) return;
try {
await api.requestAccess(selectedId, role);
setAccessRequestSent(true);
} catch (err) {
console.error("Failed to request access", err);
}
}
// ── Render ──
return (
<div className="app">
<Header
user={user}
authLoading={authLoading}
selectedMapName={selectedMapInfo?.name ?? null}
onLoginClick={() => setShowLoginModal(true)}
onAccountClick={() => setShowAccountPanel(true)}
/>
<Header mapTitle={mapTitle} />
<div className="app-body">
<div className="app-grid-area">
{/* Top-left floating map controls */}
<FloatingMapControls
isLoggedIn={!!user}
hasSelectedMap={!!selectedId}
isOwner={isOwner}
onNewMap={() => setShowNewMap(true)}
onViewMaps={() => setShowMapList(true)}
onEditMap={() => setShowEditMap(true)}
onDeleteMap={handleDelete}
<Routes>
<Route index element={<Navigate to="/map" replace />} />
<Route path="/map" element={<MapPage setMapTitle={setMapTitle} />} />
<Route
path="/map/:mapId"
element={<MapPage setMapTitle={setMapTitle} />}
/>
{selectedId && !accessDenied ? (
<>
<Grid
key={selectedId}
ref={gridRef}
mapId={selectedId}
tool={tool}
paintColor={activeColor}
tokenColor={activeColor}
onColorsLoaded={handleColorsLoaded}
/>
<div className="floating-panels-container">
<ControlPanel tool={tool} onToolChange={setTool} />
<ColorPanel
colors={mapColors}
activeColor={activeColor}
onColorChange={setActiveColor}
onColorsChange={handleColorsChange}
/>
</div>
</>
) : accessDenied ? (
<div className="access-denied-state">
<p className="access-denied-title">
You don't have access to this map
</p>
{!user ? (
<p className="access-denied-hint">
<button
className="link-btn"
onClick={() => setShowLoginModal(true)}
>
Log in
</button>{" "}
to request access or view your permissions.
</p>
) : accessRequestSent ? (
<p className="access-denied-hint access-request-sent">
✓ Access request sent! The map owner will be notified.
</p>
<Route
path="/account"
element={
authLoading ? null : user ? (
<AccountPage />
) : (
<div className="access-request-actions">
<p className="access-denied-hint">
Request access from the map owner:
</p>
<div className="access-request-btns">
<button
className="btn-request-access"
onClick={() => handleRequestAccess("viewer")}
>
Request Viewer Access
</button>
<button
className="btn-request-access"
onClick={() => handleRequestAccess("editor")}
>
Request Editor Access
</button>
</div>
</div>
)}
</div>
) : (
<div className="empty-state">
<p>Select or create a map to begin</p>
<p className="empty-hint">
{!user
? "Log in to create maps and access private maps"
: maps.length === 0
? 'Click "+ New Map" in the top-left to get started'
: 'Click "Maps" in the top-left to choose a map'}
</p>
</div>
)}
</div>
<Navigate to="/map" replace />
)
}
/>
<Route
path="/admin"
element={
authLoading ? null : user?.role === "admin" ? (
<AdminPage />
) : (
<Navigate to="/map" replace />
)
}
/>
<Route path="*" element={<Navigate to="/map" replace />} />
</Routes>
</div>
{/* ── Global modals (always available regardless of page) ── */}
{showLoginModal && (
<LoginModal
onClose={() => setShowLoginModal(false)}
onLogin={async (u) => {
setUser(u);
api.listMaps().then(setMaps).catch(console.error);
}}
/>
)}
{showAccountPanel && user && (
<AccountPanel
user={user}
onClose={() => setShowAccountPanel(false)}
onRefresh={handleUserRefresh}
/>
)}
{showNewMap && (
<NewMapModal
onClose={() => setShowNewMap(false)}
onCreate={handleCreate}
/>
)}
{showEditMap && selectedMapInfo && (
<EditMapModal
map={selectedMapInfo}
onClose={() => setShowEditMap(false)}
onUpdated={handleMapUpdated}
/>
)}
{showMapList && (
<MapListModal
maps={maps}
selectedMapId={selectedId}
onSelect={(id) => setSelectedId(id)}
onClose={() => setShowMapList(false)}
onMapsChange={setMaps}
/>
)}
</div>
);
}

View File

@@ -1,4 +1,7 @@
import type {
AdminUser,
AudioStatus,
DiscordGuild,
GridMap,
ListedMap,
MapAccessRequest,
@@ -230,3 +233,67 @@ export const auth = {
});
},
};
const ADMIN_BASE = "/api/admin";
export const adminApi = {
/** List all user accounts (admin only). */
listUsers: (): Promise<AdminUser[]> =>
request<AdminUser[]>(`${ADMIN_BASE}/users`),
/** Change a user's site role (admin only). */
setUserRole: (id: string, role: "admin" | "user"): Promise<void> =>
request<void>(`${ADMIN_BASE}/users/${id}/role`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ role }),
}),
/** Ban a user account (admin only). */
banUser: (id: string): Promise<void> =>
request<void>(`${ADMIN_BASE}/users/${id}/ban`, { method: "PUT" }),
/** Unban a user account (admin only). */
unbanUser: (id: string): Promise<void> =>
request<void>(`${ADMIN_BASE}/users/${id}/unban`, { method: "PUT" }),
/** Permanently delete a user account (admin only). */
deleteUser: (id: string): Promise<void> =>
request<void>(`${ADMIN_BASE}/users/${id}`, { method: "DELETE" }),
};
const AUDIO_BASE = "/api/audio";
export const audioApi = {
/** List all Discord guilds the bot is currently in. */
listGuilds: (): Promise<DiscordGuild[]> =>
request<DiscordGuild[]>(`${AUDIO_BASE}/guilds`),
/** Get the current audio status for a guild (voice channel, track, queue). */
getStatus: (guildId: string): Promise<AudioStatus> =>
request<AudioStatus>(`${AUDIO_BASE}/${guildId}/status`),
/** Enqueue a track URL for playback (bot joins the caller's voice channel). */
play: (guildId: string, url: string): Promise<void> =>
request<void>(`${AUDIO_BASE}/${guildId}/play`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url }),
}),
/** Pause the currently playing track. */
pause: (guildId: string): Promise<void> =>
request<void>(`${AUDIO_BASE}/${guildId}/pause`, { method: "POST" }),
/** Resume a paused track. */
resume: (guildId: string): Promise<void> =>
request<void>(`${AUDIO_BASE}/${guildId}/resume`, { method: "POST" }),
/** Stop playback and clear the queue. */
stop: (guildId: string): Promise<void> =>
request<void>(`${AUDIO_BASE}/${guildId}/stop`, { method: "POST" }),
/** Skip the current track. */
skip: (guildId: string): Promise<void> =>
request<void>(`${AUDIO_BASE}/${guildId}/skip`, { method: "POST" }),
};

View File

@@ -290,6 +290,24 @@
cursor: not-allowed;
}
/* ── Page mode ── */
.account-panel-page {
width: 480px;
max-width: 100%;
max-height: none;
border-radius: 10px;
border: 1px solid #2e3348;
}
.account-back-btn {
font-size: 0.82rem;
color: #818cf8;
}
.account-back-btn:hover {
color: #a5b4fc;
}
/* ── Footer ── */
.account-footer {
border-top: 1px solid #2e3348;

View File

@@ -8,9 +8,17 @@ interface Props {
user: UserInfo;
onClose: () => void;
onRefresh: () => void;
/** "modal" (default) renders with a full-screen backdrop overlay.
* "page" renders inline for use as a standalone page. */
mode?: "modal" | "page";
}
export default function AccountPanel({ user, onClose, onRefresh }: Props) {
export default function AccountPanel({
user,
onClose,
onRefresh,
mode = "modal",
}: Props) {
const discordConnection = user.connections.find(
(c) => c.provider === "discord",
);
@@ -114,15 +122,14 @@ export default function AccountPanel({ user, onClose, onRefresh }: Props) {
}
}
async function handleLogout() {
await auth.logout();
}
return (
<div className="account-backdrop" onClick={onClose}>
<div className="account-panel" onClick={(e) => e.stopPropagation()}>
<div className="account-header">
<h2>Account</h2>
const panelContent = (
<div
className={`account-panel${mode === "page" ? " account-panel-page" : ""}`}
onClick={(e) => e.stopPropagation()}
>
<div className="account-header">
<h2>Account</h2>
{mode != "page" && (
<button
className="account-close"
onClick={onClose}
@@ -130,220 +137,230 @@ export default function AccountPanel({ user, onClose, onRefresh }: Props) {
>
</button>
</div>
)}
</div>
{/* ── Profile ── */}
<section className="account-section">
<h3>Profile</h3>
<form onSubmit={handleSaveProfile} className="profile-form">
{/* ── Profile ── */}
<section className="account-section">
<h3>Profile</h3>
<form onSubmit={handleSaveProfile} className="profile-form">
<div className="account-field readonly-field">
<span className="account-label">Username</span>
<span className="account-value">{user.username}</span>
</div>
<div className="profile-name-row">
<label className="account-field-label">
First Name
<input
type="text"
value={firstName}
onChange={(e) => handleFirstNameChange(e.target.value)}
placeholder="Optional"
maxLength={64}
/>
</label>
<label className="account-field-label">
Last Name
<input
type="text"
value={lastName}
onChange={(e) => handleLastNameChange(e.target.value)}
placeholder="Optional"
maxLength={64}
/>
</label>
</div>
{user.email && (
<div className="account-field readonly-field">
<span className="account-label">Username</span>
<span className="account-value">{user.username}</span>
</div>
<div className="profile-name-row">
<label className="account-field-label">
First Name
<input
type="text"
value={firstName}
onChange={(e) => handleFirstNameChange(e.target.value)}
placeholder="Optional"
maxLength={64}
/>
</label>
<label className="account-field-label">
Last Name
<input
type="text"
value={lastName}
onChange={(e) => handleLastNameChange(e.target.value)}
placeholder="Optional"
maxLength={64}
/>
</label>
<span className="account-label">Email</span>
<span className="account-value">{user.email}</span>
</div>
)}
{user.email && (
<div className="account-field readonly-field">
<span className="account-label">Email</span>
<span className="account-value">{user.email}</span>
</div>
)}
{profileError && <p className="account-error">{profileError}</p>}
{profileSuccess && <p className="account-success">Profile saved!</p>}
{profileError && <p className="account-error">{profileError}</p>}
{profileSuccess && (
<p className="account-success">Profile saved!</p>
)}
{profileDirty && (
<div className="profile-actions">
<button
type="button"
className="btn-text"
onClick={() => {
setFirstName(user.first_name ?? "");
setLastName(user.last_name ?? "");
setProfileDirty(false);
setProfileError(null);
}}
>
Cancel
</button>
<button
type="submit"
className="btn-save"
disabled={profileSaving}
>
{profileSaving ? "Saving…" : "Save"}
</button>
</div>
)}
</form>
</section>
{/* ── Password ── */}
<section className="account-section">
<div className="section-header-row">
<h3>{user.has_password ? "Password" : "Set Password"}</h3>
{!showPasswordSection && (
{profileDirty && (
<div className="profile-actions">
<button
type="button"
className="btn-text"
onClick={() => {
setShowPasswordSection(true);
setPwError(null);
setPwSuccess(false);
setFirstName(user.first_name ?? "");
setLastName(user.last_name ?? "");
setProfileDirty(false);
setProfileError(null);
}}
>
{user.has_password ? "Change" : "Set Password"}
Cancel
</button>
<button
type="submit"
className="btn-save"
disabled={profileSaving}
>
{profileSaving ? "Saving…" : "Save"}
</button>
)}
</div>
{pwSuccess && !showPasswordSection && (
<p className="account-success">Password updated successfully!</p>
)}
{showPasswordSection && (
<form onSubmit={handleChangePassword} className="password-form">
{user.has_password && (
<label className="account-field-label">
Current Password
<input
type="password"
value={pwCurrent}
onChange={(e) => setPwCurrent(e.target.value)}
autoComplete="current-password"
required
/>
</label>
)}
<label className="account-field-label">
New Password
<input
type="password"
value={pwNew}
onChange={(e) => setPwNew(e.target.value)}
autoComplete="new-password"
required
minLength={8}
/>
</label>
<label className="account-field-label">
Confirm New Password
<input
type="password"
value={pwConfirm}
onChange={(e) => setPwConfirm(e.target.value)}
autoComplete="new-password"
required
minLength={8}
/>
</label>
{pwError && <p className="account-error">{pwError}</p>}
<div className="profile-actions">
<button
type="button"
className="btn-text"
onClick={() => {
setShowPasswordSection(false);
setPwCurrent("");
setPwNew("");
setPwConfirm("");
setPwError(null);
}}
>
Cancel
</button>
<button type="submit" className="btn-save" disabled={pwSaving}>
{pwSaving
? "Saving…"
: user.has_password
? "Update Password"
: "Set Password"}
</button>
</div>
</form>
)}
</section>
{/* ── Connected services ── */}
<section className="account-section">
<h3>Connected Accounts</h3>
<div className="account-connection">
<FaDiscord />
<div className="connection-info">
<span className="connection-name">Discord</span>
{discordConnection ? (
<span className="connection-linked">
{discordConnection.provider_username ?? "Connected"}
</span>
) : (
<span className="connection-unlinked">Not connected</span>
)}
</div>
)}
</form>
</section>
{/* ── Password ── */}
<section className="account-section">
<div className="section-header-row">
<h3>{user.has_password ? "Password" : "Set Password"}</h3>
{!showPasswordSection && (
<button
className="btn-text"
onClick={() => {
setShowPasswordSection(true);
setPwError(null);
setPwSuccess(false);
}}
>
{user.has_password ? "Change" : "Set Password"}
</button>
)}
</div>
{pwSuccess && !showPasswordSection && (
<p className="account-success">Password updated successfully!</p>
)}
{showPasswordSection && (
<form onSubmit={handleChangePassword} className="password-form">
{user.has_password && (
<label className="account-field-label">
Current Password
<input
type="password"
value={pwCurrent}
onChange={(e) => setPwCurrent(e.target.value)}
autoComplete="current-password"
required
/>
</label>
)}
<label className="account-field-label">
New Password
<input
type="password"
value={pwNew}
onChange={(e) => setPwNew(e.target.value)}
autoComplete="new-password"
required
minLength={8}
/>
</label>
<label className="account-field-label">
Confirm New Password
<input
type="password"
value={pwConfirm}
onChange={(e) => setPwConfirm(e.target.value)}
autoComplete="new-password"
required
minLength={8}
/>
</label>
{pwError && <p className="account-error">{pwError}</p>}
<div className="profile-actions">
<button
type="button"
className="btn-text"
onClick={() => {
setShowPasswordSection(false);
setPwCurrent("");
setPwNew("");
setPwConfirm("");
setPwError(null);
}}
>
Cancel
</button>
<button type="submit" className="btn-save" disabled={pwSaving}>
{pwSaving
? "Saving…"
: user.has_password
? "Update Password"
: "Set Password"}
</button>
</div>
</form>
)}
</section>
{/* ── Connected services ── */}
<section className="account-section">
<h3>Connected Accounts</h3>
<div className="account-connection">
<FaDiscord />
<div className="connection-info">
<span className="connection-name">Discord</span>
{discordConnection ? (
<button
className="btn-disconnect"
onClick={handleDisconnectDiscord}
disabled={!user.has_password}
title={
!user.has_password
? "Set a password first before disconnecting Discord"
: "Disconnect Discord"
}
>
Disconnect
</button>
<span className="connection-linked">
{discordConnection.provider_username ?? "Connected"}
</span>
) : (
<button
className="btn-connect-discord"
onClick={handleConnectDiscord}
>
Connect
</button>
<span className="connection-unlinked">Not connected</span>
)}
</div>
{discordConnection && !user.has_password && (
<p className="connection-hint">
Set a password above before disconnecting Discord to avoid being
locked out.
</p>
{discordConnection ? (
<button
className="btn-disconnect"
onClick={handleDisconnectDiscord}
disabled={!user.has_password}
title={
!user.has_password
? "Set a password first before disconnecting Discord"
: "Disconnect Discord"
}
>
Disconnect
</button>
) : (
<button
className="btn-connect-discord"
onClick={handleConnectDiscord}
>
Connect
</button>
)}
</section>
</div>
{/* ── Footer ── */}
{discordConnection && !user.has_password && (
<p className="connection-hint">
Set a password above before disconnecting Discord to avoid being
locked out.
</p>
)}
</section>
{/* ── Footer (modal-only logout) ── */}
{mode === "modal" && (
<div className="account-footer">
<button className="btn-logout" onClick={handleLogout}>
<button className="btn-logout" onClick={() => auth.logout()}>
Log Out
</button>
</div>
</div>
)}
</div>
);
if (mode === "page") {
return panelContent;
}
return (
<div className="account-backdrop" onClick={onClose}>
{panelContent}
</div>
);
}

View File

@@ -0,0 +1,273 @@
/* ── Backdrop ── */
.admin-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.55);
z-index: 200;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
}
/* ── Panel ── */
.admin-panel {
background: #1e1e2e;
border: 1px solid #3b3b52;
border-radius: 0.75rem;
width: min(95vw, 900px);
max-height: 85vh;
display: flex;
flex-direction: column;
overflow: hidden;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
}
/* ── Header ── */
.admin-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.25rem;
border-bottom: 1px solid #3b3b52;
flex-shrink: 0;
}
.admin-header h2 {
margin: 0;
font-size: 1.1rem;
font-weight: 600;
color: #e2e8f0;
}
.admin-close {
background: none;
border: none;
color: #94a3b8;
font-size: 1.1rem;
cursor: pointer;
padding: 0.25rem 0.4rem;
border-radius: 4px;
line-height: 1;
transition:
color 0.15s,
background 0.15s;
}
.admin-close:hover {
color: #e2e8f0;
background: #2d2d44;
}
/* ── Body ── */
.admin-body {
overflow-y: auto;
padding: 0.75rem 1rem 1rem;
flex: 1 1 auto;
}
.admin-loading,
.admin-error {
text-align: center;
padding: 2rem;
color: #94a3b8;
font-size: 0.9rem;
}
.admin-error {
color: #f87171;
}
/* ── Table ── */
.admin-table {
width: 100%;
border-collapse: collapse;
font-size: 0.875rem;
color: #cbd5e1;
}
.admin-table th {
text-align: left;
padding: 0.5rem 0.75rem;
border-bottom: 1px solid #3b3b52;
color: #94a3b8;
font-weight: 500;
white-space: nowrap;
}
.admin-table td {
padding: 0.55rem 0.75rem;
border-bottom: 1px solid #2d2d44;
vertical-align: middle;
}
/* Row variants */
.admin-row-self td {
background: rgba(99, 102, 241, 0.06);
}
.admin-row-banned td {
opacity: 0.6;
}
.admin-self-badge {
font-size: 0.75rem;
color: #818cf8;
margin-left: 0.25rem;
}
.admin-none {
color: #475569;
}
.admin-email {
color: #94a3b8;
font-size: 0.8rem;
}
.admin-date {
color: #64748b;
font-size: 0.8rem;
white-space: nowrap;
}
/* ── Role badge ── */
.admin-role-badge {
display: inline-block;
padding: 0.15rem 0.55rem;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.admin-role-badge.role-admin {
background: rgba(139, 92, 246, 0.2);
color: #a78bfa;
border: 1px solid rgba(139, 92, 246, 0.35);
}
.admin-role-badge.role-user {
background: rgba(71, 85, 105, 0.3);
color: #94a3b8;
border: 1px solid rgba(71, 85, 105, 0.4);
}
/* ── Status badge ── */
.admin-status-badge {
display: inline-block;
padding: 0.15rem 0.55rem;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.admin-status-badge.status-active {
background: rgba(34, 197, 94, 0.15);
color: #4ade80;
border: 1px solid rgba(34, 197, 94, 0.3);
}
.admin-status-badge.status-banned {
background: rgba(239, 68, 68, 0.15);
color: #f87171;
border: 1px solid rgba(239, 68, 68, 0.3);
}
/* ── Actions ── */
.admin-actions {
display: flex;
gap: 0.4rem;
align-items: center;
white-space: nowrap;
}
.admin-btn {
padding: 0.3rem 0.65rem;
border-radius: 5px;
border: 1px solid transparent;
font-size: 0.78rem;
font-weight: 500;
cursor: pointer;
transition:
background 0.15s,
opacity 0.15s;
line-height: 1.2;
}
.admin-btn:disabled {
opacity: 0.35;
cursor: not-allowed;
}
/* Role button */
.admin-btn-role {
background: rgba(99, 102, 241, 0.15);
color: #818cf8;
border-color: rgba(99, 102, 241, 0.3);
}
.admin-btn-role:not(:disabled):hover {
background: rgba(99, 102, 241, 0.28);
}
.admin-btn-role.is-admin {
background: rgba(99, 102, 241, 0.25);
color: #a5b4fc;
}
/* Ban button */
.admin-btn-ban {
background: rgba(234, 179, 8, 0.12);
color: #fbbf24;
border-color: rgba(234, 179, 8, 0.25);
}
.admin-btn-ban:not(:disabled):hover {
background: rgba(234, 179, 8, 0.22);
}
.admin-btn-ban.is-banned {
background: rgba(34, 197, 94, 0.12);
color: #4ade80;
border-color: rgba(34, 197, 94, 0.25);
}
.admin-btn-ban.is-banned:not(:disabled):hover {
background: rgba(34, 197, 94, 0.22);
}
/* Delete button */
.admin-btn-delete {
background: rgba(239, 68, 68, 0.12);
color: #f87171;
border-color: rgba(239, 68, 68, 0.25);
display: flex;
align-items: center;
justify-content: center;
padding: 0.3rem 0.55rem;
}
.admin-btn-delete:not(:disabled):hover {
background: rgba(239, 68, 68, 0.25);
}
/* ── Page mode ── */
.admin-panel-page {
width: 100%;
max-height: none;
box-shadow: none;
border-radius: 0.75rem;
}
.admin-back-btn {
font-size: 0.82rem;
color: #818cf8;
}
.admin-back-btn:hover {
color: #a5b4fc;
}

View File

@@ -0,0 +1,243 @@
import { useState, useEffect, useCallback } from "react";
import { adminApi } from "../api";
import type { AdminUser } from "../types";
import type { UserInfo } from "../types";
import "./AdminPanel.css";
import { FaTrash } from "react-icons/fa6";
interface Props {
currentUser: UserInfo;
onClose: () => void;
/** "modal" (default) renders with a full-screen backdrop overlay.
* "page" renders inline for use as a standalone page. */
mode?: "modal" | "page";
}
export default function AdminPanel({
currentUser,
onClose,
mode = "modal",
}: Props) {
const [users, setUsers] = useState<AdminUser[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState<string | null>(null); // user id being acted on
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
const list = await adminApi.listUsers();
setUsers(list);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
setError(msg.replace(/^\d+:\s*/, "").trim() || "Failed to load users");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
load();
}, [load]);
async function handleBanToggle(user: AdminUser) {
setBusy(user.id);
try {
if (user.status === "banned") {
await adminApi.unbanUser(user.id);
} else {
await adminApi.banUser(user.id);
}
setUsers((prev) =>
prev.map((u) =>
u.id === user.id
? { ...u, status: u.status === "banned" ? "active" : "banned" }
: u,
),
);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
alert(msg.replace(/^\d+:\s*/, "").trim() || "Action failed");
} finally {
setBusy(null);
}
}
async function handleRoleToggle(user: AdminUser) {
const newRole = user.role === "admin" ? "user" : "admin";
setBusy(user.id);
try {
await adminApi.setUserRole(user.id, newRole);
setUsers((prev) =>
prev.map((u) => (u.id === user.id ? { ...u, role: newRole } : u)),
);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
alert(msg.replace(/^\d+:\s*/, "").trim() || "Action failed");
} finally {
setBusy(null);
}
}
async function handleDelete(user: AdminUser) {
if (
!window.confirm(
`Permanently delete user "${user.username}"? This cannot be undone.`,
)
)
return;
setBusy(user.id);
try {
await adminApi.deleteUser(user.id);
setUsers((prev) => prev.filter((u) => u.id !== user.id));
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
alert(msg.replace(/^\d+:\s*/, "").trim() || "Failed to delete user");
} finally {
setBusy(null);
}
}
function formatDate(iso: string) {
return new Date(iso).toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
});
}
const panelContent = (
<div
className={`admin-panel${mode === "page" ? " admin-panel-page" : ""}`}
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="admin-header">
<h2>Site Admin Users</h2>
{mode != "page" && (
<button className="admin-close" onClick={onClose} aria-label="Close">
</button>
)}
</div>
{/* Body */}
<div className="admin-body">
{loading ? (
<p className="admin-loading">Loading</p>
) : error ? (
<p className="admin-error">{error}</p>
) : (
<table className="admin-table">
<thead>
<tr>
<th>Username</th>
<th>Email</th>
<th>Role</th>
<th>Status</th>
<th>Joined</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{users.map((user) => {
const isSelf = user.id === currentUser.id;
const isBusy = busy === user.id;
return (
<tr
key={user.id}
className={`admin-row${isSelf ? " admin-row-self" : ""}${user.status === "banned" ? " admin-row-banned" : ""}`}
>
<td className="admin-username">
{user.username}
{isSelf && (
<span className="admin-self-badge"> (you)</span>
)}
</td>
<td className="admin-email">
{user.email ?? <span className="admin-none"></span>}
</td>
<td>
<span className={`admin-role-badge role-${user.role}`}>
{user.role}
</span>
</td>
<td>
<span
className={`admin-status-badge status-${user.status}`}
>
{user.status}
</span>
</td>
<td className="admin-date">
{formatDate(user.created_at)}
</td>
<td className="admin-actions">
{/* Role toggle */}
<button
className={`admin-btn admin-btn-role${user.role === "admin" ? " is-admin" : ""}`}
onClick={() => handleRoleToggle(user)}
disabled={isSelf || isBusy}
title={
isSelf
? "Cannot change your own role"
: user.role === "admin"
? "Demote to user"
: "Promote to admin"
}
>
{user.role === "admin" ? "Demote" : "Make Admin"}
</button>
{/* Ban / Unban toggle */}
<button
className={`admin-btn admin-btn-ban${user.status === "banned" ? " is-banned" : ""}`}
onClick={() => handleBanToggle(user)}
disabled={isSelf || isBusy}
title={
isSelf
? "Cannot ban yourself"
: user.status === "banned"
? "Unban user"
: "Ban user"
}
>
{user.status === "banned" ? "Unban" : "Ban"}
</button>
{/* Delete */}
<button
className="admin-btn admin-btn-delete"
onClick={() => handleDelete(user)}
disabled={isSelf || isBusy}
title={
isSelf
? "Cannot delete yourself"
: "Delete user permanently"
}
>
<FaTrash />
</button>
</td>
</tr>
);
})}
</tbody>
</table>
)}
</div>
</div>
);
if (mode === "page") {
return panelContent;
}
return (
<div className="admin-backdrop" onClick={onClose}>
{panelContent}
</div>
);
}

View File

@@ -0,0 +1,297 @@
/* ── Panel card ── */
.discord-panel {
background: #1e2130;
border: 1px solid #2e3348;
border-radius: 10px;
padding: 1.5rem;
width: 380px;
max-width: 100%;
max-height: none;
display: flex;
flex-direction: column;
gap: 0;
align-self: flex-start;
}
/* ── Header ── */
.discord-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.25rem;
}
.discord-header h2 {
margin: 0;
font-size: 1.1rem;
color: #e2e8f0;
}
.discord-header-icon {
color: #5865f2;
font-size: 1.25rem;
flex-shrink: 0;
}
/* ── Section ── */
.discord-section {
border-top: 1px solid #2e3348;
padding-top: 1rem;
margin-top: 1.25rem;
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.discord-section h3 {
margin: 0;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #8892a4;
display: flex;
align-items: center;
gap: 0.4rem;
}
/* ── Shared text variants ── */
.discord-muted {
margin: 0;
font-size: 0.85rem;
color: #8892a4;
}
.discord-error {
margin: 0;
font-size: 0.8rem;
color: #f87171;
background: rgba(239, 68, 68, 0.08);
border: 1px solid rgba(239, 68, 68, 0.2);
border-radius: 5px;
padding: 0.35rem 0.6rem;
}
/* ── Guild info ── */
.discord-guild-name {
margin: 0;
font-size: 0.95rem;
font-weight: 600;
color: #e2e8f0;
}
.discord-select {
background: #141622;
border: 1px solid #2e3348;
border-radius: 6px;
color: #e2e8f0;
font-size: 0.85rem;
padding: 0.35rem 0.6rem;
outline: none;
cursor: pointer;
transition: border-color 0.12s;
width: 100%;
}
.discord-select:focus {
border-color: #5865f2;
}
/* ── Voice channel ── */
.discord-channel-name {
margin: 0;
font-size: 0.9rem;
color: #e2e8f0;
display: flex;
align-items: center;
gap: 0.15rem;
}
.discord-channel-hash {
color: #5865f2;
font-weight: 700;
font-size: 1rem;
}
/* ── Now playing ── */
.discord-now-playing {
background: #141622;
border: 1px solid #2e3348;
border-radius: 8px;
padding: 0.65rem 0.9rem;
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.discord-track-title {
margin: 0;
font-size: 0.88rem;
font-weight: 600;
color: #e2e8f0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.discord-track-status {
margin: 0;
font-size: 0.75rem;
color: #8892a4;
}
/* ── Playback controls ── */
.discord-controls {
display: flex;
gap: 0.5rem;
align-items: center;
}
.discord-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 8px;
border: none;
cursor: pointer;
font-size: 0.9rem;
transition:
background 0.12s,
opacity 0.12s;
}
.discord-btn:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.discord-btn-play,
.discord-btn-pause {
background: #5865f2;
color: #fff;
}
.discord-btn-play:hover:not(:disabled),
.discord-btn-pause:hover:not(:disabled) {
background: #4752c4;
}
.discord-btn-skip {
background: #2e3348;
color: #c4cde4;
}
.discord-btn-skip:hover:not(:disabled) {
background: #3c4460;
color: #e2e8f0;
}
.discord-btn-stop {
background: transparent;
border: 1px solid #4a5568;
color: #8892a4;
}
.discord-btn-stop:hover:not(:disabled) {
border-color: #ef4444;
color: #f87171;
}
/* ── Queue ── */
.discord-queue-count {
font-size: 0.7rem;
color: #6b7280;
letter-spacing: 0;
text-transform: none;
font-weight: 400;
}
.discord-queue-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.35rem;
max-height: 200px;
overflow-y: auto;
}
.discord-queue-item {
display: flex;
align-items: center;
gap: 0.6rem;
background: #141622;
border: 1px solid #2e3348;
border-radius: 6px;
padding: 0.4rem 0.7rem;
min-width: 0;
}
.discord-queue-index {
font-size: 0.72rem;
color: #8892a4;
min-width: 16px;
text-align: right;
flex-shrink: 0;
}
.discord-queue-title {
font-size: 0.82rem;
color: #c4cde4;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
/* ── Add to queue form ── */
.discord-play-form {
display: flex;
gap: 0.5rem;
}
.discord-url-input {
flex: 1;
background: #141622;
border: 1px solid #2e3348;
border-radius: 6px;
color: #e2e8f0;
font-size: 0.82rem;
padding: 0.35rem 0.6rem;
outline: none;
transition: border-color 0.12s;
min-width: 0;
}
.discord-url-input::placeholder {
color: #4a5568;
}
.discord-url-input:focus {
border-color: #5865f2;
}
.discord-btn-add {
background: #5865f2;
border: none;
border-radius: 6px;
color: #fff;
cursor: pointer;
font-size: 0.82rem;
font-weight: 600;
padding: 0.35rem 0.9rem;
white-space: nowrap;
transition: background 0.12s;
flex-shrink: 0;
}
.discord-btn-add:hover:not(:disabled) {
background: #4752c4;
}
.discord-btn-add:disabled {
opacity: 0.45;
cursor: not-allowed;
}

View File

@@ -0,0 +1,295 @@
import { useCallback, useEffect, useRef, useState } from "react";
import {
FaDiscord,
FaPause,
FaPlay,
FaStop,
FaForwardStep,
} from "react-icons/fa6";
import { audioApi } from "../api";
import type { AudioStatus, DiscordGuild, UserInfo } from "../types";
import "./DiscordPanel.css";
interface Props {
user: UserInfo;
}
const POLL_INTERVAL_MS = 5000;
export default function DiscordPanel({ user }: Props) {
const discordConnection = user.connections.find(
(c) => c.provider === "discord",
);
// ── Guild selection ──
const [guilds, setGuilds] = useState<DiscordGuild[]>([]);
const [selectedGuildId, setSelectedGuildId] = useState<string | null>(null);
const [guildsLoading, setGuildsLoading] = useState(true);
const [guildsError, setGuildsError] = useState<string | null>(null);
// ── Audio status ──
const [status, setStatus] = useState<AudioStatus | null>(null);
const [statusError, setStatusError] = useState<string | null>(null);
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
// ── Play input ──
const [playUrl, setPlayUrl] = useState("");
const [playLoading, setPlayLoading] = useState(false);
const [playError, setPlayError] = useState<string | null>(null);
// ── Action feedback ──
const [actionError, setActionError] = useState<string | null>(null);
// ── Load guilds on mount ──
useEffect(() => {
if (!discordConnection) return;
setGuildsLoading(true);
audioApi
.listGuilds()
.then((list) => {
setGuilds(list);
if (list.length === 1) setSelectedGuildId(list[0].id);
})
.catch((err) => {
const msg = err instanceof Error ? err.message : String(err);
setGuildsError(msg.replace(/^\d+:\s*/, "").trim());
})
.finally(() => setGuildsLoading(false));
}, [discordConnection]);
// ── Poll status whenever a guild is selected ──
const fetchStatus = useCallback(async (guildId: string) => {
try {
const s = await audioApi.getStatus(guildId);
setStatus(s);
setStatusError(null);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
setStatusError(msg.replace(/^\d+:\s*/, "").trim());
}
}, []);
useEffect(() => {
if (pollRef.current) clearInterval(pollRef.current);
if (!selectedGuildId) {
setStatus(null);
return;
}
fetchStatus(selectedGuildId);
pollRef.current = setInterval(
() => fetchStatus(selectedGuildId),
POLL_INTERVAL_MS,
);
return () => {
if (pollRef.current) clearInterval(pollRef.current);
};
}, [selectedGuildId, fetchStatus]);
// ── Helpers ──
function errMsg(err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
return msg.replace(/^\d+:\s*/, "").trim();
}
async function runAction(fn: () => Promise<void>) {
setActionError(null);
try {
await fn();
// Immediate status refresh after any action
if (selectedGuildId) await fetchStatus(selectedGuildId);
} catch (err) {
setActionError(errMsg(err));
}
}
async function handlePlay(e: React.FormEvent) {
e.preventDefault();
if (!selectedGuildId || !playUrl.trim()) return;
setPlayLoading(true);
setPlayError(null);
try {
await audioApi.play(selectedGuildId, playUrl.trim());
setPlayUrl("");
if (selectedGuildId) await fetchStatus(selectedGuildId);
} catch (err) {
setPlayError(errMsg(err));
} finally {
setPlayLoading(false);
}
}
// ── Don't render if no Discord connection ──
if (!discordConnection) return null;
const selectedGuild = guilds.find((g) => g.id === selectedGuildId);
const isPlaying = !!status?.current_track && !status?.is_paused;
return (
<div className="discord-panel">
{/* Header */}
<div className="discord-header">
<FaDiscord className="discord-header-icon" />
<h2>Discord</h2>
</div>
{/* Guild selector */}
<section className="discord-section">
<h3>Server</h3>
{guildsLoading ? (
<p className="discord-muted">Loading servers</p>
) : guildsError ? (
<p className="discord-error">{guildsError}</p>
) : guilds.length === 0 ? (
<p className="discord-muted">
The bot isn't in any servers yet.
</p>
) : guilds.length === 1 ? (
<p className="discord-guild-name">{guilds[0].name}</p>
) : (
<select
className="discord-select"
value={selectedGuildId ?? ""}
onChange={(e) => setSelectedGuildId(e.target.value || null)}
>
<option value=""> Select a server </option>
{guilds.map((g) => (
<option key={g.id} value={g.id}>
{g.name}
</option>
))}
</select>
)}
</section>
{/* Voice channel */}
{selectedGuild && (
<section className="discord-section">
<h3>Voice Channel</h3>
{statusError ? (
<p className="discord-error">{statusError}</p>
) : status?.voice_channel ? (
<p className="discord-channel-name">
<span className="discord-channel-hash">#</span>
{status.voice_channel}
</p>
) : (
<p className="discord-muted">Not connected to a voice channel</p>
)}
</section>
)}
{/* Now Playing */}
{selectedGuild && (
<section className="discord-section">
<h3>Now Playing</h3>
{status?.current_track ? (
<div className="discord-now-playing">
<p
className="discord-track-title"
title={status.current_track.title}
>
{status.current_track.title}
</p>
<p className="discord-track-status">
{status.is_paused ? "⏸ Paused" : "▶ Playing"}
</p>
</div>
) : (
<p className="discord-muted">Nothing is playing</p>
)}
{/* Controls */}
<div className="discord-controls">
{status?.is_paused ? (
<button
className="discord-btn discord-btn-play"
title="Resume"
onClick={() =>
runAction(() => audioApi.resume(selectedGuildId!))
}
disabled={!status?.current_track}
>
<FaPlay />
</button>
) : (
<button
className="discord-btn discord-btn-pause"
title="Pause"
onClick={() =>
runAction(() => audioApi.pause(selectedGuildId!))
}
disabled={!status?.current_track}
>
<FaPause />
</button>
)}
<button
className="discord-btn discord-btn-skip"
title="Skip"
onClick={() => runAction(() => audioApi.skip(selectedGuildId!))}
disabled={!status?.current_track}
>
<FaForwardStep />
</button>
<button
className="discord-btn discord-btn-stop"
title="Stop"
onClick={() => runAction(() => audioApi.stop(selectedGuildId!))}
disabled={!status?.current_track}
>
<FaStop />
</button>
</div>
{actionError && <p className="discord-error">{actionError}</p>}
</section>
)}
{/* Queue */}
{selectedGuild && (
<section className="discord-section">
<h3>Queue {status && status.queue.length > 0 && <span className="discord-queue-count">({status.queue.length})</span>}</h3>
{!status || status.queue.length === 0 ? (
<p className="discord-muted">Queue is empty</p>
) : (
<ul className="discord-queue-list">
{status.queue.map((track, i) => (
<li key={i} className="discord-queue-item" title={track.title}>
<span className="discord-queue-index">{i + 1}</span>
<span className="discord-queue-title">{track.title}</span>
</li>
))}
</ul>
)}
</section>
)}
{/* Add to queue */}
{selectedGuild && (
<section className="discord-section">
<h3>Add to Queue</h3>
<form onSubmit={handlePlay} className="discord-play-form">
<input
type="url"
className="discord-url-input"
placeholder="YouTube / SoundCloud URL…"
value={playUrl}
onChange={(e) => {
setPlayUrl(e.target.value);
setPlayError(null);
}}
/>
<button
type="submit"
className="discord-btn-add"
disabled={playLoading || !playUrl.trim()}
>
{playLoading ? "Adding…" : "Add"}
</button>
</form>
{playError && <p className="discord-error">{playError}</p>}
</section>
)}
</div>
);
}

View File

@@ -130,10 +130,14 @@ export default function EditMapModal({ map, onClose, onUpdated }: Props) {
}
}
function handleKeyDown(e: React.KeyboardEvent) {
if (e.key === "Escape") onClose();
}
const nonOwnerPerms = permissions.filter((p) => p.role !== "owner");
return (
<div className="modal-backdrop" onClick={onClose}>
<div className="modal-backdrop" onClick={onClose} onKeyDown={handleKeyDown}>
<div
className="modal edit-map-modal"
onClick={(e) => e.stopPropagation()}

View File

@@ -46,3 +46,52 @@
gap: 0.5rem;
justify-content: flex-end;
}
/* ── Account dropdown ── */
.account-dropdown-wrapper {
position: relative;
}
.account-dropdown {
position: absolute;
top: calc(100% + 6px);
right: 0;
background: #1f2937;
border: 1px solid #374151;
border-radius: 8px;
min-width: 180px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.45);
z-index: 200;
overflow: hidden;
}
.account-dropdown-item {
display: block;
width: 100%;
padding: 0.6rem 1rem;
background: none;
border: none;
color: #e5e7eb;
font-size: 0.85rem;
text-align: left;
cursor: pointer;
transition: background 0.12s;
}
.account-dropdown-item:hover {
background: #374151;
}
.account-dropdown-divider {
height: 1px;
background: #374151;
margin: 0.2rem 0;
}
.account-dropdown-logout {
color: #f87171;
}
.account-dropdown-logout:hover {
background: rgba(239, 68, 68, 0.1) !important;
}

View File

@@ -1,51 +1,127 @@
import type { UserInfo } from "../types";
import { useState, useRef, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { useAuth } from "../context/AuthContext";
import { auth } from "../api";
import LoginButton from "./LoginButton";
import LoginModal from "./LoginModal";
import "./Header.css";
interface Props {
user: UserInfo | null;
authLoading: boolean;
selectedMapName: string | null;
onLoginClick: () => void;
onAccountClick: () => void;
mapTitle: string | null;
}
export default function Header({
user,
authLoading,
selectedMapName,
onLoginClick,
onAccountClick,
}: Props) {
export default function Header({ mapTitle }: Props) {
const { user, authLoading, setUser } = useAuth();
const navigate = useNavigate();
const [dropdownOpen, setDropdownOpen] = useState(false);
const [showLoginModal, setShowLoginModal] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
/** Display name: first name if set, otherwise username */
const displayName = user ? user.first_name?.trim() || user.username : null;
// Close dropdown on outside click
useEffect(() => {
if (!dropdownOpen) return;
function handleOutsideClick(e: MouseEvent) {
if (
dropdownRef.current &&
!dropdownRef.current.contains(e.target as Node)
) {
setDropdownOpen(false);
}
}
document.addEventListener("mousedown", handleOutsideClick);
return () => document.removeEventListener("mousedown", handleOutsideClick);
}, [dropdownOpen]);
async function handleLogout() {
setDropdownOpen(false);
await auth.logout();
setUser(null);
navigate("/map");
}
return (
<header className="app-header">
<div className="app-brand">
<div
className="app-brand"
onClick={() => navigate("/map")}
style={{ cursor: "pointer" }}
>
<span>SIREN</span>
</div>
<div className="app-header-center">
{selectedMapName && (
<span className="header-map-name">{selectedMapName}</span>
)}
{mapTitle && <span className="header-map-name">{mapTitle}</span>}
</div>
<div className="app-auth">
{!authLoading &&
(user ? (
<button
className="header-btn"
onClick={onAccountClick}
title="Account settings"
>
{displayName}
</button>
<div className="account-dropdown-wrapper" ref={dropdownRef}>
<button
className="header-btn"
onClick={() => setDropdownOpen((o) => !o)}
aria-haspopup="true"
aria-expanded={dropdownOpen}
title="Account"
>
{displayName}
</button>
{dropdownOpen && (
<div className="account-dropdown">
<button
className="account-dropdown-item"
onClick={() => {
navigate("/account");
setDropdownOpen(false);
}}
>
Account
</button>
{user.role === "admin" && (
<button
className="account-dropdown-item"
onClick={() => {
navigate("/admin");
setDropdownOpen(false);
}}
>
Admin Dashboard
</button>
)}
<div className="account-dropdown-divider" />
<button
className="account-dropdown-item account-dropdown-logout"
onClick={handleLogout}
>
Log Out
</button>
</div>
)}
</div>
) : (
<LoginButton className="header-btn" onClick={onLoginClick} />
<LoginButton
className="header-btn"
onClick={() => setShowLoginModal(true)}
/>
))}
</div>
{showLoginModal && (
<LoginModal
onClose={() => setShowLoginModal(false)}
onLogin={(u) => {
setUser(u);
setShowLoginModal(false);
}}
/>
)}
</header>
);
}

View File

@@ -91,8 +91,12 @@ export default function MapListModal({
onClose();
}
function handleKeyDown(e: React.KeyboardEvent) {
if (e.key === "Escape") onClose();
}
return (
<div className="modal-backdrop" onClick={onClose}>
<div className="modal-backdrop" onClick={onClose} onKeyDown={handleKeyDown}>
<div
className="modal map-list-modal"
onClick={(e) => e.stopPropagation()}

View File

@@ -106,3 +106,14 @@
.header-btn:hover {
background: #4b5563;
}
/* Admin variant — subtle purple accent */
.header-btn-admin {
border-color: rgba(139, 92, 246, 0.45);
color: #c4b5fd;
}
.header-btn-admin:hover {
background: #4b5563;
border-color: #7c3aed;
color: #ddd6fe;
}

View File

@@ -35,8 +35,12 @@ export default function NewMapModal({ onClose, onCreate }: Props) {
}
}
function handleKeyDown(e: React.KeyboardEvent) {
if (e.key === "Escape") onClose();
}
return (
<div className="modal-backdrop" onClick={onClose}>
<div className="modal-backdrop" onClick={onClose} onKeyDown={handleKeyDown}>
<div className="modal new-map-modal" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<h2>New Map</h2>
@@ -51,7 +55,7 @@ export default function NewMapModal({ onClose, onCreate }: Props) {
<input
ref={nameRef}
type="text"
placeholder="My awesome map…"
placeholder="e.g. Ravenloft"
value={name}
onChange={(e) => setName(e.target.value)}
maxLength={60}

View File

@@ -0,0 +1,47 @@
import {
createContext,
useContext,
useState,
useEffect,
type ReactNode,
} from "react";
import { auth } from "../api";
import type { UserInfo } from "../types";
interface AuthContextValue {
user: UserInfo | null;
authLoading: boolean;
setUser: (user: UserInfo | null) => void;
refreshUser: () => Promise<void>;
}
const AuthContext = createContext<AuthContextValue | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<UserInfo | null>(null);
const [authLoading, setAuthLoading] = useState(true);
async function refreshUser() {
const u = await auth.me();
setUser(u);
}
useEffect(() => {
auth.me().then((u) => {
setUser(u);
setAuthLoading(false);
});
}, []);
return (
<AuthContext.Provider value={{ user, authLoading, setUser, refreshUser }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error("useAuth must be used within AuthProvider");
return ctx;
}

View File

@@ -1,10 +1,16 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import App from "./App.tsx";
import { AuthProvider } from "./context/AuthContext.tsx";
import "./index.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
<BrowserRouter>
<AuthProvider>
<App />
</AuthProvider>
</BrowserRouter>
</React.StrictMode>,
);

View File

@@ -0,0 +1,26 @@
import { useNavigate } from "react-router-dom";
import { useAuth } from "../context/AuthContext";
import AccountPanel from "../components/AccountPanel";
import DiscordPanel from "../components/DiscordPanel";
import "./Pages.css";
export default function AccountPage() {
const { user, refreshUser } = useAuth();
const navigate = useNavigate();
if (!user) return null;
const hasDiscord = user.connections.some((c) => c.provider === "discord");
return (
<div className={`page-container ${hasDiscord ? "account-page-layout" : ""}`}>
<AccountPanel
user={user}
onClose={() => navigate("/map")}
onRefresh={refreshUser}
mode="page"
/>
{hasDiscord && <DiscordPanel user={user} />}
</div>
);
}

View File

@@ -0,0 +1,21 @@
import { useNavigate } from "react-router-dom";
import { useAuth } from "../context/AuthContext";
import AdminPanel from "../components/AdminPanel";
import "./Pages.css";
export default function AdminPage() {
const { user } = useAuth();
const navigate = useNavigate();
if (!user || user.role !== "admin") return null;
return (
<div className="page-container page-container-wide">
<AdminPanel
currentUser={user}
onClose={() => navigate("/map")}
mode="page"
/>
</div>
);
}

351
ui/src/pages/MapPage.tsx Normal file
View File

@@ -0,0 +1,351 @@
import { useState, useEffect, useRef } from "react";
import { useParams, useNavigate } from "react-router-dom";
import type { GridMap, ListedMap, PublicAccess, Tool } from "../types";
import type { GridHandle } from "../components/Grid";
import { api } from "../api";
import { useAuth } from "../context/AuthContext";
import ControlPanel from "../components/ControlPanel";
import ColorPanel from "../components/ColorPanel";
import Grid from "../components/Grid";
import LoginModal from "../components/LoginModal";
import FloatingMapControls from "../components/FloatingMapControls";
import NewMapModal from "../components/NewMapModal";
import EditMapModal from "../components/EditMapModal";
import MapListModal from "../components/MapListModal";
import "../components/Modal.css";
const DEFAULT_COLORS = [
"#6b7280",
"#92400e",
"#15803d",
"#1d4ed8",
"#7c3aed",
"#dc2626",
"#ca8a04",
"#0f172a",
"#f9fafb",
];
function getQueryParam(key: string): string | null {
return new URLSearchParams(window.location.search).get(key);
}
function removeQueryParam(key: string) {
const url = new URL(window.location.href);
url.searchParams.delete(key);
window.history.replaceState(
null,
"",
url.pathname + (url.search !== "?" ? url.search : ""),
);
}
interface Props {
setMapTitle: (title: string | null) => void;
}
export default function MapPage({ setMapTitle }: Props) {
const { mapId: urlMapId } = useParams();
const navigate = useNavigate();
const { user, authLoading, setUser } = useAuth();
// ── Map state ──
const [maps, setMaps] = useState<ListedMap[]>([]);
const [selectedId, setSelectedId] = useState<string | null>(
urlMapId ? decodeURIComponent(urlMapId) : null,
);
/** Info for maps accessed via URL that aren't in the user's list (e.g. public maps). */
const [directMapInfo, setDirectMapInfo] = useState<GridMap | null>(null);
/** True when the current selectedId returned 403 (no access). */
const [accessDenied, setAccessDenied] = useState(false);
const [accessRequestSent, setAccessRequestSent] = useState(false);
// ── Tool + color ──
const [tool, setTool] = useState<Tool>("pan");
const [activeColor, setActiveColor] = useState(DEFAULT_COLORS[0]);
const [mapColors, setMapColors] = useState<string[]>(DEFAULT_COLORS);
const gridRef = useRef<GridHandle>(null);
// ── Modal visibility ──
const [showLoginModal, setShowLoginModal] = useState(false);
const [showNewMap, setShowNewMap] = useState(false);
const [showEditMap, setShowEditMap] = useState(false);
const [showMapList, setShowMapList] = useState(false);
// ── Derived ──
const selectedMapFromList = maps.find((m) => m.id === selectedId) ?? null;
const selectedMapInfo: GridMap | ListedMap | null =
selectedMapFromList ?? directMapInfo;
const isOwner =
user !== null &&
selectedMapInfo !== null &&
selectedMapInfo.owner_id === user.id;
// ── On mount: handle OAuth errors ──
useEffect(() => {
const error = getQueryParam("error");
if (error) {
console.error("OAuth error:", error);
removeQueryParam("error");
}
}, []);
// ── Load map list after auth resolves ──
useEffect(() => {
if (!authLoading) {
api.listMaps().then(setMaps).catch(console.error);
}
}, [user, authLoading]);
// ── Direct fetch for URL-accessed maps not in the user's list ──
useEffect(() => {
if (!selectedId || authLoading) {
setDirectMapInfo(null);
setAccessDenied(false);
return;
}
const inList = maps.some((m) => m.id === selectedId);
if (inList) {
setDirectMapInfo(null);
setAccessDenied(false);
return;
}
setDirectMapInfo(null);
setAccessDenied(false);
setAccessRequestSent(false);
api
.getMap(selectedId)
.then((state) => {
setDirectMapInfo(state.map);
})
.catch((err) => {
const msg = err instanceof Error ? err.message : String(err);
if (msg.startsWith("403")) {
setAccessDenied(true);
} else {
// 404 or unknown — clear invalid URL
setSelectedId(null);
navigate("/map", { replace: true });
}
});
}, [selectedId, maps, authLoading]); // eslint-disable-line react-hooks/exhaustive-deps
// ── Keep URL in sync ──
useEffect(() => {
const path = selectedId ? `/map/${encodeURIComponent(selectedId)}` : "/map";
navigate(path, { replace: true });
}, [selectedId]); // eslint-disable-line react-hooks/exhaustive-deps
// ── Sync map title to header ──
useEffect(() => {
setMapTitle(selectedMapInfo?.name ?? null);
}, [selectedMapInfo?.name]); // eslint-disable-line react-hooks/exhaustive-deps
// ── Clear map title on unmount ──
useEffect(() => {
return () => setMapTitle(null);
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// ── Reset palette + access state when map deselected ──
useEffect(() => {
if (!selectedId) {
setMapColors(DEFAULT_COLORS);
setActiveColor(DEFAULT_COLORS[0]);
setAccessRequestSent(false);
}
}, [selectedId]);
// ── Handlers ──
async function handleCreate(name: string, publicAccess: PublicAccess) {
const m = await api.createMap(name, publicAccess);
const listed: ListedMap = {
...m,
owner_username: user!.username,
user_role: "owner",
is_favorited: false,
};
setMaps((prev) => [listed, ...prev]);
setSelectedId(m.id);
}
async function handleDelete() {
if (!selectedId) return;
if (!confirm("Delete this map? This cannot be undone.")) return;
try {
await api.deleteMap(selectedId);
setMaps((prev) => prev.filter((m) => m.id !== selectedId));
setSelectedId(null);
} catch (err) {
console.error("Failed to delete map", err);
}
}
function handleMapUpdated(updated: GridMap) {
setMaps((prev) =>
prev.map((m) =>
m.id === updated.id
? {
...m,
name: updated.name,
public_access: updated.public_access,
updated_at: updated.updated_at,
}
: m,
),
);
if (directMapInfo?.id === updated.id) {
setDirectMapInfo(updated);
}
}
function handleColorsLoaded(colors: string[]) {
setMapColors(colors);
setActiveColor((prev) => (colors.includes(prev) ? prev : colors[0]));
}
function handleColorsChange(colors: string[]) {
setMapColors(colors);
gridRef.current?.sendColorUpdate(colors);
}
async function handleRequestAccess(role: "viewer" | "editor") {
if (!selectedId) return;
try {
await api.requestAccess(selectedId, role);
setAccessRequestSent(true);
} catch (err) {
console.error("Failed to request access", err);
}
}
// ── Render ──
return (
<div className="app-grid-area">
{/* Top-left floating map controls */}
<FloatingMapControls
isLoggedIn={!!user}
hasSelectedMap={!!selectedId}
isOwner={isOwner}
onNewMap={() => setShowNewMap(true)}
onViewMaps={() => setShowMapList(true)}
onEditMap={() => setShowEditMap(true)}
onDeleteMap={handleDelete}
/>
{selectedId && !accessDenied ? (
<>
<Grid
key={selectedId}
ref={gridRef}
mapId={selectedId}
tool={tool}
paintColor={activeColor}
tokenColor={activeColor}
onColorsLoaded={handleColorsLoaded}
/>
<div className="floating-panels-container">
<ControlPanel tool={tool} onToolChange={setTool} />
<ColorPanel
colors={mapColors}
activeColor={activeColor}
onColorChange={setActiveColor}
onColorsChange={handleColorsChange}
/>
</div>
</>
) : accessDenied ? (
<div className="access-denied-state">
<p className="access-denied-title">
You don't have access to this map
</p>
{!user ? (
<p className="access-denied-hint">
<button
className="link-btn"
onClick={() => setShowLoginModal(true)}
>
Log in
</button>{" "}
to request access or view your permissions.
</p>
) : accessRequestSent ? (
<p className="access-denied-hint access-request-sent">
✓ Access request sent! The map owner will be notified.
</p>
) : (
<div className="access-request-actions">
<p className="access-denied-hint">
Request access from the map owner:
</p>
<div className="access-request-btns">
<button
className="btn-request-access"
onClick={() => handleRequestAccess("viewer")}
>
Request Viewer Access
</button>
<button
className="btn-request-access"
onClick={() => handleRequestAccess("editor")}
>
Request Editor Access
</button>
</div>
</div>
)}
</div>
) : (
<div className="empty-state">
<p>Select or create a map to begin</p>
<p className="empty-hint">
{!user
? "Log in to create maps and access private maps"
: maps.length === 0
? 'Click "+ New Map" in the top-left to get started'
: 'Click "Maps" in the top-left to choose a map'}
</p>
</div>
)}
{/* ── Modals ── */}
{showLoginModal && (
<LoginModal
onClose={() => setShowLoginModal(false)}
onLogin={(u) => {
setUser(u);
setShowLoginModal(false);
api.listMaps().then(setMaps).catch(console.error);
}}
/>
)}
{showNewMap && (
<NewMapModal
onClose={() => setShowNewMap(false)}
onCreate={handleCreate}
/>
)}
{showEditMap && selectedMapInfo && (
<EditMapModal
map={selectedMapInfo}
onClose={() => setShowEditMap(false)}
onUpdated={handleMapUpdated}
/>
)}
{showMapList && (
<MapListModal
maps={maps}
selectedMapId={selectedId}
onSelect={(id) => setSelectedId(id)}
onClose={() => setShowMapList(false)}
onMapsChange={setMaps}
/>
)}
</div>
);
}

25
ui/src/pages/Pages.css Normal file
View File

@@ -0,0 +1,25 @@
/* ── Shared page layout ──
page-container is a direct flex child of .app-body, so it must
use flex: 1 + overflow-y: auto to fill the viewport and scroll. */
.page-container {
flex: 1;
overflow-y: auto;
display: flex;
justify-content: center;
align-items: flex-start;
padding: 2rem 1rem;
}
.page-container-wide {
align-items: stretch;
padding: 1.5rem 1rem;
}
/* ── Account page: side-by-side panels ── */
.account-page-layout {
flex-wrap: wrap;
flex-direction: row;
gap: 1.5rem;
justify-content: center;
align-items: flex-start;
}

View File

@@ -13,6 +13,20 @@ export interface UserInfo {
/** True when the account has a local password (can log in without OAuth). */
has_password: boolean;
connections: ConnectionInfo[];
/** Site-level role: "admin" | "user" */
role: "admin" | "user";
/** Account status: "active" | "banned" */
status: "active" | "banned";
}
/** User record returned by the admin user list endpoint. */
export interface AdminUser {
id: string; // UUID
username: string;
email: string | null;
role: "admin" | "user";
status: "active" | "banned";
created_at: string; // ISO datetime
}
export type MapRole = "owner" | "editor" | "viewer";
@@ -82,6 +96,25 @@ export interface MapAccessRequest {
updated_at: string;
}
// ── Discord / Audio ──────────────────────────────────────────────────────────
export interface DiscordGuild {
id: string;
name: string;
}
export interface TrackInfo {
title: string;
url: string;
}
export interface AudioStatus {
voice_channel: string | null;
is_paused: boolean;
current_track: TrackInfo | null;
queue: TrackInfo[];
}
export type Tool = "pan" | "zoom" | "draw" | "token";
export type ClientMessage =