Updating auth
This commit is contained in:
28
ui/eslint.config.js
Normal file
28
ui/eslint.config.js
Normal file
@@ -0,0 +1,28 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ['dist'] },
|
||||
{
|
||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
plugins: {
|
||||
'react-hooks': reactHooks,
|
||||
'react-refresh': reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
)
|
||||
@@ -8,6 +8,6 @@
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
<script type="module" src="src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -5,18 +5,28 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
"format": "prettier --write src"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-icons": "^5.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.1",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"typescript": "^5.5.3",
|
||||
"vite": "^5.3.4"
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@types/node": "^25.5.2",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"globals": "^17.4.0",
|
||||
"prettier": "^3.7.2",
|
||||
"typescript": "^6.0.2",
|
||||
"typescript-eslint": "^8.58.0",
|
||||
"vite": "^8.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
8
ui/prettierrc.json
Normal file
8
ui/prettierrc.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"trailingComma": "none",
|
||||
"tabWidth": 2,
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"jsxSingleQuote": true,
|
||||
"printWidth": 120
|
||||
}
|
||||
221
ui/src/App.css
221
ui/src/App.css
@@ -1,4 +1,4 @@
|
||||
/* ---- Full-viewport shell ---- */
|
||||
/* ── Full-viewport shell ── */
|
||||
.app {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -6,132 +6,21 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ---- Top header ---- */
|
||||
.app-header {
|
||||
flex-shrink: 0;
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 0 1rem;
|
||||
background: #1f2937;
|
||||
border-bottom: 1px solid #374151;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.app-brand {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 700;
|
||||
color: #f9fafb;
|
||||
letter-spacing: 0.08em;
|
||||
white-space: nowrap;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.app-brand span {
|
||||
color: #818cf8;
|
||||
}
|
||||
|
||||
.app-map-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
/* ── App body (everything below the header) ── */
|
||||
.app-body {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.map-select {
|
||||
background: #111827;
|
||||
border: 1px solid #4b5563;
|
||||
border-radius: 6px;
|
||||
color: #e5e7eb;
|
||||
padding: 0.3rem 0.6rem;
|
||||
font-size: 0.85rem;
|
||||
min-width: 160px;
|
||||
max-width: 280px;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.map-select:focus {
|
||||
border-color: #6366f1;
|
||||
}
|
||||
|
||||
.map-select option {
|
||||
background: #1f2937;
|
||||
}
|
||||
|
||||
.header-btn {
|
||||
background: #374151;
|
||||
border: 1px solid #4b5563;
|
||||
border-radius: 6px;
|
||||
color: #e5e7eb;
|
||||
cursor: pointer;
|
||||
font-size: 0.82rem;
|
||||
padding: 0.3rem 0.65rem;
|
||||
line-height: 1.4;
|
||||
white-space: nowrap;
|
||||
transition: background 0.12s;
|
||||
}
|
||||
|
||||
.header-btn:hover {
|
||||
background: #4b5563;
|
||||
}
|
||||
|
||||
.header-btn.danger:hover {
|
||||
background: #7f1d1d;
|
||||
border-color: #ef4444;
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
.new-map-form {
|
||||
display: flex;
|
||||
gap: 0.3rem;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.new-map-form input {
|
||||
background: #111827;
|
||||
border: 1px solid #6366f1;
|
||||
border-radius: 6px;
|
||||
color: #e5e7eb;
|
||||
padding: 0.3rem 0.6rem;
|
||||
font-size: 0.85rem;
|
||||
width: 160px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.new-map-form button {
|
||||
background: #6366f1;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
font-size: 0.82rem;
|
||||
padding: 0.3rem 0.65rem;
|
||||
transition: background 0.12s;
|
||||
}
|
||||
|
||||
.new-map-form button:hover {
|
||||
background: #4f46e5;
|
||||
}
|
||||
|
||||
.new-map-form .cancel-btn {
|
||||
background: #374151;
|
||||
border: 1px solid #4b5563;
|
||||
}
|
||||
|
||||
.new-map-form .cancel-btn:hover {
|
||||
background: #4b5563;
|
||||
}
|
||||
|
||||
/* ---- Grid area (fills remainder) ---- */
|
||||
/* ── Grid area (fills the app body) ── */
|
||||
.app-grid-area {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── Floating panel stack – bottom-left corner ── */
|
||||
/* ── Floating control panels – bottom-left corner ── */
|
||||
.floating-panels-container {
|
||||
position: absolute;
|
||||
bottom: 14px;
|
||||
@@ -142,7 +31,7 @@
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
/* ---- No-map placeholder ---- */
|
||||
/* ── Empty state placeholder ── */
|
||||
.empty-state {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
@@ -151,9 +40,11 @@
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
color: #4b5563;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
@@ -162,34 +53,78 @@
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
/* ---- Auth area (right side of header) ---- */
|
||||
.app-auth {
|
||||
/* ── Access denied state ── */
|
||||
.access-denied-state {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.app-username {
|
||||
font-size: 0.82rem;
|
||||
color: #9ca3af;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ---- Public checkbox in new-map form ---- */
|
||||
.new-map-public {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
font-size: 0.82rem;
|
||||
color: #9ca3af;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
color: #4b5563;
|
||||
user-select: none;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.new-map-public input[type='checkbox'] {
|
||||
accent-color: #6366f1;
|
||||
cursor: pointer;
|
||||
.access-denied-title {
|
||||
margin: 0;
|
||||
font-size: 1.15rem;
|
||||
font-weight: 600;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.access-denied-hint {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.access-request-sent {
|
||||
color: #34d399;
|
||||
}
|
||||
|
||||
.access-request-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.access-request-btns {
|
||||
display: flex;
|
||||
gap: 0.6rem;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.btn-request-access {
|
||||
background: rgba(99, 102, 241, 0.15);
|
||||
border: 1px solid rgba(99, 102, 241, 0.35);
|
||||
border-radius: 6px;
|
||||
color: #818cf8;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
padding: 0.45rem 1.1rem;
|
||||
transition:
|
||||
background 0.12s,
|
||||
border-color 0.12s;
|
||||
}
|
||||
.btn-request-access:hover {
|
||||
background: rgba(99, 102, 241, 0.25);
|
||||
border-color: rgba(99, 102, 241, 0.6);
|
||||
}
|
||||
|
||||
.link-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #818cf8;
|
||||
cursor: pointer;
|
||||
font-size: inherit;
|
||||
padding: 0;
|
||||
text-decoration: underline;
|
||||
transition: color 0.12s;
|
||||
}
|
||||
.link-btn:hover {
|
||||
color: #a5b4fc;
|
||||
}
|
||||
|
||||
514
ui/src/App.tsx
514
ui/src/App.tsx
@@ -1,297 +1,371 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import type { GridMap, Tool, TokenClaims } from './types';
|
||||
import type { GridHandle } from './components/Grid';
|
||||
import { api, auth, getToken, setToken, decodeToken } from './api';
|
||||
import ControlPanel from './components/ControlPanel.tsx';
|
||||
import ColorPanel from './components/ColorPanel';
|
||||
import Grid from './components/Grid';
|
||||
import LoginButton from './components/LoginButton';
|
||||
import './App.css';
|
||||
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 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 "./App.css";
|
||||
|
||||
/** Default colors shown before a map's own colors load from the server. */
|
||||
const DEFAULT_COLORS = [
|
||||
'#6b7280', // 1 – stone
|
||||
'#92400e', // 2 – earth
|
||||
'#15803d', // 3 – grass
|
||||
'#1d4ed8', // 4 – water
|
||||
'#7c3aed', // 5 – arcane
|
||||
'#dc2626', // 6 – lava
|
||||
'#ca8a04', // 7 – sand
|
||||
'#0f172a', // 8 – void
|
||||
'#f9fafb', // 9 – white
|
||||
"#6b7280",
|
||||
"#92400e",
|
||||
"#15803d",
|
||||
"#1d4ed8",
|
||||
"#7c3aed",
|
||||
"#dc2626",
|
||||
"#ca8a04",
|
||||
"#0f172a",
|
||||
"#f9fafb",
|
||||
];
|
||||
|
||||
/** Read the map ID from the current URL path (/map/:id). */
|
||||
function getMapIdFromUrl(): string | null {
|
||||
const match = window.location.pathname.match(/^\/map\/([^/]+)/);
|
||||
return match ? decodeURIComponent(match[1]) : null;
|
||||
}
|
||||
|
||||
/** Read a query parameter value from the current URL. */
|
||||
function getQueryParam(key: string): string | null {
|
||||
return new URLSearchParams(window.location.search).get(key);
|
||||
}
|
||||
|
||||
/** Strip a query parameter from the current URL without causing a reload. */
|
||||
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 : ''));
|
||||
window.history.replaceState(
|
||||
null,
|
||||
"",
|
||||
url.pathname + (url.search !== "?" ? url.search : ""),
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
// ---- Auth state ----
|
||||
const [user, setUser] = useState<TokenClaims | null>(() => {
|
||||
const token = getToken();
|
||||
return token ? decodeToken(token) : null;
|
||||
});
|
||||
// ── Auth state ──
|
||||
const [user, setUser] = useState<UserInfo | null>(null);
|
||||
const [authLoading, setAuthLoading] = useState(true);
|
||||
|
||||
// ---- Map state ----
|
||||
const [maps, setMaps] = useState<GridMap[]>([]);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(getMapIdFromUrl);
|
||||
// ── 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 + unified active color (shared between draw and token)
|
||||
const [tool, setTool] = useState<Tool>('pan');
|
||||
const [activeColor, setActiveColor] = useState(DEFAULT_COLORS[0]);
|
||||
|
||||
// Per-map color palette (updated from WS state on map load / color edits)
|
||||
const [mapColors, setMapColors] = useState<string[]>(DEFAULT_COLORS);
|
||||
|
||||
// Ref to Grid so App can push color updates through the WS
|
||||
// ── 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);
|
||||
|
||||
// New-map form
|
||||
const [showNewMap, setShowNewMap] = useState(false);
|
||||
const [newMapName, setNewMapName] = useState('');
|
||||
const [newMapPublic, setNewMapPublic] = useState(false);
|
||||
const newMapInputRef = useRef<HTMLInputElement>(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);
|
||||
|
||||
// ---- Handle OAuth callback: ?token= or ?error= ----
|
||||
// ── 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(() => {
|
||||
const token = getQueryParam('token');
|
||||
const error = getQueryParam('error');
|
||||
|
||||
if (token) {
|
||||
setToken(token);
|
||||
const claims = decodeToken(token);
|
||||
setUser(claims);
|
||||
removeQueryParam('token');
|
||||
} else if (error) {
|
||||
console.error('OAuth error:', error);
|
||||
removeQueryParam('error');
|
||||
auth.me().then((u) => {
|
||||
setUser(u);
|
||||
setAuthLoading(false);
|
||||
});
|
||||
const error = getQueryParam("error");
|
||||
if (error) {
|
||||
console.error("OAuth error:", error);
|
||||
removeQueryParam("error");
|
||||
}
|
||||
}, []);
|
||||
|
||||
// ---- Load map list ----
|
||||
// ── Load map list after auth resolves ──
|
||||
useEffect(() => {
|
||||
api.listMaps().then(setMaps).catch(console.error);
|
||||
}, [user]); // re-fetch when auth state changes
|
||||
if (!authLoading) {
|
||||
api.listMaps().then(setMaps).catch(console.error);
|
||||
}
|
||||
}, [user, authLoading]);
|
||||
|
||||
// Once maps load, validate the URL-sourced selectedId still exists
|
||||
// ── Direct fetch for URL-accessed maps not in the user's list ──
|
||||
useEffect(() => {
|
||||
if (maps.length === 0 && selectedId) {
|
||||
// Maps are still loading — skip
|
||||
if (!selectedId || authLoading) {
|
||||
setDirectMapInfo(null);
|
||||
setAccessDenied(false);
|
||||
return;
|
||||
}
|
||||
if (selectedId) {
|
||||
const exists = maps.some(m => m.id === selectedId);
|
||||
if (!exists) {
|
||||
// Invalid or inaccessible map ID — reroute to /map
|
||||
setSelectedId(null);
|
||||
window.history.replaceState(null, '', '/map');
|
||||
}
|
||||
const inList = maps.some((m) => m.id === selectedId);
|
||||
if (inList) {
|
||||
setDirectMapInfo(null);
|
||||
setAccessDenied(false);
|
||||
return;
|
||||
}
|
||||
}, [maps]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Keep the URL in sync with the selected map
|
||||
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);
|
||||
const path = selectedId ? `/map/${encodeURIComponent(selectedId)}` : "/map";
|
||||
window.history.replaceState(null, "", path);
|
||||
}, [selectedId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (showNewMap) newMapInputRef.current?.focus();
|
||||
}, [showNewMap]);
|
||||
|
||||
// Reset palette to defaults when no map is selected
|
||||
// ── Reset palette + access state when map deselected ──
|
||||
useEffect(() => {
|
||||
if (!selectedId) {
|
||||
setMapColors(DEFAULT_COLORS);
|
||||
setActiveColor(DEFAULT_COLORS[0]);
|
||||
setAccessRequestSent(false);
|
||||
}
|
||||
}, [selectedId]);
|
||||
|
||||
// ---- Derived state ----
|
||||
const selectedMap = maps.find(m => m.id === selectedId) ?? null;
|
||||
// ── Handlers ──
|
||||
|
||||
// The current user is considered the owner if their Discord ID matches owner_id
|
||||
const isOwner = user !== null && selectedMap !== null && selectedMap.owner_id === user.sub;
|
||||
|
||||
// ---- Handlers ----
|
||||
async function handleCreate(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
const name = newMapName.trim();
|
||||
if (!name) return;
|
||||
try {
|
||||
const m = await api.createMap(name, newMapPublic);
|
||||
setMaps(prev => [m, ...prev]);
|
||||
setSelectedId(m.id);
|
||||
setShowNewMap(false);
|
||||
setNewMapName('');
|
||||
setNewMapPublic(false);
|
||||
} catch (err) {
|
||||
console.error('Failed to create map', err);
|
||||
}
|
||||
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;
|
||||
if (!confirm("Delete this map? This cannot be undone.")) return;
|
||||
try {
|
||||
await api.deleteMap(selectedId);
|
||||
setMaps(prev => prev.filter(m => m.id !== selectedId));
|
||||
setMaps((prev) => prev.filter((m) => m.id !== selectedId));
|
||||
setSelectedId(null);
|
||||
} catch (err) {
|
||||
console.error('Failed to delete map', err);
|
||||
console.error("Failed to delete map", err);
|
||||
}
|
||||
}
|
||||
|
||||
/** Called by Grid when the WS state/colors_updated message arrives. */
|
||||
function handleColorsLoaded(colors: string[]) {
|
||||
setMapColors(colors);
|
||||
setActiveColor(prev => colors.includes(prev) ? prev : colors[0]);
|
||||
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]));
|
||||
}
|
||||
|
||||
/** Called by ColorPanel when the user double-clicks and edits a swatch. */
|
||||
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 ── */}
|
||||
<header className="app-header">
|
||||
<div className="app-brand">
|
||||
<span>SIREN</span>
|
||||
</div>
|
||||
<Header
|
||||
user={user}
|
||||
authLoading={authLoading}
|
||||
selectedMapName={selectedMapInfo?.name ?? null}
|
||||
onLoginClick={() => setShowLoginModal(true)}
|
||||
onAccountClick={() => setShowAccountPanel(true)}
|
||||
/>
|
||||
|
||||
<div className="app-map-controls">
|
||||
{/* Map selector */}
|
||||
{maps.length > 0 && !showNewMap && (
|
||||
<select
|
||||
className="map-select"
|
||||
value={selectedId ?? ''}
|
||||
onChange={e => setSelectedId(e.target.value || null)}
|
||||
>
|
||||
<option value="">— Select a map —</option>
|
||||
{maps.map(m => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{m.name}{m.is_public ? ' (Public)' : ' (Private)'}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<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}
|
||||
/>
|
||||
|
||||
{/* New map form — only for authenticated users */}
|
||||
{user && (
|
||||
showNewMap ? (
|
||||
<form className="new-map-form" onSubmit={handleCreate}>
|
||||
<input
|
||||
ref={newMapInputRef}
|
||||
type="text"
|
||||
placeholder="Map name…"
|
||||
value={newMapName}
|
||||
onChange={e => setNewMapName(e.target.value)}
|
||||
maxLength={60}
|
||||
/>
|
||||
<label className="new-map-public">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={newMapPublic}
|
||||
onChange={e => setNewMapPublic(e.target.checked)}
|
||||
/>
|
||||
Public
|
||||
</label>
|
||||
<button type="submit" disabled={!newMapName.trim()}>Create</button>
|
||||
<button
|
||||
type="button"
|
||||
className="cancel-btn"
|
||||
onClick={() => { setShowNewMap(false); setNewMapName(''); setNewMapPublic(false); }}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<button className="header-btn" onClick={() => setShowNewMap(true)}>
|
||||
+ New Map
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* Delete current map — only for the owner */}
|
||||
{isOwner && !showNewMap && (
|
||||
<button className="header-btn danger" onClick={handleDelete} title="Delete this map">
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Auth area ── */}
|
||||
<div className="app-auth">
|
||||
{user ? (
|
||||
{selectedId && !accessDenied ? (
|
||||
<>
|
||||
<span className="app-username">{user.name}</span>
|
||||
<button className="header-btn" onClick={() => { auth.logout(); setUser(null); }}>
|
||||
Log out
|
||||
</button>
|
||||
<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>
|
||||
) : (
|
||||
<LoginButton className="header-btn" />
|
||||
<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>
|
||||
</header>
|
||||
|
||||
{/* ── Grid area ── */}
|
||||
<div className="app-grid-area">
|
||||
{selectedId ? (
|
||||
<>
|
||||
{/* key forces full remount (new WS + clear state) on map change */}
|
||||
<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>
|
||||
</>
|
||||
) : (
|
||||
<div className="empty-state">
|
||||
<p>Select or create a map to begin</p>
|
||||
<p className="empty-hint">
|
||||
{!user
|
||||
? 'Log in with Discord to create maps and access private maps'
|
||||
: maps.length === 0
|
||||
? 'Click "+ New Map" in the header to get started'
|
||||
: 'Choose a map from the header dropdown'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
298
ui/src/api.ts
298
ui/src/api.ts
@@ -1,126 +1,232 @@
|
||||
import type { GridMap, MapPermission, MapRole, MapState, TokenClaims } from './types';
|
||||
import type {
|
||||
GridMap,
|
||||
ListedMap,
|
||||
MapAccessRequest,
|
||||
MapPermission,
|
||||
MapRole,
|
||||
MapState,
|
||||
PublicAccess,
|
||||
UserInfo,
|
||||
} from "./types";
|
||||
|
||||
const BASE = '/api/grid';
|
||||
const AUTH_BASE = '/api/auth/discord';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Token helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const TOKEN_KEY = 'siren_token';
|
||||
|
||||
export function getToken(): string | null {
|
||||
return localStorage.getItem(TOKEN_KEY);
|
||||
}
|
||||
|
||||
export function setToken(token: string): void {
|
||||
localStorage.setItem(TOKEN_KEY, token);
|
||||
}
|
||||
|
||||
export function removeToken(): void {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
}
|
||||
|
||||
/** Decode the JWT payload without verifying the signature (client-side only). */
|
||||
export function decodeToken(token: string): TokenClaims | null {
|
||||
try {
|
||||
const payload = token.split('.')[1];
|
||||
const json = atob(payload.replace(/-/g, '+').replace(/_/g, '/'));
|
||||
return JSON.parse(json) as TokenClaims;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns true if the stored token is present and not expired. */
|
||||
// export function isAuthenticated(): boolean {
|
||||
// const token = getToken();
|
||||
// if (!token) return false;
|
||||
// const claims = decodeToken(token);
|
||||
// if (!claims) return false;
|
||||
// return claims.exp > Date.now() / 1000;
|
||||
// }
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Core fetch wrapper
|
||||
// ---------------------------------------------------------------------------
|
||||
const GRID_BASE = "/api/grid";
|
||||
const AUTH_BASE = "/api/auth";
|
||||
|
||||
async function request<T>(url: string, init?: RequestInit): Promise<T> {
|
||||
const token = getToken();
|
||||
const headers: Record<string, string> = {
|
||||
...(init?.headers as Record<string, string>),
|
||||
};
|
||||
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const res = await fetch(url, { ...init, headers });
|
||||
|
||||
if (res.status === 401) {
|
||||
// Token expired or invalid — clear local storage
|
||||
removeToken();
|
||||
}
|
||||
const res = await fetch(url, {
|
||||
...init,
|
||||
credentials: "include",
|
||||
headers: {
|
||||
...(init?.headers as Record<string, string>),
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => res.statusText);
|
||||
throw new Error(`${res.status}: ${text}`);
|
||||
}
|
||||
if (res.status === 204) return undefined as T;
|
||||
return res.json();
|
||||
|
||||
// Read the bdoy if it exists
|
||||
const text = await res.text();
|
||||
if (!text) return undefined as T;
|
||||
|
||||
// Parse JSON if it exists
|
||||
const contentType = res.headers.get("content-type") ?? "";
|
||||
if (contentType.includes("application/json")) {
|
||||
return JSON.parse(text) as T;
|
||||
} else if (contentType.includes("text/plain")) {
|
||||
return text as unknown as T;
|
||||
}
|
||||
|
||||
throw new Error(`Expected JSON or text but got: ${contentType}`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Grid map API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const api = {
|
||||
listMaps: (): Promise<GridMap[]> =>
|
||||
request<GridMap[]>(`${BASE}/maps`),
|
||||
/** List maps where the authenticated user has a direct role or has favorited. */
|
||||
listMaps: (): Promise<ListedMap[]> =>
|
||||
request<ListedMap[]>(`${GRID_BASE}/maps`),
|
||||
|
||||
createMap: (name: string, is_public = false): Promise<GridMap> =>
|
||||
request<GridMap>(`${BASE}/maps`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, is_public }),
|
||||
/** Create a new map (authenticated). */
|
||||
createMap: (
|
||||
name: string,
|
||||
public_access: PublicAccess = "private",
|
||||
): Promise<GridMap> =>
|
||||
request<GridMap>(`${GRID_BASE}/maps`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name, public_access }),
|
||||
}),
|
||||
|
||||
/** Get full map state (cells, tokens, colors). */
|
||||
getMap: (id: string): Promise<MapState> =>
|
||||
request<MapState>(`${BASE}/maps/${id}`),
|
||||
request<MapState>(`${GRID_BASE}/maps/${id}`),
|
||||
|
||||
/** Update map name and/or public_access (owner only). */
|
||||
updateMap: (
|
||||
id: string,
|
||||
payload: { name?: string; public_access?: PublicAccess },
|
||||
): Promise<GridMap> =>
|
||||
request<GridMap>(`${GRID_BASE}/maps/${id}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
|
||||
/** Delete a map (owner only). */
|
||||
deleteMap: (id: string): Promise<void> =>
|
||||
request<void>(`${BASE}/maps/${id}`, { method: 'DELETE' }),
|
||||
request<void>(`${GRID_BASE}/maps/${id}`, { method: "DELETE" }),
|
||||
|
||||
// ---- Permissions ----
|
||||
|
||||
/** List all permissions for a map including usernames (owner only). */
|
||||
listPermissions: (mapId: string): Promise<MapPermission[]> =>
|
||||
request<MapPermission[]>(`${BASE}/maps/${mapId}/permissions`),
|
||||
request<MapPermission[]>(`${GRID_BASE}/maps/${mapId}/permissions`),
|
||||
|
||||
updatePermission: (mapId: string, userId: number, role: MapRole | null): Promise<void> =>
|
||||
request<void>(`${BASE}/maps/${mapId}/permissions`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ user_id: userId, role }),
|
||||
/**
|
||||
* Add or update a user's role by username.
|
||||
* Pass `role: null` to remove the user's permission entirely.
|
||||
*/
|
||||
updatePermission: (
|
||||
mapId: string,
|
||||
username: string,
|
||||
role: MapRole | null,
|
||||
): Promise<void> =>
|
||||
request<void>(`${GRID_BASE}/maps/${mapId}/permissions`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, role }),
|
||||
}),
|
||||
|
||||
/** Favorite a map (adds it to the user's map list). */
|
||||
favoriteMap: (id: string): Promise<void> =>
|
||||
request<void>(`${GRID_BASE}/maps/${id}/favorite`, { method: "POST" }),
|
||||
|
||||
/** Un-favorite a map. */
|
||||
unfavoriteMap: (id: string): Promise<void> =>
|
||||
request<void>(`${GRID_BASE}/maps/${id}/favorite`, { method: "DELETE" }),
|
||||
|
||||
/** Request viewer or editor access to a map. */
|
||||
requestAccess: (mapId: string, role: "editor" | "viewer"): Promise<void> =>
|
||||
request<void>(`${GRID_BASE}/maps/${mapId}/access-requests`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ role }),
|
||||
}),
|
||||
|
||||
/** List pending access requests for a map (owner only). */
|
||||
listAccessRequests: (mapId: string): Promise<MapAccessRequest[]> =>
|
||||
request<MapAccessRequest[]>(`${GRID_BASE}/maps/${mapId}/access-requests`),
|
||||
|
||||
/** Approve or deny a pending access request (owner only). */
|
||||
resolveAccessRequest: (
|
||||
mapId: string,
|
||||
requestId: string,
|
||||
action: "approve" | "deny",
|
||||
): Promise<void> =>
|
||||
request<void>(`${GRID_BASE}/maps/${mapId}/access-requests/${requestId}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action }),
|
||||
}),
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Auth API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const auth = {
|
||||
/** Fetches the Discord OAuth URL and redirects the browser to it.
|
||||
* Passes the current page's origin + /map as the UI redirect URI so
|
||||
* the backend knows where to send the browser after login completes.
|
||||
*/
|
||||
async login(): Promise<void> {
|
||||
const redirectUri = encodeURIComponent(window.location.origin + '/map');
|
||||
const url = await request<string>(`${AUTH_BASE}/authorize?redirect_uri=${redirectUri}`);
|
||||
window.location.href = url;
|
||||
/** Fetch the currently authenticated user's info. Returns null if not logged in. */
|
||||
async me(): Promise<UserInfo | null> {
|
||||
try {
|
||||
return await request<UserInfo>(`${AUTH_BASE}/me`);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
logout(): void {
|
||||
removeToken();
|
||||
window.location.href = '/map';
|
||||
/** Register a new local account. */
|
||||
async register(username: string, password: string): Promise<void> {
|
||||
await request<void>(`${AUTH_BASE}/register`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
},
|
||||
|
||||
/** Login with username and password. */
|
||||
async loginLocal(username: string, password: string): Promise<void> {
|
||||
await request<void>(`${AUTH_BASE}/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
},
|
||||
|
||||
/** Start Discord OAuth login flow (anonymous). */
|
||||
async loginDiscord(redirectUri?: string): Promise<void> {
|
||||
const target = encodeURIComponent(redirectUri ?? window.location.href);
|
||||
const response = await request<string>(
|
||||
`${AUTH_BASE}/discord/authorize?redirect_uri=${target}`,
|
||||
);
|
||||
window.location.href = JSON.parse(response);
|
||||
},
|
||||
|
||||
/** Start Discord OAuth connect flow (authenticated users only). */
|
||||
async connectDiscord(redirectUri?: string): Promise<void> {
|
||||
const target = encodeURIComponent(
|
||||
redirectUri ?? window.location.origin + "/account",
|
||||
);
|
||||
const response = await request<string>(
|
||||
`${AUTH_BASE}/discord/connect?redirect_uri=${target}`,
|
||||
);
|
||||
window.location.href = JSON.parse(response);
|
||||
},
|
||||
|
||||
/** Clear the session cookie server-side and reload. */
|
||||
async logout(): Promise<void> {
|
||||
try {
|
||||
await request<void>(`${AUTH_BASE}/logout`, { method: "POST" });
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
window.location.href = "/map";
|
||||
},
|
||||
|
||||
/**
|
||||
* Update the user's first and/or last name.
|
||||
* Pass an empty string to clear a field; pass undefined to leave it unchanged.
|
||||
*/
|
||||
async updateProfile(
|
||||
firstName?: string,
|
||||
lastName?: string,
|
||||
): Promise<UserInfo> {
|
||||
return request<UserInfo>(`${AUTH_BASE}/profile`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ first_name: firstName, last_name: lastName }),
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Change or set the account password.
|
||||
* `currentPassword` is required when the account already has a password,
|
||||
* and can be omitted (null/undefined) for OAuth-only accounts setting a
|
||||
* password for the first time.
|
||||
*/
|
||||
async changePassword(
|
||||
currentPassword: string | null,
|
||||
newPassword: string,
|
||||
): Promise<void> {
|
||||
await request<void>(`${AUTH_BASE}/change-password`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
current_password: currentPassword ?? undefined,
|
||||
new_password: newPassword,
|
||||
}),
|
||||
});
|
||||
},
|
||||
|
||||
/** Disconnect an OAuth provider (requires a password to be set first). */
|
||||
async disconnectProvider(provider: string): Promise<void> {
|
||||
await request<void>(`${AUTH_BASE}/connections/${provider}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
316
ui/src/components/AccountPanel.css
Normal file
316
ui/src/components/AccountPanel.css
Normal file
@@ -0,0 +1,316 @@
|
||||
/* ── Backdrop ── */
|
||||
.account-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
/* ── Panel card ── */
|
||||
.account-panel {
|
||||
background: #1e2130;
|
||||
border: 1px solid #2e3348;
|
||||
border-radius: 10px;
|
||||
padding: 1.5rem;
|
||||
width: 440px;
|
||||
max-width: 90vw;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
/* ── Header ── */
|
||||
.account-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.account-header h2 {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.account-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #8892a4;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
padding: 0.25rem;
|
||||
}
|
||||
.account-close:hover {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
/* ── Section ── */
|
||||
.account-section {
|
||||
border-top: 1px solid #2e3348;
|
||||
padding-top: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.account-section h3 {
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: #8892a4;
|
||||
}
|
||||
|
||||
.section-header-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.section-header-row h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ── Profile form ── */
|
||||
.profile-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.profile-name-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.account-field-label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
font-size: 0.75rem;
|
||||
color: #8892a4;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.account-field-label input {
|
||||
background: #141622;
|
||||
border: 1px solid #2e3348;
|
||||
border-radius: 6px;
|
||||
color: #e2e8f0;
|
||||
font-size: 0.85rem;
|
||||
padding: 0.35rem 0.6rem;
|
||||
outline: none;
|
||||
transition: border-color 0.12s;
|
||||
}
|
||||
|
||||
.account-field-label input:focus {
|
||||
border-color: #6366f1;
|
||||
}
|
||||
|
||||
/* ── Read-only fields ── */
|
||||
.readonly-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.account-label {
|
||||
font-size: 0.8rem;
|
||||
color: #8892a4;
|
||||
min-width: 70px;
|
||||
}
|
||||
|
||||
.account-value {
|
||||
font-size: 0.9rem;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
/* ── Profile save / cancel row ── */
|
||||
.profile-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.btn-save {
|
||||
background: #6366f1;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
padding: 0.35rem 0.9rem;
|
||||
transition: background 0.12s;
|
||||
}
|
||||
.btn-save:hover {
|
||||
background: #4f46e5;
|
||||
}
|
||||
.btn-save:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.btn-text {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #6b7280;
|
||||
cursor: pointer;
|
||||
font-size: 0.82rem;
|
||||
padding: 0.25rem 0.4rem;
|
||||
transition: color 0.12s;
|
||||
}
|
||||
.btn-text:hover {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
/* ── Password form ── */
|
||||
.password-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
/* ── Messages ── */
|
||||
.account-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;
|
||||
}
|
||||
|
||||
.account-success {
|
||||
margin: 0;
|
||||
font-size: 0.8rem;
|
||||
color: #34d399;
|
||||
background: rgba(16, 185, 129, 0.08);
|
||||
border: 1px solid rgba(16, 185, 129, 0.2);
|
||||
border-radius: 5px;
|
||||
padding: 0.35rem 0.6rem;
|
||||
}
|
||||
|
||||
/* ── Connection row ── */
|
||||
.account-connection {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
background: #141622;
|
||||
border: 1px solid #2e3348;
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
|
||||
.connection-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.discord-icon {
|
||||
color: #5865f2;
|
||||
}
|
||||
|
||||
.connection-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
.connection-name {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.connection-linked {
|
||||
font-size: 0.75rem;
|
||||
color: #4ade80;
|
||||
}
|
||||
|
||||
.connection-unlinked {
|
||||
font-size: 0.75rem;
|
||||
color: #8892a4;
|
||||
}
|
||||
|
||||
.connection-hint {
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
color: #f59e0b;
|
||||
padding: 0 0.25rem;
|
||||
}
|
||||
|
||||
.btn-connect-discord {
|
||||
background: #5865f2;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
padding: 0.35rem 0.75rem;
|
||||
transition: background 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.btn-connect-discord:hover {
|
||||
background: #4752c4;
|
||||
}
|
||||
|
||||
.btn-disconnect {
|
||||
background: transparent;
|
||||
border: 1px solid #4a5568;
|
||||
border-radius: 6px;
|
||||
color: #8892a4;
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
padding: 0.35rem 0.75rem;
|
||||
transition:
|
||||
color 0.15s,
|
||||
border-color 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.btn-disconnect:hover:not(:disabled) {
|
||||
border-color: #ef4444;
|
||||
color: #f87171;
|
||||
}
|
||||
.btn-disconnect:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ── Footer ── */
|
||||
.account-footer {
|
||||
border-top: 1px solid #2e3348;
|
||||
padding-top: 1rem;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.btn-logout {
|
||||
background: transparent;
|
||||
border: 1px solid #4a5568;
|
||||
border-radius: 6px;
|
||||
color: #8892a4;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
padding: 0.4rem 1rem;
|
||||
transition:
|
||||
color 0.15s,
|
||||
border-color 0.15s;
|
||||
}
|
||||
.btn-logout:hover {
|
||||
border-color: #ef4444;
|
||||
color: #ef4444;
|
||||
}
|
||||
358
ui/src/components/AccountPanel.tsx
Normal file
358
ui/src/components/AccountPanel.tsx
Normal file
@@ -0,0 +1,358 @@
|
||||
import { useState } from "react";
|
||||
import { auth } from "../api";
|
||||
import type { UserInfo } from "../types";
|
||||
import "./AccountPanel.css";
|
||||
|
||||
interface Props {
|
||||
user: UserInfo;
|
||||
onClose: () => void;
|
||||
onRefresh: () => void;
|
||||
}
|
||||
|
||||
export default function AccountPanel({ user, onClose, onRefresh }: Props) {
|
||||
const discordConnection = user.connections.find(
|
||||
(c) => c.provider === "discord",
|
||||
);
|
||||
|
||||
// ── Profile editing ──
|
||||
const [firstName, setFirstName] = useState(user.first_name ?? "");
|
||||
const [lastName, setLastName] = useState(user.last_name ?? "");
|
||||
const [profileDirty, setProfileDirty] = useState(false);
|
||||
const [profileSaving, setProfileSaving] = useState(false);
|
||||
const [profileError, setProfileError] = useState<string | null>(null);
|
||||
const [profileSuccess, setProfileSuccess] = useState(false);
|
||||
|
||||
// ── Password change ──
|
||||
const [pwCurrent, setPwCurrent] = useState("");
|
||||
const [pwNew, setPwNew] = useState("");
|
||||
const [pwConfirm, setPwConfirm] = useState("");
|
||||
const [pwSaving, setPwSaving] = useState(false);
|
||||
const [pwError, setPwError] = useState<string | null>(null);
|
||||
const [pwSuccess, setPwSuccess] = useState(false);
|
||||
const [showPasswordSection, setShowPasswordSection] = useState(false);
|
||||
|
||||
function handleFirstNameChange(v: string) {
|
||||
setFirstName(v);
|
||||
setProfileDirty(true);
|
||||
setProfileSuccess(false);
|
||||
}
|
||||
|
||||
function handleLastNameChange(v: string) {
|
||||
setLastName(v);
|
||||
setProfileDirty(true);
|
||||
setProfileSuccess(false);
|
||||
}
|
||||
|
||||
async function handleSaveProfile(e: React.SubmitEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
setProfileSaving(true);
|
||||
setProfileError(null);
|
||||
setProfileSuccess(false);
|
||||
try {
|
||||
await auth.updateProfile(firstName, lastName);
|
||||
setProfileDirty(false);
|
||||
setProfileSuccess(true);
|
||||
await onRefresh();
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
setProfileError(
|
||||
msg.replace(/^\d+:\s*/, "").trim() || "Failed to save profile",
|
||||
);
|
||||
} finally {
|
||||
setProfileSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleChangePassword(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setPwError(null);
|
||||
setPwSuccess(false);
|
||||
|
||||
if (pwNew !== pwConfirm) {
|
||||
setPwError("Passwords do not match");
|
||||
return;
|
||||
}
|
||||
if (pwNew.length < 8) {
|
||||
setPwError("Password must be at least 8 characters");
|
||||
return;
|
||||
}
|
||||
|
||||
setPwSaving(true);
|
||||
try {
|
||||
await auth.changePassword(user.has_password ? pwCurrent : null, pwNew);
|
||||
setPwCurrent("");
|
||||
setPwNew("");
|
||||
setPwConfirm("");
|
||||
setPwSuccess(true);
|
||||
setShowPasswordSection(false);
|
||||
await onRefresh();
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
setPwError(
|
||||
msg.replace(/^\d+:\s*/, "").trim() || "Failed to change password",
|
||||
);
|
||||
} finally {
|
||||
setPwSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleConnectDiscord() {
|
||||
try {
|
||||
await auth.connectDiscord(window.location.origin + "/map");
|
||||
} catch (err) {
|
||||
console.error("Failed to connect Discord:", err);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDisconnectDiscord() {
|
||||
if (!confirm("Disconnect your Discord account?")) return;
|
||||
try {
|
||||
await auth.disconnectProvider("discord");
|
||||
await onRefresh();
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
alert(
|
||||
msg.replace(/^\d+:\s*/, "").trim() || "Failed to disconnect Discord",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
<button
|
||||
className="account-close"
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ── 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">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>
|
||||
)}
|
||||
|
||||
{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 && (
|
||||
<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">
|
||||
<svg
|
||||
className="connection-icon discord-icon"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03z" />
|
||||
</svg>
|
||||
|
||||
<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>
|
||||
|
||||
{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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{discordConnection && !user.has_password && (
|
||||
<p className="connection-hint">
|
||||
Set a password above before disconnecting Discord to avoid being
|
||||
locked out.
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* ── Footer ── */}
|
||||
<div className="account-footer">
|
||||
<button className="btn-logout" onClick={handleLogout}>
|
||||
Log Out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -31,7 +31,9 @@
|
||||
border: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
transition: transform 0.1s, border-color 0.1s;
|
||||
transition:
|
||||
transform 0.1s,
|
||||
border-color 0.1s;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import './ColorPanel.css';
|
||||
import { useEffect, useRef } from "react";
|
||||
import "./ColorPanel.css";
|
||||
|
||||
interface Props {
|
||||
colors: string[];
|
||||
@@ -8,7 +8,12 @@ interface Props {
|
||||
onColorsChange: (colors: string[]) => void;
|
||||
}
|
||||
|
||||
export default function ColorPanel({ colors, activeColor, onColorChange, onColorsChange }: Props) {
|
||||
export default function ColorPanel({
|
||||
colors,
|
||||
activeColor,
|
||||
onColorChange,
|
||||
onColorsChange,
|
||||
}: Props) {
|
||||
// One hidden color input ref per slot
|
||||
const inputRefs = useRef<(HTMLInputElement | null)[]>([]);
|
||||
|
||||
@@ -16,14 +21,18 @@ export default function ColorPanel({ colors, activeColor, onColorChange, onColor
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.shiftKey || e.ctrlKey || e.metaKey || e.altKey) return;
|
||||
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
|
||||
if (
|
||||
e.target instanceof HTMLInputElement ||
|
||||
e.target instanceof HTMLTextAreaElement
|
||||
)
|
||||
return;
|
||||
const num = parseInt(e.key, 10);
|
||||
if (num >= 1 && num <= colors.length) {
|
||||
onColorChange(colors[num - 1]);
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handler);
|
||||
return () => window.removeEventListener('keydown', handler);
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [colors, onColorChange]);
|
||||
|
||||
function handleDoubleClick(index: number) {
|
||||
@@ -44,7 +53,7 @@ export default function ColorPanel({ colors, activeColor, onColorChange, onColor
|
||||
{colors.map((c, i) => (
|
||||
<div key={i} className="cp-swatch-wrapper">
|
||||
<button
|
||||
className={`cp-swatch ${activeColor === c ? 'selected' : ''}`}
|
||||
className={`cp-swatch ${activeColor === c ? "selected" : ""}`}
|
||||
style={{ background: c }}
|
||||
onClick={() => onColorChange(c)}
|
||||
onDoubleClick={() => handleDoubleClick(i)}
|
||||
@@ -54,10 +63,12 @@ export default function ColorPanel({ colors, activeColor, onColorChange, onColor
|
||||
</button>
|
||||
{/* Hidden color picker for this slot */}
|
||||
<input
|
||||
ref={el => { inputRefs.current[i] = el; }}
|
||||
ref={(el) => {
|
||||
inputRefs.current[i] = el;
|
||||
}}
|
||||
type="color"
|
||||
value={c}
|
||||
onChange={e => handleColorEdit(i, e.target.value)}
|
||||
onChange={(e) => handleColorEdit(i, e.target.value)}
|
||||
className="cp-color-input"
|
||||
tabIndex={-1}
|
||||
/>
|
||||
|
||||
@@ -23,7 +23,9 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.12s, border-color 0.12s;
|
||||
transition:
|
||||
background 0.12s,
|
||||
border-color 0.12s;
|
||||
}
|
||||
|
||||
.fp-tool-btn:hover {
|
||||
|
||||
@@ -1,47 +1,85 @@
|
||||
import { useEffect } from 'react';
|
||||
import { MdPanTool, MdZoomIn, MdBrush, MdPerson } from 'react-icons/md';
|
||||
import type { Tool } from '../types';
|
||||
import './ControlPanel.css';
|
||||
import { useEffect } from "react";
|
||||
import { MdPanTool, MdZoomIn, MdBrush, MdPerson } from "react-icons/md";
|
||||
import type { Tool } from "../types";
|
||||
import "./ControlPanel.css";
|
||||
|
||||
interface Props {
|
||||
tool: Tool;
|
||||
onToolChange: (t: Tool) => void;
|
||||
}
|
||||
|
||||
const TOOLS: { id: Tool; icon: React.ReactNode; title: string; shortcut: string }[] = [
|
||||
{ id: 'pan', icon: <MdPanTool />, title: 'Pan – drag to move the map', shortcut: 'Shift+1' },
|
||||
{ id: 'zoom', icon: <MdZoomIn />, title: 'Zoom – click to zoom in/out', shortcut: 'Shift+2' },
|
||||
{ id: 'draw', icon: <MdBrush />, title: 'Draw – left-click to paint, right-click to erase, Shift+click to fill', shortcut: 'Shift+3' },
|
||||
{ id: 'token', icon: <MdPerson />, title: 'Token – click to place, drag to move, right-click to delete', shortcut: 'Shift+4' },
|
||||
const TOOLS: {
|
||||
id: Tool;
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
shortcut: string;
|
||||
}[] = [
|
||||
{
|
||||
id: "pan",
|
||||
icon: <MdPanTool />,
|
||||
title: "Pan – drag to move the map",
|
||||
shortcut: "Shift+1",
|
||||
},
|
||||
{
|
||||
id: "zoom",
|
||||
icon: <MdZoomIn />,
|
||||
title: "Zoom – click to zoom in/out",
|
||||
shortcut: "Shift+2",
|
||||
},
|
||||
{
|
||||
id: "draw",
|
||||
icon: <MdBrush />,
|
||||
title:
|
||||
"Draw – left-click to paint, right-click to erase, Shift+click to fill",
|
||||
shortcut: "Shift+3",
|
||||
},
|
||||
{
|
||||
id: "token",
|
||||
icon: <MdPerson />,
|
||||
title: "Token – click to place, drag to move, right-click to delete",
|
||||
shortcut: "Shift+4",
|
||||
},
|
||||
];
|
||||
|
||||
export default function ControlPanel({ tool, onToolChange }: Props) {
|
||||
// Keyboard shortcuts: Shift+1/2/3/4 for tools
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
|
||||
if (
|
||||
e.target instanceof HTMLInputElement ||
|
||||
e.target instanceof HTMLTextAreaElement
|
||||
)
|
||||
return;
|
||||
if (!e.shiftKey) return;
|
||||
switch (e.key) {
|
||||
case '!': // Shift+1 on many layouts
|
||||
case '1': onToolChange('pan'); break;
|
||||
case '@': // Shift+2
|
||||
case '2': onToolChange('zoom'); break;
|
||||
case '#': // Shift+3
|
||||
case '3': onToolChange('draw'); break;
|
||||
case '$': // Shift+4
|
||||
case '4': onToolChange('token'); break;
|
||||
case "!": // Shift+1 on many layouts
|
||||
case "1":
|
||||
onToolChange("pan");
|
||||
break;
|
||||
case "@": // Shift+2
|
||||
case "2":
|
||||
onToolChange("zoom");
|
||||
break;
|
||||
case "#": // Shift+3
|
||||
case "3":
|
||||
onToolChange("draw");
|
||||
break;
|
||||
case "$": // Shift+4
|
||||
case "4":
|
||||
onToolChange("token");
|
||||
break;
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handler);
|
||||
return () => window.removeEventListener('keydown', handler);
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [onToolChange]);
|
||||
|
||||
return (
|
||||
<div className="floating-panel">
|
||||
{TOOLS.map(t => (
|
||||
{TOOLS.map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
className={`fp-tool-btn ${tool === t.id ? 'active' : ''}`}
|
||||
className={`fp-tool-btn ${tool === t.id ? "active" : ""}`}
|
||||
onClick={() => onToolChange(t.id)}
|
||||
title={`${t.title} (${t.shortcut})`}
|
||||
>
|
||||
|
||||
198
ui/src/components/EditMapModal.css
Normal file
198
ui/src/components/EditMapModal.css
Normal file
@@ -0,0 +1,198 @@
|
||||
.edit-map-modal {
|
||||
width: 500px;
|
||||
max-width: 92vw;
|
||||
max-height: 88vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.edit-map-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.1rem;
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
/* ── Section dividers ── */
|
||||
.edit-section {
|
||||
border-top: 1px solid #2e3348;
|
||||
padding-top: 1rem;
|
||||
margin-top: 0.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.edit-section h3 {
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.edit-loading {
|
||||
font-size: 0.82rem;
|
||||
color: #6b7280;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ── Permission list ── */
|
||||
.perm-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.perm-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
background: #141622;
|
||||
border: 1px solid #2e3348;
|
||||
border-radius: 6px;
|
||||
padding: 0.45rem 0.75rem;
|
||||
}
|
||||
|
||||
.perm-username {
|
||||
flex: 1;
|
||||
font-size: 0.85rem;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.perm-role-badge {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.role-owner {
|
||||
background: rgba(99, 102, 241, 0.2);
|
||||
color: #818cf8;
|
||||
}
|
||||
.role-editor {
|
||||
background: rgba(234, 179, 8, 0.15);
|
||||
color: #fbbf24;
|
||||
}
|
||||
.role-viewer {
|
||||
background: rgba(16, 185, 129, 0.15);
|
||||
color: #34d399;
|
||||
}
|
||||
|
||||
.perm-remove {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #6b7280;
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
padding: 0.1rem 0.3rem;
|
||||
line-height: 1;
|
||||
transition: color 0.12s;
|
||||
}
|
||||
.perm-remove:hover {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.perm-empty {
|
||||
font-size: 0.8rem;
|
||||
color: #4b5563;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ── Add permission form ── */
|
||||
.add-perm-form {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.add-perm-input {
|
||||
flex: 1;
|
||||
min-width: 120px;
|
||||
background: #111827;
|
||||
border: 1px solid #4b5563;
|
||||
border-radius: 6px;
|
||||
color: #e5e7eb;
|
||||
padding: 0.35rem 0.6rem;
|
||||
font-size: 0.82rem;
|
||||
outline: none;
|
||||
}
|
||||
.add-perm-input:focus {
|
||||
border-color: #6366f1;
|
||||
}
|
||||
|
||||
.add-perm-role {
|
||||
background: #1f2937;
|
||||
border: 1px solid #4b5563;
|
||||
border-radius: 6px;
|
||||
color: #e5e7eb;
|
||||
padding: 0.35rem 0.6rem;
|
||||
font-size: 0.82rem;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* ── Access request list ── */
|
||||
.req-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.req-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
background: #141622;
|
||||
border: 1px solid #2e3348;
|
||||
border-radius: 6px;
|
||||
padding: 0.45rem 0.75rem;
|
||||
}
|
||||
|
||||
.req-username {
|
||||
flex: 1;
|
||||
font-size: 0.85rem;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.req-actions {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.btn-approve {
|
||||
background: rgba(16, 185, 129, 0.15);
|
||||
border: 1px solid rgba(16, 185, 129, 0.3);
|
||||
border-radius: 5px;
|
||||
color: #34d399;
|
||||
cursor: pointer;
|
||||
font-size: 0.75rem;
|
||||
padding: 0.2rem 0.55rem;
|
||||
transition: background 0.12s;
|
||||
}
|
||||
.btn-approve:hover {
|
||||
background: rgba(16, 185, 129, 0.25);
|
||||
}
|
||||
|
||||
.btn-deny {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border: 1px solid rgba(239, 68, 68, 0.25);
|
||||
border-radius: 5px;
|
||||
color: #f87171;
|
||||
cursor: pointer;
|
||||
font-size: 0.75rem;
|
||||
padding: 0.2rem 0.55rem;
|
||||
transition: background 0.12s;
|
||||
}
|
||||
.btn-deny:hover {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
}
|
||||
|
||||
/* ── Small variant of primary button ── */
|
||||
.btn-sm {
|
||||
padding: 0.35rem 0.75rem !important;
|
||||
font-size: 0.8rem !important;
|
||||
}
|
||||
326
ui/src/components/EditMapModal.tsx
Normal file
326
ui/src/components/EditMapModal.tsx
Normal file
@@ -0,0 +1,326 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { api } from "../api";
|
||||
import type {
|
||||
GridMap,
|
||||
ListedMap,
|
||||
MapAccessRequest,
|
||||
MapPermission,
|
||||
MapRole,
|
||||
PublicAccess,
|
||||
} from "../types";
|
||||
import "./EditMapModal.css";
|
||||
|
||||
interface Props {
|
||||
map: GridMap | ListedMap;
|
||||
onClose: () => void;
|
||||
onUpdated: (updated: GridMap) => void;
|
||||
}
|
||||
|
||||
export default function EditMapModal({ map, onClose, onUpdated }: Props) {
|
||||
const [name, setName] = useState(map.name);
|
||||
const [publicAccess, setPublicAccess] = useState<PublicAccess>(
|
||||
map.public_access,
|
||||
);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
|
||||
// Permissions
|
||||
const [permissions, setPermissions] = useState<MapPermission[]>([]);
|
||||
const [permsLoading, setPermsLoading] = useState(true);
|
||||
|
||||
// Add permission
|
||||
const [addUsername, setAddUsername] = useState("");
|
||||
const [addRole, setAddRole] = useState<"editor" | "viewer">("viewer");
|
||||
const [addLoading, setAddLoading] = useState(false);
|
||||
const [addError, setAddError] = useState<string | null>(null);
|
||||
|
||||
// Access requests
|
||||
const [requests, setRequests] = useState<MapAccessRequest[]>([]);
|
||||
const [reqsLoading, setReqsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
loadPermissions();
|
||||
loadRequests();
|
||||
}, [map.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
async function loadPermissions() {
|
||||
setPermsLoading(true);
|
||||
try {
|
||||
const perms = await api.listPermissions(map.id);
|
||||
setPermissions(perms);
|
||||
} catch {
|
||||
// silent
|
||||
} finally {
|
||||
setPermsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRequests() {
|
||||
setReqsLoading(true);
|
||||
try {
|
||||
const reqs = await api.listAccessRequests(map.id);
|
||||
setRequests(reqs);
|
||||
} catch {
|
||||
// silent — might just not be owner
|
||||
} finally {
|
||||
setReqsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return;
|
||||
setSaving(true);
|
||||
setSaveError(null);
|
||||
try {
|
||||
const updated = await api.updateMap(map.id, {
|
||||
name: trimmed,
|
||||
public_access: publicAccess,
|
||||
});
|
||||
onUpdated(updated);
|
||||
onClose();
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
setSaveError(msg.replace(/^\d+:\s*/, "").trim() || "Failed to save");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRemovePermission(username: string) {
|
||||
try {
|
||||
await api.updatePermission(map.id, username, null);
|
||||
setPermissions((prev) => prev.filter((p) => p.username !== username));
|
||||
} catch (err) {
|
||||
console.error("Failed to remove permission", err);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAddPermission(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
const username = addUsername.trim();
|
||||
if (!username) return;
|
||||
setAddLoading(true);
|
||||
setAddError(null);
|
||||
try {
|
||||
await api.updatePermission(map.id, username, addRole as MapRole);
|
||||
setAddUsername("");
|
||||
await loadPermissions();
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
setAddError(msg.replace(/^\d+:\s*/, "").trim() || "Failed to add user");
|
||||
} finally {
|
||||
setAddLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleResolveRequest(
|
||||
requestId: string,
|
||||
action: "approve" | "deny",
|
||||
) {
|
||||
try {
|
||||
await api.resolveAccessRequest(map.id, requestId, action);
|
||||
setRequests((prev) => prev.filter((r) => r.id !== requestId));
|
||||
if (action === "approve") {
|
||||
await loadPermissions();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to resolve request", err);
|
||||
}
|
||||
}
|
||||
|
||||
const nonOwnerPerms = permissions.filter((p) => p.role !== "owner");
|
||||
|
||||
return (
|
||||
<div className="modal-backdrop" onClick={onClose}>
|
||||
<div
|
||||
className="modal edit-map-modal"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="modal-header">
|
||||
<h2>Edit Map</h2>
|
||||
<button className="modal-close" onClick={onClose} aria-label="Close">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ── Map settings ── */}
|
||||
<form onSubmit={handleSave} className="edit-map-form">
|
||||
<label className="field-label">
|
||||
Map Name
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
maxLength={60}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
|
||||
<fieldset className="public-access-fieldset">
|
||||
<legend>Visibility</legend>
|
||||
|
||||
<label className="radio-option">
|
||||
<input
|
||||
type="radio"
|
||||
name="edit_public_access"
|
||||
value="private"
|
||||
checked={publicAccess === "private"}
|
||||
onChange={() => setPublicAccess("private")}
|
||||
/>
|
||||
<span className="radio-label">
|
||||
<strong>Private</strong>
|
||||
<span className="radio-hint">Only you and invited users</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label className="radio-option">
|
||||
<input
|
||||
type="radio"
|
||||
name="edit_public_access"
|
||||
value="public_view"
|
||||
checked={publicAccess === "public_view"}
|
||||
onChange={() => setPublicAccess("public_view")}
|
||||
/>
|
||||
<span className="radio-label">
|
||||
<strong>Public – View Only</strong>
|
||||
<span className="radio-hint">
|
||||
Anyone with the link can view
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label className="radio-option">
|
||||
<input
|
||||
type="radio"
|
||||
name="edit_public_access"
|
||||
value="public_edit"
|
||||
checked={publicAccess === "public_edit"}
|
||||
onChange={() => setPublicAccess("public_edit")}
|
||||
/>
|
||||
<span className="radio-label">
|
||||
<strong>Public – View & Edit</strong>
|
||||
<span className="radio-hint">
|
||||
Anyone with the link can view and edit
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
{saveError && <p className="form-error">{saveError}</p>}
|
||||
|
||||
<div className="modal-actions">
|
||||
<button type="button" className="btn-secondary" onClick={onClose}>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-primary"
|
||||
disabled={!name.trim() || saving}
|
||||
>
|
||||
{saving ? "Saving…" : "Save Changes"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* ── Permissions ── */}
|
||||
<section className="edit-section">
|
||||
<h3>Permissions</h3>
|
||||
|
||||
{permsLoading ? (
|
||||
<p className="edit-loading">Loading…</p>
|
||||
) : (
|
||||
<div className="perm-list">
|
||||
{permissions.map((p) => (
|
||||
<div key={p.user_id} className="perm-row">
|
||||
<span className="perm-username">{p.username}</span>
|
||||
<span className={`perm-role-badge role-${p.role}`}>
|
||||
{p.role}
|
||||
</span>
|
||||
{p.role !== "owner" && (
|
||||
<button
|
||||
className="perm-remove"
|
||||
onClick={() => handleRemovePermission(p.username)}
|
||||
title={`Remove ${p.username}`}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{nonOwnerPerms.length === 0 &&
|
||||
permissions.filter((p) => p.role === "owner").length > 0 && (
|
||||
<p className="perm-empty">No editors or viewers yet</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add user */}
|
||||
<form className="add-perm-form" onSubmit={handleAddPermission}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Username…"
|
||||
value={addUsername}
|
||||
onChange={(e) => setAddUsername(e.target.value)}
|
||||
className="add-perm-input"
|
||||
/>
|
||||
<select
|
||||
value={addRole}
|
||||
onChange={(e) =>
|
||||
setAddRole(e.target.value as "editor" | "viewer")
|
||||
}
|
||||
className="add-perm-role"
|
||||
>
|
||||
<option value="viewer">Viewer</option>
|
||||
<option value="editor">Editor</option>
|
||||
</select>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-primary btn-sm"
|
||||
disabled={!addUsername.trim() || addLoading}
|
||||
>
|
||||
{addLoading ? "…" : "Add"}
|
||||
</button>
|
||||
</form>
|
||||
{addError && (
|
||||
<p className="form-error" style={{ marginTop: "0.4rem" }}>
|
||||
{addError}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* ── Access Requests ── */}
|
||||
{!reqsLoading && requests.length > 0 && (
|
||||
<section className="edit-section">
|
||||
<h3>Pending Access Requests</h3>
|
||||
<div className="req-list">
|
||||
{requests.map((r) => (
|
||||
<div key={r.id} className="req-row">
|
||||
<span className="req-username">{r.username}</span>
|
||||
<span className={`perm-role-badge role-${r.requested_role}`}>
|
||||
{r.requested_role}
|
||||
</span>
|
||||
<div className="req-actions">
|
||||
<button
|
||||
className="btn-approve"
|
||||
onClick={() => handleResolveRequest(r.id, "approve")}
|
||||
>
|
||||
✓ Approve
|
||||
</button>
|
||||
<button
|
||||
className="btn-deny"
|
||||
onClick={() => handleResolveRequest(r.id, "deny")}
|
||||
>
|
||||
✕ Deny
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
54
ui/src/components/FloatingMapControls.css
Normal file
54
ui/src/components/FloatingMapControls.css
Normal file
@@ -0,0 +1,54 @@
|
||||
.floating-map-controls {
|
||||
position: absolute;
|
||||
top: 14px;
|
||||
left: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.fmc-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
background: rgba(17, 24, 39, 0.88);
|
||||
border: 1px solid #374151;
|
||||
border-radius: 6px;
|
||||
color: #d1d5db;
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
padding: 0.35rem 0.7rem;
|
||||
line-height: 1.4;
|
||||
white-space: nowrap;
|
||||
backdrop-filter: blur(6px);
|
||||
transition:
|
||||
background 0.12s,
|
||||
border-color 0.12s,
|
||||
color 0.12s;
|
||||
}
|
||||
|
||||
.fmc-btn:hover {
|
||||
background: rgba(55, 65, 81, 0.95);
|
||||
border-color: #6b7280;
|
||||
color: #f3f4f6;
|
||||
}
|
||||
|
||||
.fmc-btn-primary {
|
||||
background: rgba(99, 102, 241, 0.85);
|
||||
border-color: #6366f1;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.fmc-btn-primary:hover {
|
||||
background: rgba(79, 70, 229, 0.95);
|
||||
border-color: #4f46e5;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.fmc-btn-danger:hover {
|
||||
background: rgba(127, 29, 29, 0.9);
|
||||
border-color: #ef4444;
|
||||
color: #fca5a5;
|
||||
}
|
||||
75
ui/src/components/FloatingMapControls.tsx
Normal file
75
ui/src/components/FloatingMapControls.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import "./FloatingMapControls.css";
|
||||
|
||||
interface Props {
|
||||
isLoggedIn: boolean;
|
||||
hasSelectedMap: boolean;
|
||||
isOwner: boolean;
|
||||
onNewMap: () => void;
|
||||
onViewMaps: () => void;
|
||||
onEditMap: () => void;
|
||||
onDeleteMap: () => void;
|
||||
}
|
||||
|
||||
export default function FloatingMapControls({
|
||||
isLoggedIn,
|
||||
hasSelectedMap,
|
||||
isOwner,
|
||||
onNewMap,
|
||||
onViewMaps,
|
||||
onEditMap,
|
||||
onDeleteMap,
|
||||
}: Props) {
|
||||
if (!isLoggedIn) return null;
|
||||
|
||||
return (
|
||||
<div className="floating-map-controls">
|
||||
{/* Always visible for logged-in users */}
|
||||
<button className="fmc-btn" onClick={onViewMaps} title="View my maps">
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<rect x="3" y="3" width="7" height="7" />
|
||||
<rect x="14" y="3" width="7" height="7" />
|
||||
<rect x="14" y="14" width="7" height="7" />
|
||||
<rect x="3" y="14" width="7" height="7" />
|
||||
</svg>
|
||||
Maps
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="fmc-btn fmc-btn-primary"
|
||||
onClick={onNewMap}
|
||||
title="Create a new map"
|
||||
>
|
||||
+ New Map
|
||||
</button>
|
||||
|
||||
{/* Owner-only actions — only when a map is selected */}
|
||||
{hasSelectedMap && isOwner && (
|
||||
<>
|
||||
<button
|
||||
className="fmc-btn"
|
||||
onClick={onEditMap}
|
||||
title="Edit map settings"
|
||||
>
|
||||
Edit Map
|
||||
</button>
|
||||
<button
|
||||
className="fmc-btn fmc-btn-danger"
|
||||
onClick={onDeleteMap}
|
||||
title="Delete this map"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,21 @@
|
||||
import {
|
||||
useRef, useEffect, useCallback, useState,
|
||||
forwardRef, useImperativeHandle,
|
||||
} from 'react';
|
||||
import type { GridCell, GridToken, Tool, ServerMessage, ClientMessage } from '../types';
|
||||
import { useWebSocket } from '../hooks/useWebSocket';
|
||||
import TokenDialog from './TokenDialog';
|
||||
import './Grid.css';
|
||||
useRef,
|
||||
useEffect,
|
||||
useCallback,
|
||||
useState,
|
||||
forwardRef,
|
||||
useImperativeHandle,
|
||||
} from "react";
|
||||
import type {
|
||||
GridCell,
|
||||
GridToken,
|
||||
Tool,
|
||||
ServerMessage,
|
||||
ClientMessage,
|
||||
} from "../types";
|
||||
import { useWebSocket } from "../hooks/useWebSocket";
|
||||
import TokenDialog from "./TokenDialog";
|
||||
import "./Grid.css";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
@@ -16,9 +26,9 @@ const MIN_ZOOM = 8;
|
||||
const MAX_ZOOM = 160;
|
||||
const ZOOM_STEP = 1.12;
|
||||
|
||||
const BG_COLOR = '#111827';
|
||||
const GRID_COLOR = 'rgba(255,255,255,0.07)';
|
||||
const GRID_COLOR_MAJOR = 'rgba(255,255,255,0.16)';
|
||||
const BG_COLOR = "#111827";
|
||||
const GRID_COLOR = "rgba(255,255,255,0.07)";
|
||||
const GRID_COLOR_MAJOR = "rgba(255,255,255,0.16)";
|
||||
|
||||
/** BFS stops at this many cells; region is considered unbounded → paint only the clicked cell. */
|
||||
const MAX_FLOOD_CELLS = 2500;
|
||||
@@ -56,14 +66,22 @@ function cellKey(x: number, y: number): string {
|
||||
return `${x},${y}`;
|
||||
}
|
||||
|
||||
function canvasToCell(cx: number, cy: number, cam: Camera): { x: number; y: number } {
|
||||
function canvasToCell(
|
||||
cx: number,
|
||||
cy: number,
|
||||
cam: Camera,
|
||||
): { x: number; y: number } {
|
||||
return {
|
||||
x: Math.floor(cx / cam.zoom + cam.offsetX),
|
||||
y: Math.floor(cy / cam.zoom + cam.offsetY),
|
||||
};
|
||||
}
|
||||
|
||||
function cellToCanvas(cellX: number, cellY: number, cam: Camera): { x: number; y: number } {
|
||||
function cellToCanvas(
|
||||
cellX: number,
|
||||
cellY: number,
|
||||
cam: Camera,
|
||||
): { x: number; y: number } {
|
||||
return {
|
||||
x: (cellX - cam.offsetX) * cam.zoom,
|
||||
y: (cellY - cam.offsetY) * cam.zoom,
|
||||
@@ -84,7 +102,7 @@ function drawToken(
|
||||
const cy = py + zoom / 2;
|
||||
const r = zoom * 0.38;
|
||||
|
||||
ctx.shadowColor = 'rgba(0,0,0,0.6)';
|
||||
ctx.shadowColor = "rgba(0,0,0,0.6)";
|
||||
ctx.shadowBlur = 5;
|
||||
|
||||
ctx.beginPath();
|
||||
@@ -92,7 +110,7 @@ function drawToken(
|
||||
ctx.fillStyle = color;
|
||||
ctx.fill();
|
||||
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.55)';
|
||||
ctx.strokeStyle = "rgba(255,255,255,0.55)";
|
||||
ctx.lineWidth = Math.max(1, zoom * 0.04);
|
||||
ctx.stroke();
|
||||
|
||||
@@ -104,10 +122,10 @@ function drawToken(
|
||||
words.length >= 2
|
||||
? (words[0][0] + words[1][0]).toUpperCase()
|
||||
: label.slice(0, 2).toUpperCase();
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fillStyle = "#ffffff";
|
||||
ctx.font = `bold ${Math.round(zoom * 0.3)}px system-ui, sans-serif`;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.fillText(initials, cx, cy);
|
||||
}
|
||||
}
|
||||
@@ -133,8 +151,10 @@ function floodFill(
|
||||
visited.add(cellKey(startX, startY));
|
||||
|
||||
const dirs = [
|
||||
{ dx: 1, dy: 0 }, { dx: -1, dy: 0 },
|
||||
{ dx: 0, dy: 1 }, { dx: 0, dy: -1 },
|
||||
{ dx: 1, dy: 0 },
|
||||
{ dx: -1, dy: 0 },
|
||||
{ dx: 0, dy: 1 },
|
||||
{ dx: 0, dy: -1 },
|
||||
];
|
||||
|
||||
while (queue.length > 0) {
|
||||
@@ -170,9 +190,9 @@ function clampCameraToContent(
|
||||
) {
|
||||
if (cells.size === 0 && tokens.size === 0) return;
|
||||
|
||||
const viewLeft = cam.offsetX;
|
||||
const viewRight = cam.offsetX + canvasW / cam.zoom;
|
||||
const viewTop = cam.offsetY;
|
||||
const viewLeft = cam.offsetX;
|
||||
const viewRight = cam.offsetX + canvasW / cam.zoom;
|
||||
const viewTop = cam.offsetY;
|
||||
const viewBottom = cam.offsetY + canvasH / cam.zoom;
|
||||
|
||||
// Quick visibility check
|
||||
@@ -180,8 +200,10 @@ function clampCameraToContent(
|
||||
|
||||
cellLoop: for (const cell of cells.values()) {
|
||||
if (
|
||||
cell.x + 1 > viewLeft && cell.x < viewRight &&
|
||||
cell.y + 1 > viewTop && cell.y < viewBottom
|
||||
cell.x + 1 > viewLeft &&
|
||||
cell.x < viewRight &&
|
||||
cell.y + 1 > viewTop &&
|
||||
cell.y < viewBottom
|
||||
) {
|
||||
anyVisible = true;
|
||||
break cellLoop;
|
||||
@@ -191,8 +213,10 @@ function clampCameraToContent(
|
||||
if (!anyVisible) {
|
||||
for (const tok of tokens.values()) {
|
||||
if (
|
||||
tok.x + 1 > viewLeft && tok.x < viewRight &&
|
||||
tok.y + 1 > viewTop && tok.y < viewBottom
|
||||
tok.x + 1 > viewLeft &&
|
||||
tok.x < viewRight &&
|
||||
tok.y + 1 > viewTop &&
|
||||
tok.y < viewBottom
|
||||
) {
|
||||
anyVisible = true;
|
||||
break;
|
||||
@@ -203,8 +227,10 @@ function clampCameraToContent(
|
||||
if (anyVisible) return;
|
||||
|
||||
// Find the bounding box of all content
|
||||
let minX = Infinity, maxX = -Infinity;
|
||||
let minY = Infinity, maxY = -Infinity;
|
||||
let minX = Infinity,
|
||||
maxX = -Infinity;
|
||||
let minY = Infinity,
|
||||
maxY = -Infinity;
|
||||
|
||||
for (const c of cells.values()) {
|
||||
if (c.x < minX) minX = c.x;
|
||||
@@ -248,43 +274,54 @@ const Grid = forwardRef<GridHandle, Props>(function Grid(
|
||||
ref,
|
||||
) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
const cameraRef = useRef<Camera>({ offsetX: -2, offsetY: -2, zoom: DEFAULT_ZOOM });
|
||||
const cameraRef = useRef<Camera>({
|
||||
offsetX: -2,
|
||||
offsetY: -2,
|
||||
zoom: DEFAULT_ZOOM,
|
||||
});
|
||||
|
||||
const cellsRef = useRef<Map<string, GridCell>>(new Map());
|
||||
const cellsRef = useRef<Map<string, GridCell>>(new Map());
|
||||
const tokensRef = useRef<Map<string, GridToken>>(new Map());
|
||||
|
||||
const [tick, setTick] = useState(0);
|
||||
const redraw = useCallback(() => setTick(n => n + 1), []);
|
||||
const redraw = useCallback(() => setTick((n) => n + 1), []);
|
||||
|
||||
// ---- Mouse interaction state (refs to avoid stale closures) ----
|
||||
const isPanning = useRef(false);
|
||||
const panStart = useRef<{ mx: number; my: number; ox: number; oy: number } | null>(null);
|
||||
const isDrawing = useRef(false);
|
||||
const isErasing = useRef(false);
|
||||
const lastPainted = useRef<string | null>(null);
|
||||
const isDragging = useRef(false);
|
||||
const dragTokenId = useRef<string | null>(null);
|
||||
const dragCellPos = useRef<{ x: number; y: number } | null>(null);
|
||||
const isPanning = useRef(false);
|
||||
const panStart = useRef<{
|
||||
mx: number;
|
||||
my: number;
|
||||
ox: number;
|
||||
oy: number;
|
||||
} | null>(null);
|
||||
const isDrawing = useRef(false);
|
||||
const isErasing = useRef(false);
|
||||
const lastPainted = useRef<string | null>(null);
|
||||
const isDragging = useRef(false);
|
||||
const dragTokenId = useRef<string | null>(null);
|
||||
const dragCellPos = useRef<{ x: number; y: number } | null>(null);
|
||||
|
||||
// ---- WASD state ----
|
||||
const keysHeld = useRef<Set<string>>(new Set());
|
||||
const rafId = useRef<number | null>(null);
|
||||
const keysHeld = useRef<Set<string>>(new Set());
|
||||
const rafId = useRef<number | null>(null);
|
||||
const lastFrameTime = useRef<number | null>(null);
|
||||
|
||||
// ---- Stable send ref so handlers never go stale ----
|
||||
const sendRef = useRef<(msg: ClientMessage) => void>(() => {});
|
||||
|
||||
const [cursor, setCursor] = useState<string>('default');
|
||||
const [dialogPos, setDialogPos] = useState<{ x: number; y: number } | null>(null);
|
||||
const [cursor, setCursor] = useState<string>("default");
|
||||
const [dialogPos, setDialogPos] = useState<{ x: number; y: number } | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Imperative handle — lets App.tsx trigger a color WS update
|
||||
// -------------------------------------------------------------------------
|
||||
useImperativeHandle(ref, () => ({
|
||||
sendColorUpdate(colors: string[]) {
|
||||
sendRef.current({ type: 'update_colors', colors });
|
||||
sendRef.current({ type: "update_colors", colors });
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -293,11 +330,11 @@ const Grid = forwardRef<GridHandle, Props>(function Grid(
|
||||
// -------------------------------------------------------------------------
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
const canvas = canvasRef.current;
|
||||
const canvas = canvasRef.current;
|
||||
if (!container || !canvas) return;
|
||||
|
||||
const resize = () => {
|
||||
canvas.width = container.clientWidth;
|
||||
canvas.width = container.clientWidth;
|
||||
canvas.height = container.clientHeight;
|
||||
redraw();
|
||||
};
|
||||
@@ -313,77 +350,92 @@ const Grid = forwardRef<GridHandle, Props>(function Grid(
|
||||
// -------------------------------------------------------------------------
|
||||
// Keep a stable ref to the callback so handleMessage doesn't re-create
|
||||
const onColorsLoadedRef = useRef(onColorsLoaded);
|
||||
useEffect(() => { onColorsLoadedRef.current = onColorsLoaded; }, [onColorsLoaded]);
|
||||
useEffect(() => {
|
||||
onColorsLoadedRef.current = onColorsLoaded;
|
||||
}, [onColorsLoaded]);
|
||||
|
||||
const handleMessage = useCallback((msg: ServerMessage) => {
|
||||
switch (msg.type) {
|
||||
case 'state': {
|
||||
cellsRef.current.clear();
|
||||
tokensRef.current.clear();
|
||||
msg.cells.forEach(c => cellsRef.current.set(cellKey(c.x, c.y), c));
|
||||
msg.tokens.forEach(t => tokensRef.current.set(t.id, t));
|
||||
onColorsLoadedRef.current(msg.colors);
|
||||
redraw();
|
||||
break;
|
||||
}
|
||||
case 'cell_painted': {
|
||||
const key = cellKey(msg.x, msg.y);
|
||||
cellsRef.current.set(key, {
|
||||
map_id: mapId,
|
||||
x: msg.x, y: msg.y, color: msg.color,
|
||||
});
|
||||
redraw();
|
||||
break;
|
||||
}
|
||||
case 'cells_batch_painted': {
|
||||
msg.cells.forEach(c => {
|
||||
const key = cellKey(c.x, c.y);
|
||||
const handleMessage = useCallback(
|
||||
(msg: ServerMessage) => {
|
||||
switch (msg.type) {
|
||||
case "state": {
|
||||
cellsRef.current.clear();
|
||||
tokensRef.current.clear();
|
||||
msg.cells.forEach((c) => cellsRef.current.set(cellKey(c.x, c.y), c));
|
||||
msg.tokens.forEach((t) => tokensRef.current.set(t.id, t));
|
||||
onColorsLoadedRef.current(msg.colors);
|
||||
redraw();
|
||||
break;
|
||||
}
|
||||
case "cell_painted": {
|
||||
const key = cellKey(msg.x, msg.y);
|
||||
cellsRef.current.set(key, {
|
||||
map_id: mapId,
|
||||
x: c.x, y: c.y, color: c.color,
|
||||
x: msg.x,
|
||||
y: msg.y,
|
||||
color: msg.color,
|
||||
});
|
||||
});
|
||||
redraw();
|
||||
break;
|
||||
}
|
||||
case 'cell_erased': {
|
||||
cellsRef.current.delete(cellKey(msg.x, msg.y));
|
||||
redraw();
|
||||
break;
|
||||
}
|
||||
case 'token_added': {
|
||||
tokensRef.current.set(msg.id, {
|
||||
id: msg.id, map_id: mapId,
|
||||
x: msg.x, y: msg.y, label: msg.label, color: msg.color,
|
||||
});
|
||||
redraw();
|
||||
break;
|
||||
}
|
||||
case 'token_moved': {
|
||||
const tok = tokensRef.current.get(msg.id);
|
||||
if (tok) {
|
||||
tokensRef.current.set(msg.id, { ...tok, x: msg.x, y: msg.y });
|
||||
redraw();
|
||||
break;
|
||||
}
|
||||
break;
|
||||
case "cells_batch_painted": {
|
||||
msg.cells.forEach((c) => {
|
||||
const key = cellKey(c.x, c.y);
|
||||
cellsRef.current.set(key, {
|
||||
map_id: mapId,
|
||||
x: c.x,
|
||||
y: c.y,
|
||||
color: c.color,
|
||||
});
|
||||
});
|
||||
redraw();
|
||||
break;
|
||||
}
|
||||
case "cell_erased": {
|
||||
cellsRef.current.delete(cellKey(msg.x, msg.y));
|
||||
redraw();
|
||||
break;
|
||||
}
|
||||
case "token_added": {
|
||||
tokensRef.current.set(msg.id, {
|
||||
id: msg.id,
|
||||
map_id: mapId,
|
||||
x: msg.x,
|
||||
y: msg.y,
|
||||
label: msg.label,
|
||||
color: msg.color,
|
||||
});
|
||||
redraw();
|
||||
break;
|
||||
}
|
||||
case "token_moved": {
|
||||
const tok = tokensRef.current.get(msg.id);
|
||||
if (tok) {
|
||||
tokensRef.current.set(msg.id, { ...tok, x: msg.x, y: msg.y });
|
||||
redraw();
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "token_deleted": {
|
||||
tokensRef.current.delete(msg.id);
|
||||
redraw();
|
||||
break;
|
||||
}
|
||||
case "colors_updated": {
|
||||
onColorsLoadedRef.current(msg.colors);
|
||||
break;
|
||||
}
|
||||
case "error":
|
||||
console.error("[Grid WS]", msg.message);
|
||||
break;
|
||||
}
|
||||
case 'token_deleted': {
|
||||
tokensRef.current.delete(msg.id);
|
||||
redraw();
|
||||
break;
|
||||
}
|
||||
case 'colors_updated': {
|
||||
onColorsLoadedRef.current(msg.colors);
|
||||
break;
|
||||
}
|
||||
case 'error':
|
||||
console.error('[Grid WS]', msg.message);
|
||||
break;
|
||||
}
|
||||
}, [mapId, redraw]);
|
||||
},
|
||||
[mapId, redraw],
|
||||
);
|
||||
|
||||
const { send } = useWebSocket(mapId, handleMessage);
|
||||
useEffect(() => { sendRef.current = send; }, [send]);
|
||||
useEffect(() => {
|
||||
sendRef.current = send;
|
||||
}, [send]);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Canvas draw
|
||||
@@ -391,11 +443,11 @@ const Grid = forwardRef<GridHandle, Props>(function Grid(
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
const W = canvas.width;
|
||||
const H = canvas.height;
|
||||
const W = canvas.width;
|
||||
const H = canvas.height;
|
||||
const cam = cameraRef.current;
|
||||
const { offsetX, offsetY, zoom } = cam;
|
||||
|
||||
@@ -403,13 +455,19 @@ const Grid = forwardRef<GridHandle, Props>(function Grid(
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
|
||||
const startCX = Math.floor(offsetX) - 1;
|
||||
const endCX = Math.ceil(offsetX + W / zoom) + 1;
|
||||
const endCX = Math.ceil(offsetX + W / zoom) + 1;
|
||||
const startCY = Math.floor(offsetY) - 1;
|
||||
const endCY = Math.ceil(offsetY + H / zoom) + 1;
|
||||
const endCY = Math.ceil(offsetY + H / zoom) + 1;
|
||||
|
||||
// Painted cells
|
||||
cellsRef.current.forEach(cell => {
|
||||
if (cell.x < startCX || cell.x > endCX || cell.y < startCY || cell.y > endCY) return;
|
||||
cellsRef.current.forEach((cell) => {
|
||||
if (
|
||||
cell.x < startCX ||
|
||||
cell.x > endCX ||
|
||||
cell.y < startCY ||
|
||||
cell.y > endCY
|
||||
)
|
||||
return;
|
||||
const { x: px, y: py } = cellToCanvas(cell.x, cell.y, cam);
|
||||
ctx.fillStyle = cell.color;
|
||||
ctx.fillRect(px, py, zoom, zoom);
|
||||
@@ -437,7 +495,7 @@ const Grid = forwardRef<GridHandle, Props>(function Grid(
|
||||
}
|
||||
|
||||
// Tokens (skip the one being dragged)
|
||||
tokensRef.current.forEach(token => {
|
||||
tokensRef.current.forEach((token) => {
|
||||
if (isDragging.current && dragTokenId.current === token.id) return;
|
||||
drawToken(ctx, token.x, token.y, token.label, token.color, cam);
|
||||
});
|
||||
@@ -447,7 +505,14 @@ const Grid = forwardRef<GridHandle, Props>(function Grid(
|
||||
const tok = tokensRef.current.get(dragTokenId.current);
|
||||
if (tok) {
|
||||
ctx.globalAlpha = 0.6;
|
||||
drawToken(ctx, dragCellPos.current.x, dragCellPos.current.y, tok.label, tok.color, cam);
|
||||
drawToken(
|
||||
ctx,
|
||||
dragCellPos.current.x,
|
||||
dragCellPos.current.y,
|
||||
tok.label,
|
||||
tok.color,
|
||||
cam,
|
||||
);
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
}
|
||||
@@ -460,21 +525,24 @@ const Grid = forwardRef<GridHandle, Props>(function Grid(
|
||||
const canvas = canvasRef.current;
|
||||
if (canvas) {
|
||||
clampCameraToContent(
|
||||
cameraRef.current, cellsRef.current, tokensRef.current,
|
||||
canvas.width, canvas.height,
|
||||
cameraRef.current,
|
||||
cellsRef.current,
|
||||
tokensRef.current,
|
||||
canvas.width,
|
||||
canvas.height,
|
||||
);
|
||||
}
|
||||
redraw();
|
||||
}
|
||||
|
||||
function applyZoom(canvasX: number, canvasY: number, factor: number) {
|
||||
const cam = cameraRef.current;
|
||||
const cam = cameraRef.current;
|
||||
const worldX = canvasX / cam.zoom + cam.offsetX;
|
||||
const worldY = canvasY / cam.zoom + cam.offsetY;
|
||||
const newZoom = clamp(cam.zoom * factor, MIN_ZOOM, MAX_ZOOM);
|
||||
cam.offsetX = worldX - canvasX / newZoom;
|
||||
cam.offsetY = worldY - canvasY / newZoom;
|
||||
cam.zoom = newZoom;
|
||||
cam.zoom = newZoom;
|
||||
applyClampAndRedraw();
|
||||
}
|
||||
|
||||
@@ -486,14 +554,14 @@ const Grid = forwardRef<GridHandle, Props>(function Grid(
|
||||
if (!canvas) return;
|
||||
const onWheel = (e: WheelEvent) => {
|
||||
e.preventDefault();
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const cx = e.clientX - rect.left;
|
||||
const cy = e.clientY - rect.top;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const cx = e.clientX - rect.left;
|
||||
const cy = e.clientY - rect.top;
|
||||
const factor = e.deltaY < 0 ? ZOOM_STEP : 1 / ZOOM_STEP;
|
||||
applyZoom(cx, cy, factor);
|
||||
};
|
||||
canvas.addEventListener('wheel', onWheel, { passive: false });
|
||||
return () => canvas.removeEventListener('wheel', onWheel);
|
||||
canvas.addEventListener("wheel", onWheel, { passive: false });
|
||||
return () => canvas.removeEventListener("wheel", onWheel);
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -508,34 +576,45 @@ const Grid = forwardRef<GridHandle, Props>(function Grid(
|
||||
return;
|
||||
}
|
||||
|
||||
const dt = lastFrameTime.current !== null
|
||||
? (timestamp - lastFrameTime.current) / 1000
|
||||
: 0;
|
||||
const dt =
|
||||
lastFrameTime.current !== null
|
||||
? (timestamp - lastFrameTime.current) / 1000
|
||||
: 0;
|
||||
lastFrameTime.current = timestamp;
|
||||
|
||||
const cam = cameraRef.current;
|
||||
const cam = cameraRef.current;
|
||||
const speed = WASD_PAN_SPEED;
|
||||
|
||||
if (keys.has('a')) cam.offsetX -= speed * dt;
|
||||
if (keys.has('d')) cam.offsetX += speed * dt;
|
||||
if (keys.has('w')) cam.offsetY -= speed * dt;
|
||||
if (keys.has('s')) cam.offsetY += speed * dt;
|
||||
if (keys.has("a")) cam.offsetX -= speed * dt;
|
||||
if (keys.has("d")) cam.offsetX += speed * dt;
|
||||
if (keys.has("w")) cam.offsetY -= speed * dt;
|
||||
if (keys.has("s")) cam.offsetY += speed * dt;
|
||||
|
||||
const canvas = canvasRef.current;
|
||||
if (canvas) {
|
||||
clampCameraToContent(cam, cellsRef.current, tokensRef.current, canvas.width, canvas.height);
|
||||
clampCameraToContent(
|
||||
cam,
|
||||
cellsRef.current,
|
||||
tokensRef.current,
|
||||
canvas.width,
|
||||
canvas.height,
|
||||
);
|
||||
}
|
||||
setTick(n => n + 1);
|
||||
setTick((n) => n + 1);
|
||||
|
||||
rafId.current = requestAnimationFrame(rafTick);
|
||||
}
|
||||
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
|
||||
if (
|
||||
e.target instanceof HTMLInputElement ||
|
||||
e.target instanceof HTMLTextAreaElement
|
||||
)
|
||||
return;
|
||||
// Don't intercept WASD when modifier keys are held (e.g. Shift+keys are for tool shortcuts)
|
||||
if (e.shiftKey || e.ctrlKey || e.metaKey || e.altKey) return;
|
||||
const key = e.key.toLowerCase();
|
||||
if (['w', 'a', 's', 'd'].includes(key)) {
|
||||
if (["w", "a", "s", "d"].includes(key)) {
|
||||
e.preventDefault();
|
||||
keysHeld.current.add(key);
|
||||
if (rafId.current === null) {
|
||||
@@ -549,11 +628,11 @@ const Grid = forwardRef<GridHandle, Props>(function Grid(
|
||||
keysHeld.current.delete(e.key.toLowerCase());
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
window.addEventListener('keyup', onKeyUp);
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
window.addEventListener("keyup", onKeyUp);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onKeyDown);
|
||||
window.removeEventListener('keyup', onKeyUp);
|
||||
window.removeEventListener("keydown", onKeyDown);
|
||||
window.removeEventListener("keyup", onKeyUp);
|
||||
if (rafId.current !== null) {
|
||||
cancelAnimationFrame(rafId.current);
|
||||
rafId.current = null;
|
||||
@@ -585,22 +664,27 @@ const Grid = forwardRef<GridHandle, Props>(function Grid(
|
||||
const cell = canvasToCell(mx, my, cameraRef.current);
|
||||
|
||||
// ---- Pan tool ----
|
||||
if (tool === 'pan' && e.button === 0) {
|
||||
if (tool === "pan" && e.button === 0) {
|
||||
isPanning.current = true;
|
||||
panStart.current = { mx, my, ox: cameraRef.current.offsetX, oy: cameraRef.current.offsetY };
|
||||
setCursor('grabbing');
|
||||
panStart.current = {
|
||||
mx,
|
||||
my,
|
||||
ox: cameraRef.current.offsetX,
|
||||
oy: cameraRef.current.offsetY,
|
||||
};
|
||||
setCursor("grabbing");
|
||||
return;
|
||||
}
|
||||
|
||||
// ---- Zoom tool ----
|
||||
if (tool === 'zoom') {
|
||||
if (e.button === 0) applyZoom(mx, my, ZOOM_STEP * ZOOM_STEP);
|
||||
if (tool === "zoom") {
|
||||
if (e.button === 0) applyZoom(mx, my, ZOOM_STEP * ZOOM_STEP);
|
||||
else if (e.button === 2) applyZoom(mx, my, 1 / (ZOOM_STEP * ZOOM_STEP));
|
||||
return;
|
||||
}
|
||||
|
||||
// ---- Draw tool ----
|
||||
if (tool === 'draw') {
|
||||
if (tool === "draw") {
|
||||
if (e.button === 0) {
|
||||
if (e.shiftKey) {
|
||||
// Shift+click → flood fill uncolored region
|
||||
@@ -609,42 +693,52 @@ const Grid = forwardRef<GridHandle, Props>(function Grid(
|
||||
const region = floodFill(cell.x, cell.y, cellsRef.current);
|
||||
if (region === null || region.length === 1) {
|
||||
// Unbounded or trivially single cell → paint one cell
|
||||
sendRef.current({ type: 'paint_cell', x: cell.x, y: cell.y, color: paintColor });
|
||||
sendRef.current({
|
||||
type: "paint_cell",
|
||||
x: cell.x,
|
||||
y: cell.y,
|
||||
color: paintColor,
|
||||
});
|
||||
} else {
|
||||
// Bounded enclosed region → batch paint
|
||||
sendRef.current({
|
||||
type: 'paint_cells',
|
||||
type: "paint_cells",
|
||||
cells: region.map(({ x, y }) => ({ x, y, color: paintColor })),
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
isDrawing.current = true;
|
||||
isDrawing.current = true;
|
||||
lastPainted.current = cellKey(cell.x, cell.y);
|
||||
sendRef.current({ type: 'paint_cell', x: cell.x, y: cell.y, color: paintColor });
|
||||
sendRef.current({
|
||||
type: "paint_cell",
|
||||
x: cell.x,
|
||||
y: cell.y,
|
||||
color: paintColor,
|
||||
});
|
||||
}
|
||||
} else if (e.button === 2) {
|
||||
isErasing.current = true;
|
||||
const key = cellKey(cell.x, cell.y);
|
||||
isErasing.current = true;
|
||||
const key = cellKey(cell.x, cell.y);
|
||||
lastPainted.current = key;
|
||||
if (cellsRef.current.has(key)) {
|
||||
sendRef.current({ type: 'erase_cell', x: cell.x, y: cell.y });
|
||||
sendRef.current({ type: "erase_cell", x: cell.x, y: cell.y });
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// ---- Token tool ----
|
||||
if (tool === 'token') {
|
||||
if (tool === "token") {
|
||||
if (e.button === 2) {
|
||||
const tok = tokenAtCell(cell.x, cell.y);
|
||||
if (tok) sendRef.current({ type: 'delete_token', id: tok.id });
|
||||
if (tok) sendRef.current({ type: "delete_token", id: tok.id });
|
||||
return;
|
||||
}
|
||||
if (e.button === 0) {
|
||||
const tok = tokenAtCell(cell.x, cell.y);
|
||||
if (tok) {
|
||||
isDragging.current = true;
|
||||
isDragging.current = true;
|
||||
dragTokenId.current = tok.id;
|
||||
dragCellPos.current = { x: cell.x, y: cell.y };
|
||||
redraw();
|
||||
@@ -661,8 +755,8 @@ const Grid = forwardRef<GridHandle, Props>(function Grid(
|
||||
// Pan
|
||||
if (isPanning.current && panStart.current) {
|
||||
const cam = cameraRef.current;
|
||||
const dx = (mx - panStart.current.mx) / cam.zoom;
|
||||
const dy = (my - panStart.current.my) / cam.zoom;
|
||||
const dx = (mx - panStart.current.mx) / cam.zoom;
|
||||
const dy = (my - panStart.current.my) / cam.zoom;
|
||||
cam.offsetX = panStart.current.ox - dx;
|
||||
cam.offsetY = panStart.current.oy - dy;
|
||||
applyClampAndRedraw();
|
||||
@@ -672,13 +766,18 @@ const Grid = forwardRef<GridHandle, Props>(function Grid(
|
||||
// Draw / erase stroke
|
||||
if (isDrawing.current || isErasing.current) {
|
||||
const cell = canvasToCell(mx, my, cameraRef.current);
|
||||
const key = cellKey(cell.x, cell.y);
|
||||
const key = cellKey(cell.x, cell.y);
|
||||
if (lastPainted.current !== key) {
|
||||
lastPainted.current = key;
|
||||
if (isDrawing.current) {
|
||||
sendRef.current({ type: 'paint_cell', x: cell.x, y: cell.y, color: paintColor });
|
||||
sendRef.current({
|
||||
type: "paint_cell",
|
||||
x: cell.x,
|
||||
y: cell.y,
|
||||
color: paintColor,
|
||||
});
|
||||
} else if (isErasing.current && cellsRef.current.has(key)) {
|
||||
sendRef.current({ type: 'erase_cell', x: cell.x, y: cell.y });
|
||||
sendRef.current({ type: "erase_cell", x: cell.x, y: cell.y });
|
||||
}
|
||||
}
|
||||
return;
|
||||
@@ -687,7 +786,10 @@ const Grid = forwardRef<GridHandle, Props>(function Grid(
|
||||
// Token drag
|
||||
if (isDragging.current && dragCellPos.current) {
|
||||
const cell = canvasToCell(mx, my, cameraRef.current);
|
||||
if (dragCellPos.current.x !== cell.x || dragCellPos.current.y !== cell.y) {
|
||||
if (
|
||||
dragCellPos.current.x !== cell.x ||
|
||||
dragCellPos.current.y !== cell.y
|
||||
) {
|
||||
dragCellPos.current = { x: cell.x, y: cell.y };
|
||||
redraw();
|
||||
}
|
||||
@@ -697,29 +799,32 @@ const Grid = forwardRef<GridHandle, Props>(function Grid(
|
||||
function handleMouseUp(_e: React.MouseEvent) {
|
||||
if (isPanning.current) {
|
||||
isPanning.current = false;
|
||||
panStart.current = null;
|
||||
setCursor('grab');
|
||||
panStart.current = null;
|
||||
setCursor("grab");
|
||||
return;
|
||||
}
|
||||
|
||||
if (isDrawing.current || isErasing.current) {
|
||||
isDrawing.current = false;
|
||||
isErasing.current = false;
|
||||
isDrawing.current = false;
|
||||
isErasing.current = false;
|
||||
lastPainted.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (isDragging.current && dragTokenId.current && dragCellPos.current) {
|
||||
const tok = tokensRef.current.get(dragTokenId.current);
|
||||
if (tok && (tok.x !== dragCellPos.current.x || tok.y !== dragCellPos.current.y)) {
|
||||
if (
|
||||
tok &&
|
||||
(tok.x !== dragCellPos.current.x || tok.y !== dragCellPos.current.y)
|
||||
) {
|
||||
sendRef.current({
|
||||
type: 'move_token',
|
||||
type: "move_token",
|
||||
id: dragTokenId.current,
|
||||
x: dragCellPos.current.x,
|
||||
y: dragCellPos.current.y,
|
||||
});
|
||||
}
|
||||
isDragging.current = false;
|
||||
isDragging.current = false;
|
||||
dragTokenId.current = null;
|
||||
dragCellPos.current = null;
|
||||
redraw();
|
||||
@@ -727,13 +832,13 @@ const Grid = forwardRef<GridHandle, Props>(function Grid(
|
||||
}
|
||||
|
||||
function handleMouseLeave() {
|
||||
isPanning.current = false;
|
||||
panStart.current = null;
|
||||
isDrawing.current = false;
|
||||
isErasing.current = false;
|
||||
isPanning.current = false;
|
||||
panStart.current = null;
|
||||
isDrawing.current = false;
|
||||
isErasing.current = false;
|
||||
lastPainted.current = null;
|
||||
if (isDragging.current) {
|
||||
isDragging.current = false;
|
||||
isDragging.current = false;
|
||||
dragTokenId.current = null;
|
||||
dragCellPos.current = null;
|
||||
redraw();
|
||||
@@ -743,16 +848,30 @@ const Grid = forwardRef<GridHandle, Props>(function Grid(
|
||||
// Sync cursor CSS to active tool
|
||||
useEffect(() => {
|
||||
switch (tool) {
|
||||
case 'pan': setCursor('grab'); break;
|
||||
case 'zoom': setCursor('zoom-in'); break;
|
||||
case 'draw': setCursor('crosshair'); break;
|
||||
case 'token': setCursor('crosshair'); break;
|
||||
case "pan":
|
||||
setCursor("grab");
|
||||
break;
|
||||
case "zoom":
|
||||
setCursor("zoom-in");
|
||||
break;
|
||||
case "draw":
|
||||
setCursor("crosshair");
|
||||
break;
|
||||
case "token":
|
||||
setCursor("crosshair");
|
||||
break;
|
||||
}
|
||||
}, [tool]);
|
||||
|
||||
function handleAddToken(label: string, color: string) {
|
||||
if (!dialogPos) return;
|
||||
sendRef.current({ type: 'add_token', x: dialogPos.x, y: dialogPos.y, label, color });
|
||||
sendRef.current({
|
||||
type: "add_token",
|
||||
x: dialogPos.x,
|
||||
y: dialogPos.y,
|
||||
label,
|
||||
color,
|
||||
});
|
||||
setDialogPos(null);
|
||||
}
|
||||
|
||||
@@ -766,7 +885,7 @@ const Grid = forwardRef<GridHandle, Props>(function Grid(
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onContextMenu={e => e.preventDefault()}
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
/>
|
||||
{dialogPos && (
|
||||
<TokenDialog
|
||||
|
||||
48
ui/src/components/Header.css
Normal file
48
ui/src/components/Header.css
Normal file
@@ -0,0 +1,48 @@
|
||||
.app-header {
|
||||
flex-shrink: 0;
|
||||
height: 48px;
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 0 1rem;
|
||||
background: #1f2937;
|
||||
border-bottom: 1px solid #374151;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.app-brand {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 700;
|
||||
color: #f9fafb;
|
||||
letter-spacing: 0.08em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.app-brand span {
|
||||
color: #818cf8;
|
||||
}
|
||||
|
||||
.app-header-center {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.header-map-name {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: #e5e7eb;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.app-auth {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
51
ui/src/components/Header.tsx
Normal file
51
ui/src/components/Header.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import type { UserInfo } from "../types";
|
||||
import LoginButton from "./LoginButton";
|
||||
import "./Header.css";
|
||||
|
||||
interface Props {
|
||||
user: UserInfo | null;
|
||||
authLoading: boolean;
|
||||
selectedMapName: string | null;
|
||||
onLoginClick: () => void;
|
||||
onAccountClick: () => void;
|
||||
}
|
||||
|
||||
export default function Header({
|
||||
user,
|
||||
authLoading,
|
||||
selectedMapName,
|
||||
onLoginClick,
|
||||
onAccountClick,
|
||||
}: Props) {
|
||||
/** Display name: first name if set, otherwise username */
|
||||
const displayName = user ? user.first_name?.trim() || user.username : null;
|
||||
|
||||
return (
|
||||
<header className="app-header">
|
||||
<div className="app-brand">
|
||||
<span>SIREN</span>
|
||||
</div>
|
||||
|
||||
<div className="app-header-center">
|
||||
{selectedMapName && (
|
||||
<span className="header-map-name">{selectedMapName}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="app-auth">
|
||||
{!authLoading &&
|
||||
(user ? (
|
||||
<button
|
||||
className="header-btn"
|
||||
onClick={onAccountClick}
|
||||
title="Account settings"
|
||||
>
|
||||
{displayName}
|
||||
</button>
|
||||
) : (
|
||||
<LoginButton className="header-btn" onClick={onLoginClick} />
|
||||
))}
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -1,21 +1,13 @@
|
||||
import { auth } from '../api';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export default function LoginButton({ className }: Props) {
|
||||
async function handleLogin() {
|
||||
try {
|
||||
await auth.login();
|
||||
} catch (err) {
|
||||
console.error('Failed to initiate login:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/** A simple button that opens the login modal when clicked. */
|
||||
export default function LoginButton({ className, onClick }: Props) {
|
||||
return (
|
||||
<button className={className} onClick={handleLogin}>
|
||||
Log in with Discord
|
||||
<button className={className} onClick={onClick}>
|
||||
Log In / Register
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
159
ui/src/components/LoginModal.css
Normal file
159
ui/src/components/LoginModal.css
Normal file
@@ -0,0 +1,159 @@
|
||||
/* ── Backdrop ── */
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
/* ── Modal card ── */
|
||||
.modal {
|
||||
background: #1e2130;
|
||||
border: 1px solid #2e3348;
|
||||
border-radius: 10px;
|
||||
padding: 2rem;
|
||||
width: 360px;
|
||||
max-width: 90vw;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
position: absolute;
|
||||
top: 0.75rem;
|
||||
right: 0.75rem;
|
||||
background: none;
|
||||
border: none;
|
||||
color: #8892a4;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
padding: 0.25rem;
|
||||
}
|
||||
.modal-close:hover {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
/* ── Tabs ── */
|
||||
.modal-tabs {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
border-bottom: 1px solid #2e3348;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.modal-tab {
|
||||
flex: 1;
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
color: #8892a4;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
padding: 0.5rem;
|
||||
transition:
|
||||
color 0.15s,
|
||||
border-color 0.15s;
|
||||
}
|
||||
.modal-tab.active {
|
||||
color: #e2e8f0;
|
||||
border-bottom-color: #5865f2;
|
||||
}
|
||||
.modal-tab:hover:not(.active) {
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
/* ── Form ── */
|
||||
.modal-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.modal-form label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
color: #8892a4;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.modal-form input {
|
||||
background: #141622;
|
||||
border: 1px solid #2e3348;
|
||||
border-radius: 6px;
|
||||
color: #e2e8f0;
|
||||
font-size: 0.9rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
outline: none;
|
||||
}
|
||||
.modal-form input:focus {
|
||||
border-color: #5865f2;
|
||||
}
|
||||
|
||||
.modal-error {
|
||||
color: #f87171;
|
||||
font-size: 0.8rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ── Buttons ── */
|
||||
.btn-primary {
|
||||
background: #5865f2;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
padding: 0.6rem;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: #4752c4;
|
||||
}
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* ── Divider ── */
|
||||
.modal-divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
color: #4a5568;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.modal-divider::before,
|
||||
.modal-divider::after {
|
||||
content: "";
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: #2e3348;
|
||||
}
|
||||
|
||||
/* ── Discord button ── */
|
||||
.btn-discord {
|
||||
align-items: center;
|
||||
background: #5865f2;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
gap: 0.5rem;
|
||||
justify-content: center;
|
||||
padding: 0.6rem;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.btn-discord:hover {
|
||||
background: #4752c4;
|
||||
}
|
||||
162
ui/src/components/LoginModal.tsx
Normal file
162
ui/src/components/LoginModal.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
import { useState } from "react";
|
||||
import { auth } from "../api";
|
||||
import type { UserInfo } from "../types";
|
||||
import "./LoginModal.css";
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
onLogin: (user: UserInfo) => void;
|
||||
}
|
||||
|
||||
type Tab = "login" | "register";
|
||||
|
||||
export default function LoginModal({ onClose, onLogin }: Props) {
|
||||
const [tab, setTab] = useState<Tab>("login");
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirm, setConfirm] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
|
||||
if (tab === "register" && password !== confirm) {
|
||||
setError("Passwords do not match");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
if (tab === "login") {
|
||||
await auth.loginLocal(username, password);
|
||||
} else {
|
||||
await auth.register(username, password);
|
||||
}
|
||||
// Cookie is now set server-side; fetch user info to update parent
|
||||
const user = await auth.me();
|
||||
if (user) {
|
||||
onLogin(user);
|
||||
onClose();
|
||||
} else {
|
||||
setError("Login succeeded but could not load user info.");
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
// Extract the human-readable part (strip leading status code)
|
||||
setError(
|
||||
msg
|
||||
.replace(/^\d+:\s*/, "")
|
||||
.replace(/\{.*\}/s, "")
|
||||
.trim() || "Authentication failed",
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDiscordLogin() {
|
||||
try {
|
||||
await auth.loginDiscord(window.location.origin + "/map");
|
||||
} catch (err) {
|
||||
console.error("Discord login failed:", err);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="modal-backdrop" onClick={onClose}>
|
||||
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
||||
<button className="modal-close" onClick={onClose} aria-label="Close">
|
||||
✕
|
||||
</button>
|
||||
|
||||
{/* Tab switcher */}
|
||||
<div className="modal-tabs">
|
||||
<button
|
||||
className={`modal-tab ${tab === "login" ? "active" : ""}`}
|
||||
onClick={() => {
|
||||
setTab("login");
|
||||
setError(null);
|
||||
}}
|
||||
>
|
||||
Log In
|
||||
</button>
|
||||
<button
|
||||
className={`modal-tab ${tab === "register" ? "active" : ""}`}
|
||||
onClick={() => {
|
||||
setTab("register");
|
||||
setError(null);
|
||||
}}
|
||||
>
|
||||
Register
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Username / password form */}
|
||||
<form className="modal-form" onSubmit={handleSubmit}>
|
||||
<label>
|
||||
Username
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
autoComplete="username"
|
||||
required
|
||||
minLength={1}
|
||||
maxLength={32}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Password
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete={
|
||||
tab === "login" ? "current-password" : "new-password"
|
||||
}
|
||||
required
|
||||
minLength={8}
|
||||
/>
|
||||
</label>
|
||||
{tab === "register" && (
|
||||
<label>
|
||||
Confirm Password
|
||||
<input
|
||||
type="password"
|
||||
value={confirm}
|
||||
onChange={(e) => setConfirm(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
required
|
||||
minLength={8}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{error && <p className="modal-error">{error}</p>}
|
||||
|
||||
<button type="submit" className="btn-primary" disabled={loading}>
|
||||
{loading
|
||||
? "Loading…"
|
||||
: tab === "login"
|
||||
? "Log In"
|
||||
: "Create Account"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="modal-divider">
|
||||
<span>or</span>
|
||||
</div>
|
||||
|
||||
{/* Discord OAuth */}
|
||||
<button className="btn-discord" onClick={handleDiscordLogin}>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03z" />
|
||||
</svg>
|
||||
Log In with Discord
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
.map-list {
|
||||
width: 220px;
|
||||
flex-shrink: 0;
|
||||
background: #1f2937;
|
||||
border-right: 1px solid #374151;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.map-list-header {
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid #374151;
|
||||
}
|
||||
|
||||
.map-list-header h2 {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
color: #f3f4f6;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.map-create-form {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
padding: 0.75rem;
|
||||
border-bottom: 1px solid #374151;
|
||||
}
|
||||
|
||||
.map-create-form input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
background: #111827;
|
||||
border: 1px solid #4b5563;
|
||||
border-radius: 4px;
|
||||
color: #e5e7eb;
|
||||
padding: 0.35rem 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.map-create-form input:focus {
|
||||
border-color: #6366f1;
|
||||
}
|
||||
|
||||
.map-create-form button {
|
||||
background: #6366f1;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
padding: 0.35rem 0.6rem;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.map-create-form button:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.map-entries {
|
||||
list-style: none;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
|
||||
.map-empty {
|
||||
padding: 1rem;
|
||||
color: #6b7280;
|
||||
font-size: 0.85rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.map-entry {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
border-radius: 4px;
|
||||
margin: 0.1rem 0.25rem;
|
||||
}
|
||||
|
||||
.map-entry:hover {
|
||||
background: #374151;
|
||||
}
|
||||
|
||||
.map-entry.selected {
|
||||
background: #4338ca;
|
||||
}
|
||||
|
||||
.map-name {
|
||||
flex: 1;
|
||||
font-size: 0.875rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.map-dims {
|
||||
font-size: 0.7rem;
|
||||
color: #9ca3af;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.map-entry.selected .map-dims {
|
||||
color: #c7d2fe;
|
||||
}
|
||||
|
||||
.map-delete {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0.1rem;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.map-entry:hover .map-delete {
|
||||
opacity: 1;
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import type { GridMap } from '../types';
|
||||
import './MapList.css';
|
||||
|
||||
interface Props {
|
||||
maps: GridMap[];
|
||||
selectedMapId: string | null;
|
||||
onSelect: (id: string) => void;
|
||||
onCreate: (name: string) => void;
|
||||
onDelete: (id: string) => void;
|
||||
}
|
||||
|
||||
export default function MapList({ maps, selectedMapId, onSelect, onCreate, onDelete }: Props) {
|
||||
const [newName, setNewName] = useState('');
|
||||
|
||||
function handleCreate(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
const name = newName.trim();
|
||||
if (!name) return;
|
||||
onCreate(name);
|
||||
setNewName('');
|
||||
}
|
||||
|
||||
function handleDeleteClick(e: React.MouseEvent, id: string) {
|
||||
e.stopPropagation();
|
||||
if (confirm('Delete this map? This cannot be undone.')) {
|
||||
onDelete(id);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="map-list">
|
||||
<div className="map-list-header">
|
||||
<h2>Maps</h2>
|
||||
</div>
|
||||
|
||||
<form className="map-create-form" onSubmit={handleCreate}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="New map name…"
|
||||
value={newName}
|
||||
onChange={e => setNewName(e.target.value)}
|
||||
/>
|
||||
<button type="submit" disabled={!newName.trim()}>+</button>
|
||||
</form>
|
||||
|
||||
<ul className="map-entries">
|
||||
{maps.length === 0 && (
|
||||
<li className="map-empty">No maps yet</li>
|
||||
)}
|
||||
{maps.map(map => (
|
||||
<li
|
||||
key={map.id}
|
||||
className={`map-entry ${map.id === selectedMapId ? 'selected' : ''}`}
|
||||
onClick={() => onSelect(map.id)}
|
||||
>
|
||||
<span className="map-name">{map.name}</span>
|
||||
<button
|
||||
className="map-delete"
|
||||
onClick={e => handleDeleteClick(e, map.id)}
|
||||
title="Delete map"
|
||||
>
|
||||
Delete️
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
183
ui/src/components/MapListModal.css
Normal file
183
ui/src/components/MapListModal.css
Normal file
@@ -0,0 +1,183 @@
|
||||
.map-list-modal {
|
||||
width: 540px;
|
||||
max-width: 92vw;
|
||||
max-height: 80vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.map-list-empty {
|
||||
font-size: 0.85rem;
|
||||
color: #6b7280;
|
||||
text-align: center;
|
||||
padding: 1.5rem 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.map-list-scroll {
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
margin-top: 0.5rem;
|
||||
padding-right: 2px; /* room for scrollbar */
|
||||
}
|
||||
|
||||
.map-list-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
background: #141622;
|
||||
border: 1px solid #2e3348;
|
||||
border-radius: 8px;
|
||||
padding: 0.6rem 0.85rem;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.1s,
|
||||
border-color 0.1s;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.map-list-row:hover {
|
||||
background: #1a1d2e;
|
||||
border-color: #4b5563;
|
||||
}
|
||||
|
||||
.map-list-row.active {
|
||||
border-color: #6366f1;
|
||||
background: rgba(99, 102, 241, 0.08);
|
||||
}
|
||||
|
||||
.map-list-row:focus-visible {
|
||||
box-shadow: 0 0 0 2px #6366f1;
|
||||
}
|
||||
|
||||
.map-list-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.map-list-name {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: #e2e8f0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.map-list-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.map-list-owner {
|
||||
font-size: 0.75rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.map-access-badge {
|
||||
font-size: 0.68rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.access-private {
|
||||
background: rgba(75, 85, 99, 0.3);
|
||||
color: #9ca3af;
|
||||
}
|
||||
.access-public_view {
|
||||
background: rgba(59, 130, 246, 0.15);
|
||||
color: #60a5fa;
|
||||
}
|
||||
.access-public_edit {
|
||||
background: rgba(16, 185, 129, 0.15);
|
||||
color: #34d399;
|
||||
}
|
||||
|
||||
.map-fav-badge {
|
||||
font-size: 0.68rem;
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
/* Role badge reused from EditMapModal */
|
||||
.perm-role-badge {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.role-owner {
|
||||
background: rgba(99, 102, 241, 0.2);
|
||||
color: #818cf8;
|
||||
}
|
||||
.role-editor {
|
||||
background: rgba(234, 179, 8, 0.15);
|
||||
color: #fbbf24;
|
||||
}
|
||||
.role-viewer {
|
||||
background: rgba(16, 185, 129, 0.15);
|
||||
color: #34d399;
|
||||
}
|
||||
|
||||
/* ── Row action buttons ── */
|
||||
.map-list-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.map-action-btn {
|
||||
background: none;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 5px;
|
||||
color: #6b7280;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1;
|
||||
padding: 0.3rem 0.4rem;
|
||||
transition:
|
||||
color 0.12s,
|
||||
border-color 0.12s,
|
||||
background 0.12s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.map-action-btn:hover {
|
||||
color: #e2e8f0;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-color: #4b5563;
|
||||
}
|
||||
|
||||
.map-action-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.fav-btn.fav-active {
|
||||
color: #f59e0b;
|
||||
}
|
||||
.fav-btn.fav-active:hover {
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
.copy-btn {
|
||||
color: #6b7280;
|
||||
}
|
||||
.copied-text {
|
||||
color: #34d399;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
183
ui/src/components/MapListModal.tsx
Normal file
183
ui/src/components/MapListModal.tsx
Normal file
@@ -0,0 +1,183 @@
|
||||
import { useState } from "react";
|
||||
import { api } from "../api";
|
||||
import type { ListedMap } from "../types";
|
||||
import "./MapListModal.css";
|
||||
|
||||
interface Props {
|
||||
maps: ListedMap[];
|
||||
selectedMapId: string | null;
|
||||
onSelect: (id: string) => void;
|
||||
onClose: () => void;
|
||||
onMapsChange: (maps: ListedMap[]) => void;
|
||||
}
|
||||
|
||||
/** Copy text to the clipboard; show a brief "Copied!" toast. */
|
||||
function copyToClipboard(
|
||||
text: string,
|
||||
setCopied: (id: string | null) => void,
|
||||
id: string,
|
||||
) {
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
setCopied(id);
|
||||
setTimeout(() => setCopied(null), 1500);
|
||||
});
|
||||
}
|
||||
|
||||
function accessLabel(access: string): string {
|
||||
switch (access) {
|
||||
case "public_view":
|
||||
return "Public (view)";
|
||||
case "public_edit":
|
||||
return "Public (edit)";
|
||||
default:
|
||||
return "Private";
|
||||
}
|
||||
}
|
||||
|
||||
export default function MapListModal({
|
||||
maps,
|
||||
selectedMapId,
|
||||
onSelect,
|
||||
onClose,
|
||||
onMapsChange,
|
||||
}: Props) {
|
||||
const [copiedId, setCopiedId] = useState<string | null>(null);
|
||||
const [togglingId, setTogglingId] = useState<string | null>(null);
|
||||
|
||||
async function handleFavoriteToggle(e: React.MouseEvent, map: ListedMap) {
|
||||
e.stopPropagation();
|
||||
setTogglingId(map.id);
|
||||
try {
|
||||
if (map.is_favorited) {
|
||||
await api.unfavoriteMap(map.id);
|
||||
} else {
|
||||
await api.favoriteMap(map.id);
|
||||
}
|
||||
onMapsChange(
|
||||
maps.map((m) =>
|
||||
m.id === map.id ? { ...m, is_favorited: !m.is_favorited } : m,
|
||||
),
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("Failed to toggle favorite", err);
|
||||
} finally {
|
||||
setTogglingId(null);
|
||||
}
|
||||
}
|
||||
|
||||
function handleCopyLink(e: React.MouseEvent, map: ListedMap) {
|
||||
e.stopPropagation();
|
||||
const link = `${window.location.origin}/map/${encodeURIComponent(map.id)}`;
|
||||
copyToClipboard(link, setCopiedId, map.id);
|
||||
}
|
||||
|
||||
function handleSelect(map: ListedMap) {
|
||||
onSelect(map.id);
|
||||
onClose();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="modal-backdrop" onClick={onClose}>
|
||||
<div
|
||||
className="modal map-list-modal"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="modal-header">
|
||||
<h2>My Maps</h2>
|
||||
<button className="modal-close" onClick={onClose} aria-label="Close">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{maps.length === 0 ? (
|
||||
<p className="map-list-empty">
|
||||
No maps yet. Click "+ New Map" to create one.
|
||||
</p>
|
||||
) : (
|
||||
<div className="map-list-scroll">
|
||||
{maps.map((map) => (
|
||||
<div
|
||||
key={map.id}
|
||||
className={`map-list-row ${map.id === selectedMapId ? "active" : ""}`}
|
||||
onClick={() => handleSelect(map)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleSelect(map)}
|
||||
>
|
||||
<div className="map-list-main">
|
||||
<span className="map-list-name">{map.name}</span>
|
||||
<div className="map-list-meta">
|
||||
<span className="map-list-owner">
|
||||
by {map.owner_username}
|
||||
</span>
|
||||
<span
|
||||
className={`map-access-badge access-${map.public_access}`}
|
||||
>
|
||||
{accessLabel(map.public_access)}
|
||||
</span>
|
||||
{map.user_role && (
|
||||
<span className={`perm-role-badge role-${map.user_role}`}>
|
||||
{map.user_role}
|
||||
</span>
|
||||
)}
|
||||
{map.is_favorited && !map.user_role && (
|
||||
<span className="map-fav-badge">★ Favorited</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="map-list-actions">
|
||||
{/* Favorite toggle */}
|
||||
<button
|
||||
className={`map-action-btn fav-btn ${map.is_favorited ? "fav-active" : ""}`}
|
||||
onClick={(e) => handleFavoriteToggle(e, map)}
|
||||
disabled={togglingId === map.id}
|
||||
title={
|
||||
map.is_favorited
|
||||
? "Remove from favorites"
|
||||
: "Add to favorites"
|
||||
}
|
||||
>
|
||||
{map.is_favorited ? "★" : "☆"}
|
||||
</button>
|
||||
|
||||
{/* Copy link */}
|
||||
<button
|
||||
className="map-action-btn copy-btn"
|
||||
onClick={(e) => handleCopyLink(e, map)}
|
||||
title="Copy link"
|
||||
>
|
||||
{copiedId === map.id ? (
|
||||
<span className="copied-text">✓</span>
|
||||
) : (
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<rect
|
||||
x="9"
|
||||
y="9"
|
||||
width="13"
|
||||
height="13"
|
||||
rx="2"
|
||||
ry="2"
|
||||
/>
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
108
ui/src/components/Modal.css
Normal file
108
ui/src/components/Modal.css
Normal file
@@ -0,0 +1,108 @@
|
||||
/* ── Shared modal primitives used across all modal components ── */
|
||||
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 500;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: #1e2130;
|
||||
border: 1px solid #2e3348;
|
||||
border-radius: 10px;
|
||||
padding: 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.modal-header h2 {
|
||||
margin: 0;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 700;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #6b7280;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
padding: 0.25rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
transition:
|
||||
color 0.12s,
|
||||
background 0.12s;
|
||||
}
|
||||
.modal-close:hover {
|
||||
color: #e2e8f0;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
/* ── Shared button styles ── */
|
||||
|
||||
.btn-primary {
|
||||
background: #6366f1;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
padding: 0.4rem 1rem;
|
||||
transition: background 0.12s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.btn-primary:hover {
|
||||
background: #4f46e5;
|
||||
}
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #374151;
|
||||
border: 1px solid #4b5563;
|
||||
border-radius: 6px;
|
||||
color: #d1d5db;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
padding: 0.4rem 1rem;
|
||||
transition: background 0.12s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.btn-secondary:hover {
|
||||
background: #4b5563;
|
||||
}
|
||||
|
||||
/* ── Shared header button ── */
|
||||
.header-btn {
|
||||
background: #374151;
|
||||
border: 1px solid #4b5563;
|
||||
border-radius: 6px;
|
||||
color: #e5e7eb;
|
||||
cursor: pointer;
|
||||
font-size: 0.82rem;
|
||||
padding: 0.3rem 0.65rem;
|
||||
line-height: 1.4;
|
||||
white-space: nowrap;
|
||||
transition: background 0.12s;
|
||||
}
|
||||
.header-btn:hover {
|
||||
background: #4b5563;
|
||||
}
|
||||
103
ui/src/components/NewMapModal.css
Normal file
103
ui/src/components/NewMapModal.css
Normal file
@@ -0,0 +1,103 @@
|
||||
.new-map-modal {
|
||||
width: 440px;
|
||||
max-width: 92vw;
|
||||
}
|
||||
|
||||
.new-map-form-modal {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
padding-top: 0.25rem;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.82rem;
|
||||
color: #9ca3af;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.field-label input {
|
||||
background: #111827;
|
||||
border: 1px solid #4b5563;
|
||||
border-radius: 6px;
|
||||
color: #e5e7eb;
|
||||
padding: 0.4rem 0.65rem;
|
||||
font-size: 0.9rem;
|
||||
outline: none;
|
||||
transition: border-color 0.12s;
|
||||
}
|
||||
|
||||
.field-label input:focus {
|
||||
border-color: #6366f1;
|
||||
}
|
||||
|
||||
/* ── Visibility fieldset ── */
|
||||
.public-access-fieldset {
|
||||
border: 1px solid #374151;
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.public-access-fieldset legend {
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.07em;
|
||||
color: #6b7280;
|
||||
padding: 0 0.3rem;
|
||||
}
|
||||
|
||||
.radio-option {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.6rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.radio-option input[type="radio"] {
|
||||
accent-color: #6366f1;
|
||||
margin-top: 3px;
|
||||
flex-shrink: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.radio-label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.1rem;
|
||||
}
|
||||
|
||||
.radio-label strong {
|
||||
font-size: 0.85rem;
|
||||
color: #e2e8f0;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.radio-hint {
|
||||
font-size: 0.75rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
/* ── Shared form error ── */
|
||||
.form-error {
|
||||
margin: 0;
|
||||
font-size: 0.82rem;
|
||||
color: #f87171;
|
||||
background: rgba(239, 68, 68, 0.08);
|
||||
border: 1px solid rgba(239, 68, 68, 0.25);
|
||||
border-radius: 6px;
|
||||
padding: 0.4rem 0.7rem;
|
||||
}
|
||||
|
||||
/* ── Modal actions row ── */
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.6rem;
|
||||
padding-top: 0.25rem;
|
||||
}
|
||||
130
ui/src/components/NewMapModal.tsx
Normal file
130
ui/src/components/NewMapModal.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import type { PublicAccess } from "../types";
|
||||
import "./NewMapModal.css";
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
onCreate: (name: string, publicAccess: PublicAccess) => Promise<void>;
|
||||
}
|
||||
|
||||
export default function NewMapModal({ onClose, onCreate }: Props) {
|
||||
const [name, setName] = useState("");
|
||||
const [publicAccess, setPublicAccess] = useState<PublicAccess>("private");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const nameRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
nameRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
await onCreate(trimmed, publicAccess);
|
||||
onClose();
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
setError(msg.replace(/^\d+:\s*/, "").trim() || "Failed to create map");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="modal-backdrop" onClick={onClose}>
|
||||
<div className="modal new-map-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h2>New Map</h2>
|
||||
<button className="modal-close" onClick={onClose} aria-label="Close">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="new-map-form-modal">
|
||||
<label className="field-label">
|
||||
Map Name
|
||||
<input
|
||||
ref={nameRef}
|
||||
type="text"
|
||||
placeholder="My awesome map…"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
maxLength={60}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
|
||||
<fieldset className="public-access-fieldset">
|
||||
<legend>Visibility</legend>
|
||||
|
||||
<label className="radio-option">
|
||||
<input
|
||||
type="radio"
|
||||
name="public_access"
|
||||
value="private"
|
||||
checked={publicAccess === "private"}
|
||||
onChange={() => setPublicAccess("private")}
|
||||
/>
|
||||
<span className="radio-label">
|
||||
<strong>Private</strong>
|
||||
<span className="radio-hint">Only you and invited users</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label className="radio-option">
|
||||
<input
|
||||
type="radio"
|
||||
name="public_access"
|
||||
value="public_view"
|
||||
checked={publicAccess === "public_view"}
|
||||
onChange={() => setPublicAccess("public_view")}
|
||||
/>
|
||||
<span className="radio-label">
|
||||
<strong>Public – View Only</strong>
|
||||
<span className="radio-hint">
|
||||
Anyone with the link can view
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label className="radio-option">
|
||||
<input
|
||||
type="radio"
|
||||
name="public_access"
|
||||
value="public_edit"
|
||||
checked={publicAccess === "public_edit"}
|
||||
onChange={() => setPublicAccess("public_edit")}
|
||||
/>
|
||||
<span className="radio-label">
|
||||
<strong>Public – View & Edit</strong>
|
||||
<span className="radio-hint">
|
||||
Anyone with the link can view and edit
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
{error && <p className="form-error">{error}</p>}
|
||||
|
||||
<div className="modal-actions">
|
||||
<button type="button" className="btn-secondary" onClick={onClose}>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-primary"
|
||||
disabled={!name.trim() || loading}
|
||||
>
|
||||
{loading ? "Creating…" : "Create Map"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -38,7 +38,7 @@
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.dialog label input[type='text'] {
|
||||
.dialog label input[type="text"] {
|
||||
background: #111827;
|
||||
border: 1px solid #4b5563;
|
||||
border-radius: 5px;
|
||||
@@ -48,11 +48,11 @@
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.dialog label input[type='text']:focus {
|
||||
.dialog label input[type="text"]:focus {
|
||||
border-color: #6366f1;
|
||||
}
|
||||
|
||||
.dialog label input[type='color'] {
|
||||
.dialog label input[type="color"] {
|
||||
width: 48px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import './TokenDialog.css';
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import "./TokenDialog.css";
|
||||
|
||||
interface Props {
|
||||
defaultColor: string;
|
||||
@@ -7,8 +7,12 @@ interface Props {
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export default function TokenDialog({ defaultColor, onConfirm, onCancel }: Props) {
|
||||
const [label, setLabel] = useState('');
|
||||
export default function TokenDialog({
|
||||
defaultColor,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: Props) {
|
||||
const [label, setLabel] = useState("");
|
||||
const [color, setColor] = useState(defaultColor);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
@@ -24,12 +28,16 @@ export default function TokenDialog({ defaultColor, onConfirm, onCancel }: Props
|
||||
}
|
||||
|
||||
function handleKeyDown(e: React.KeyboardEvent) {
|
||||
if (e.key === 'Escape') onCancel();
|
||||
if (e.key === "Escape") onCancel();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="dialog-overlay" onClick={onCancel} onKeyDown={handleKeyDown}>
|
||||
<div className="dialog" onClick={e => e.stopPropagation()}>
|
||||
<div
|
||||
className="dialog-overlay"
|
||||
onClick={onCancel}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<div className="dialog" onClick={(e) => e.stopPropagation()}>
|
||||
<h3>Add Token</h3>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<label>
|
||||
@@ -39,7 +47,7 @@ export default function TokenDialog({ defaultColor, onConfirm, onCancel }: Props
|
||||
type="text"
|
||||
placeholder="e.g. Strahd von Zarovich"
|
||||
value={label}
|
||||
onChange={e => setLabel(e.target.value)}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
maxLength={30}
|
||||
/>
|
||||
</label>
|
||||
@@ -48,14 +56,18 @@ export default function TokenDialog({ defaultColor, onConfirm, onCancel }: Props
|
||||
<input
|
||||
type="color"
|
||||
value={color}
|
||||
onChange={e => setColor(e.target.value)}
|
||||
onChange={(e) => setColor(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<div className="dialog-actions">
|
||||
<button type="button" onClick={onCancel} className="btn-secondary">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" className="btn-primary" disabled={!label.trim()}>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-primary"
|
||||
disabled={!label.trim()}
|
||||
>
|
||||
Place Token
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useEffect, useRef, useCallback } from 'react';
|
||||
import type { ServerMessage, ClientMessage } from '../types';
|
||||
import { getToken } from '../api';
|
||||
import { useEffect, useRef, useCallback } from "react";
|
||||
import type { ServerMessage, ClientMessage } from "../types";
|
||||
|
||||
export function useWebSocket(
|
||||
mapId: string,
|
||||
@@ -12,10 +11,10 @@ export function useWebSocket(
|
||||
onMessageRef.current = onMessage;
|
||||
|
||||
useEffect(() => {
|
||||
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const token = getToken();
|
||||
const tokenParam = token ? `?token=${encodeURIComponent(token)}` : '';
|
||||
const url = `${proto}//${window.location.host}/api/grid/maps/${mapId}/ws${tokenParam}`;
|
||||
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
// The browser automatically sends the siren_session cookie with the
|
||||
// WebSocket upgrade request — no manual token query param needed.
|
||||
const url = `${proto}//${window.location.host}/api/grid/maps/${mapId}/ws`;
|
||||
|
||||
const ws = new WebSocket(url);
|
||||
wsRef.current = ws;
|
||||
@@ -29,12 +28,12 @@ export function useWebSocket(
|
||||
const msg: ServerMessage = JSON.parse(event.data as string);
|
||||
onMessageRef.current(msg);
|
||||
} catch (err) {
|
||||
console.error('[WS] Failed to parse message:', err);
|
||||
console.error("[WS] Failed to parse message:", err);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = (err) => {
|
||||
console.error('[WS] Error:', err);
|
||||
console.error("[WS] Error:", err);
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
*, *::before, *::after {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html, body, #root {
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
font-family:
|
||||
system-ui,
|
||||
-apple-system,
|
||||
sans-serif;
|
||||
background: #111827;
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App.tsx';
|
||||
import './index.css';
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import App from "./App.tsx";
|
||||
import "./index.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
|
||||
123
ui/src/types.ts
123
ui/src/types.ts
@@ -1,27 +1,62 @@
|
||||
export interface User {
|
||||
id: string; // Discord snowflake (stored as string)
|
||||
username: string;
|
||||
avatar?: string;
|
||||
// ---------------------------------------------------------------------------
|
||||
// User / Auth
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ConnectionInfo {
|
||||
provider: string;
|
||||
provider_username: string | null;
|
||||
provider_avatar: string | null;
|
||||
}
|
||||
|
||||
export type MapRole = 'owner' | 'editor' | 'viewer';
|
||||
export interface UserInfo {
|
||||
id: string; // UUID
|
||||
username: string;
|
||||
first_name: string | null;
|
||||
last_name: string | null;
|
||||
email: string | null;
|
||||
/** True when the account has a local password (can log in without OAuth). */
|
||||
has_password: boolean;
|
||||
connections: ConnectionInfo[];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Maps
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type MapRole = "owner" | "editor" | "viewer";
|
||||
|
||||
/** Map visibility / editability level. */
|
||||
export type PublicAccess = "private" | "public_view" | "public_edit";
|
||||
|
||||
export interface MapPermission {
|
||||
map_id: string;
|
||||
user_id: number;
|
||||
user_id: string; // UUID
|
||||
username: string;
|
||||
role: MapRole;
|
||||
}
|
||||
|
||||
/** Core map record (returned by create, get, update). */
|
||||
export interface GridMap {
|
||||
id: string;
|
||||
name: string;
|
||||
is_public: boolean;
|
||||
owner_id: number;
|
||||
public_access: PublicAccess;
|
||||
owner_id: string; // UUID
|
||||
colors: string[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extended map record returned by the list endpoint.
|
||||
* Includes owner username, the caller's role, and a favorite flag.
|
||||
*/
|
||||
export interface ListedMap extends GridMap {
|
||||
owner_username: string;
|
||||
/** Null when the map is in the list only via a favorite (no explicit permission). */
|
||||
user_role: MapRole | null;
|
||||
is_favorited: boolean;
|
||||
}
|
||||
|
||||
export interface GridCell {
|
||||
map_id: string;
|
||||
x: number;
|
||||
@@ -44,36 +79,52 @@ export interface MapState {
|
||||
tokens: GridToken[];
|
||||
}
|
||||
|
||||
export type Tool = 'pan' | 'zoom' | 'draw' | 'token';
|
||||
export interface MapAccessRequest {
|
||||
id: string; // UUID
|
||||
map_id: string;
|
||||
user_id: string; // UUID
|
||||
username: string;
|
||||
requested_role: "editor" | "viewer";
|
||||
status: "pending" | "approved" | "denied";
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
// ---- WebSocket message types ------------------------------------------------
|
||||
export type Tool = "pan" | "zoom" | "draw" | "token";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// WebSocket message types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type ClientMessage =
|
||||
| { type: 'paint_cell'; x: number; y: number; color: string }
|
||||
| { type: 'paint_cells'; cells: Array<{ x: number; y: number; color: string }> }
|
||||
| { type: 'erase_cell'; x: number; y: number }
|
||||
| { type: 'add_token'; x: number; y: number; label: string; color: string }
|
||||
| { type: 'move_token'; id: string; x: number; y: number }
|
||||
| { type: 'delete_token'; id: string }
|
||||
| { type: 'update_colors'; colors: string[] };
|
||||
| { type: "paint_cell"; x: number; y: number; color: string }
|
||||
| {
|
||||
type: "paint_cells";
|
||||
cells: Array<{ x: number; y: number; color: string }>;
|
||||
}
|
||||
| { type: "erase_cell"; x: number; y: number }
|
||||
| { type: "add_token"; x: number; y: number; label: string; color: string }
|
||||
| { type: "move_token"; id: string; x: number; y: number }
|
||||
| { type: "delete_token"; id: string }
|
||||
| { type: "update_colors"; colors: string[] };
|
||||
|
||||
export type ServerMessage =
|
||||
| { type: 'state'; cells: GridCell[]; tokens: GridToken[]; colors: string[] }
|
||||
| { type: 'cell_painted'; x: number; y: number; color: string }
|
||||
| { type: 'cells_batch_painted'; cells: Array<{ x: number; y: number; color: string }> }
|
||||
| { type: 'cell_erased'; x: number; y: number }
|
||||
| { type: 'token_added'; id: string; x: number; y: number; label: string; color: string }
|
||||
| { type: 'token_moved'; id: string; x: number; y: number }
|
||||
| { type: 'token_deleted'; id: string }
|
||||
| { type: 'colors_updated'; colors: string[] }
|
||||
| { type: 'error'; message: string };
|
||||
|
||||
// ---- Auth token payload (JWT claims) ----------------------------------------
|
||||
|
||||
export interface TokenClaims {
|
||||
sub: number; // Discord user ID
|
||||
name: string;
|
||||
iat: number;
|
||||
exp: number;
|
||||
jti: string;
|
||||
}
|
||||
| { type: "state"; cells: GridCell[]; tokens: GridToken[]; colors: string[] }
|
||||
| { type: "cell_painted"; x: number; y: number; color: string }
|
||||
| {
|
||||
type: "cells_batch_painted";
|
||||
cells: Array<{ x: number; y: number; color: string }>;
|
||||
}
|
||||
| { type: "cell_erased"; x: number; y: number }
|
||||
| {
|
||||
type: "token_added";
|
||||
id: string;
|
||||
x: number;
|
||||
y: number;
|
||||
label: string;
|
||||
color: string;
|
||||
}
|
||||
| { type: "token_moved"; id: string; x: number; y: number }
|
||||
| { type: "token_deleted"; id: string }
|
||||
| { type: "colors_updated"; colors: string[] }
|
||||
| { type: "error"; message: string };
|
||||
|
||||
1
ui/src/vite-env.d.ts
vendored
Normal file
1
ui/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -1,13 +1,23 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import react from '@vitejs/plugin-react'
|
||||
import path from "path";
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
'@api': path.resolve(__dirname, './src/api'),
|
||||
'@components': path.resolve(__dirname, './src/components'),
|
||||
'@hooks': path.resolve(__dirname, './src/hooks'),
|
||||
'@types': path.resolve(__dirname, './src/types'),
|
||||
}
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
// Proxy REST calls and WebSocket upgrades to the Axum backend
|
||||
// Proxy REST calls and WebSocket upgrades to the API
|
||||
'/api': {
|
||||
target: 'http://localhost:3000',
|
||||
changeOrigin: true,
|
||||
|
||||
Reference in New Issue
Block a user