Updates to pages
This commit is contained in:
@@ -12,7 +12,8 @@
|
||||
"dependencies": {
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-icons": "^5.6.0"
|
||||
"react-icons": "^5.6.0",
|
||||
"react-router-dom": "^7.14.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.4",
|
||||
|
||||
393
ui/src/App.tsx
393
ui/src/App.tsx
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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" }),
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
273
ui/src/components/AdminPanel.css
Normal file
273
ui/src/components/AdminPanel.css
Normal 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;
|
||||
}
|
||||
243
ui/src/components/AdminPanel.tsx
Normal file
243
ui/src/components/AdminPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
297
ui/src/components/DiscordPanel.css
Normal file
297
ui/src/components/DiscordPanel.css
Normal 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;
|
||||
}
|
||||
295
ui/src/components/DiscordPanel.tsx
Normal file
295
ui/src/components/DiscordPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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()}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
47
ui/src/context/AuthContext.tsx
Normal file
47
ui/src/context/AuthContext.tsx
Normal 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;
|
||||
}
|
||||
@@ -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>,
|
||||
);
|
||||
|
||||
26
ui/src/pages/AccountPage.tsx
Normal file
26
ui/src/pages/AccountPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
21
ui/src/pages/AdminPage.tsx
Normal file
21
ui/src/pages/AdminPage.tsx
Normal 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
351
ui/src/pages/MapPage.tsx
Normal 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
25
ui/src/pages/Pages.css
Normal 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;
|
||||
}
|
||||
@@ -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 =
|
||||
|
||||
Reference in New Issue
Block a user