Formatting and cleanup

This commit is contained in:
2026-04-04 14:33:07 -04:00
parent f17e5061cd
commit 070337577c
20 changed files with 237 additions and 421 deletions

View File

@@ -2,6 +2,7 @@ import { useState } from "react";
import { auth } from "../api";
import type { UserInfo } from "../types";
import "./AccountPanel.css";
import { FaDiscord } from "react-icons/fa6";
interface Props {
user: UserInfo;
@@ -43,7 +44,7 @@ export default function AccountPanel({ user, onClose, onRefresh }: Props) {
setProfileSuccess(false);
}
async function handleSaveProfile(e: React.SubmitEvent<HTMLFormElement>) {
async function handleSaveProfile(e: React.SubmitEvent<HTMLFormElement>) {
e.preventDefault();
setProfileSaving(true);
setProfileError(null);
@@ -63,7 +64,7 @@ export default function AccountPanel({ user, onClose, onRefresh }: Props) {
}
}
async function handleChangePassword(e: React.FormEvent) {
async function handleChangePassword(e: React.SubmitEvent<HTMLFormElement>) {
e.preventDefault();
setPwError(null);
setPwSuccess(false);
@@ -72,10 +73,6 @@ export default function AccountPanel({ user, onClose, onRefresh }: Props) {
setPwError("Passwords do not match");
return;
}
if (pwNew.length < 8) {
setPwError("Password must be at least 8 characters");
return;
}
setPwSaving(true);
try {
@@ -296,13 +293,7 @@ export default function AccountPanel({ user, onClose, onRefresh }: Props) {
<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>
<FaDiscord />
<div className="connection-info">
<span className="connection-name">Discord</span>

View File

@@ -24,7 +24,7 @@ export default function FloatingMapControls({
return (
<div className="floating-map-controls">
{/* Always visible for logged-in users */}
<button className="fmc-btn" onClick={onViewMaps} title="View my maps">
<button className="fmc-btn" onClick={onViewMaps} title="View maps">
<svg
width="14"
height="14"

View File

@@ -17,10 +17,6 @@ import { useWebSocket } from "../hooks/useWebSocket";
import TokenDialog from "./TokenDialog";
import "./Grid.css";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const DEFAULT_ZOOM = 40;
const MIN_ZOOM = 8;
const MAX_ZOOM = 160;
@@ -36,10 +32,6 @@ const MAX_FLOOD_CELLS = 2500;
/** World units per second for WASD keyboard panning. */
const WASD_PAN_SPEED = 12;
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface Camera {
offsetX: number;
offsetY: number;
@@ -58,10 +50,6 @@ export interface GridHandle {
sendColorUpdate: (colors: string[]) => void;
}
// ---------------------------------------------------------------------------
// Pure helpers
// ---------------------------------------------------------------------------
function cellKey(x: number, y: number): string {
return `${x},${y}`;
}
@@ -265,10 +253,6 @@ function clampCameraToContent(
}
}
// ---------------------------------------------------------------------------
// Grid component
// ---------------------------------------------------------------------------
const Grid = forwardRef<GridHandle, Props>(function Grid(
{ mapId, tool, paintColor, tokenColor, onColorsLoaded },
ref,
@@ -316,18 +300,12 @@ const Grid = forwardRef<GridHandle, Props>(function Grid(
null,
);
// -------------------------------------------------------------------------
// Imperative handle — lets App.tsx trigger a color WS update
// -------------------------------------------------------------------------
useImperativeHandle(ref, () => ({
sendColorUpdate(colors: string[]) {
sendRef.current({ type: "update_colors", colors });
},
}));
// -------------------------------------------------------------------------
// Resize canvas to fill container
// -------------------------------------------------------------------------
useEffect(() => {
const container = containerRef.current;
const canvas = canvasRef.current;
@@ -345,9 +323,6 @@ const Grid = forwardRef<GridHandle, Props>(function Grid(
return () => observer.disconnect();
}, [redraw]);
// -------------------------------------------------------------------------
// WebSocket
// -------------------------------------------------------------------------
// Keep a stable ref to the callback so handleMessage doesn't re-create
const onColorsLoadedRef = useRef(onColorsLoaded);
useEffect(() => {
@@ -437,9 +412,6 @@ const Grid = forwardRef<GridHandle, Props>(function Grid(
sendRef.current = send;
}, [send]);
// -------------------------------------------------------------------------
// Canvas draw
// -------------------------------------------------------------------------
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
@@ -518,9 +490,6 @@ const Grid = forwardRef<GridHandle, Props>(function Grid(
}
}, [tick]);
// -------------------------------------------------------------------------
// Camera helpers
// -------------------------------------------------------------------------
function applyClampAndRedraw() {
const canvas = canvasRef.current;
if (canvas) {
@@ -546,9 +515,6 @@ const Grid = forwardRef<GridHandle, Props>(function Grid(
applyClampAndRedraw();
}
// -------------------------------------------------------------------------
// Wheel → zoom
// -------------------------------------------------------------------------
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
@@ -564,9 +530,6 @@ const Grid = forwardRef<GridHandle, Props>(function Grid(
return () => canvas.removeEventListener("wheel", onWheel);
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// -------------------------------------------------------------------------
// WASD panning — requestAnimationFrame loop
// -------------------------------------------------------------------------
useEffect(() => {
function rafTick(timestamp: number) {
const keys = keysHeld.current;
@@ -640,9 +603,6 @@ const Grid = forwardRef<GridHandle, Props>(function Grid(
};
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// -------------------------------------------------------------------------
// Mouse helpers
// -------------------------------------------------------------------------
function getCanvasPoint(e: React.MouseEvent) {
const rect = canvasRef.current!.getBoundingClientRect();
return { x: e.clientX - rect.left, y: e.clientY - rect.top };
@@ -655,9 +615,6 @@ const Grid = forwardRef<GridHandle, Props>(function Grid(
return null;
}
// -------------------------------------------------------------------------
// Mouse handlers
// -------------------------------------------------------------------------
function handleMouseDown(e: React.MouseEvent) {
e.preventDefault();
const { x: mx, y: my } = getCanvasPoint(e);

View File

@@ -2,6 +2,7 @@ import { useState } from "react";
import { auth } from "../api";
import type { UserInfo } from "../types";
import "./LoginModal.css";
import { FaDiscord } from "react-icons/fa6";
interface Props {
onClose: () => void;
@@ -103,7 +104,7 @@ export default function LoginModal({ onClose, onLogin }: Props) {
onChange={(e) => setUsername(e.target.value)}
autoComplete="username"
required
minLength={1}
minLength={3}
maxLength={32}
/>
</label>
@@ -117,7 +118,6 @@ export default function LoginModal({ onClose, onLogin }: Props) {
tab === "login" ? "current-password" : "new-password"
}
required
minLength={8}
/>
</label>
{tab === "register" && (
@@ -129,7 +129,6 @@ export default function LoginModal({ onClose, onLogin }: Props) {
onChange={(e) => setConfirm(e.target.value)}
autoComplete="new-password"
required
minLength={8}
/>
</label>
)}
@@ -151,9 +150,7 @@ export default function LoginModal({ onClose, onLogin }: Props) {
{/* 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>
<FaDiscord />
Log In with Discord
</button>
</div>

View File

@@ -108,6 +108,12 @@
color: #f59e0b;
}
.map-list-updated {
font-size: 0.68rem;
color: #4b5563;
margin-left: auto;
}
/* Role badge reused from EditMapModal */
.perm-role-badge {
font-size: 0.7rem;

View File

@@ -2,6 +2,21 @@ import { useState } from "react";
import { api } from "../api";
import type { ListedMap } from "../types";
import "./MapListModal.css";
import { FaTrash } from "react-icons/fa6";
function timeAgo(date: Date): string {
const diff = Date.now() - new Date(date).getTime();
const minutes = Math.floor(diff / 60_000);
if (minutes < 1) return "just now";
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
if (days < 30) return `${days}d ago`;
const months = Math.floor(days / 30);
if (months < 12) return `${months}mo ago`;
return `${Math.floor(months / 12)}y ago`;
}
interface Props {
maps: ListedMap[];
@@ -83,7 +98,7 @@ export default function MapListModal({
onClick={(e) => e.stopPropagation()}
>
<div className="modal-header">
<h2>My Maps</h2>
<h2>Maps</h2>
<button className="modal-close" onClick={onClose} aria-label="Close">
</button>
@@ -95,86 +110,123 @@ export default function MapListModal({
</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}
{[...maps]
.sort(
(a, b) =>
new Date(b.updated_at).getTime() -
new Date(a.updated_at).getTime(),
)
.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>
)}
{map.is_favorited && !map.user_role && (
<span className="map-fav-badge"> Favorited</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>
)}
<span
className="map-list-updated"
title={new Date(map.updated_at).toLocaleString()}
>
{timeAgo(map.updated_at)}
</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>
{/* Delete map button */}
<button
className="map-action-btn delete-btn"
onClick={async (e) => {
e.stopPropagation();
if (
window.confirm(
"Are you sure you want to delete this map? This action cannot be undone.",
)
) {
try {
await api.deleteMap(map.id);
onMapsChange(maps.filter((m) => m.id !== map.id));
} catch (err) {
console.error("Failed to delete map", err);
}
}
}}
title="Delete map"
>
<FaTrash />
</button>
</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>

View File

@@ -1,7 +1,3 @@
// ---------------------------------------------------------------------------
// User / Auth
// ---------------------------------------------------------------------------
export interface ConnectionInfo {
provider: string;
provider_username: string | null;
@@ -19,10 +15,6 @@ export interface UserInfo {
connections: ConnectionInfo[];
}
// ---------------------------------------------------------------------------
// Maps
// ---------------------------------------------------------------------------
export type MapRole = "owner" | "editor" | "viewer";
/** Map visibility / editability level. */
@@ -42,8 +34,8 @@ export interface GridMap {
public_access: PublicAccess;
owner_id: string; // UUID
colors: string[];
created_at: string;
updated_at: string;
created_at: Date;
updated_at: Date;
}
/**
@@ -92,10 +84,6 @@ export interface MapAccessRequest {
export type Tool = "pan" | "zoom" | "draw" | "token";
// ---------------------------------------------------------------------------
// WebSocket message types
// ---------------------------------------------------------------------------
export type ClientMessage =
| { type: "paint_cell"; x: number; y: number; color: string }
| {