363 lines
11 KiB
TypeScript
363 lines
11 KiB
TypeScript
|
|
/**
|
||
|
|
* SessionMenu — top-bar dropdown that drives the SessionService.
|
||
|
|
*
|
||
|
|
* Holds the only place in the UI where sessions get created, renamed,
|
||
|
|
* deleted, and switched. Export/Import ZIP menu items are slots —
|
||
|
|
* T06/T07 wire them.
|
||
|
|
*
|
||
|
|
* Switching sessions writes the new id into the URL hash; the routing
|
||
|
|
* layer is the source of truth (see `routing.ts`). That keeps deep
|
||
|
|
* links + browser back/forward behaving naturally.
|
||
|
|
*/
|
||
|
|
|
||
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||
|
|
import type { CSSProperties } from "react";
|
||
|
|
|
||
|
|
import type { Session } from "@shared/session";
|
||
|
|
import { useActiveSession, useSessionListTick, useSessionService } from "@work/index";
|
||
|
|
|
||
|
|
import { navigateTo } from "./routing";
|
||
|
|
|
||
|
|
interface SessionMenuProps {
|
||
|
|
readonly onExportZip?: () => void;
|
||
|
|
readonly onImportZip?: () => void;
|
||
|
|
readonly onOpenSamples?: () => void;
|
||
|
|
}
|
||
|
|
|
||
|
|
export function SessionMenu({ onExportZip, onImportZip, onOpenSamples }: SessionMenuProps) {
|
||
|
|
const service = useSessionService();
|
||
|
|
const tick = useSessionListTick();
|
||
|
|
const active = useActiveSession();
|
||
|
|
|
||
|
|
const [open, setOpen] = useState(false);
|
||
|
|
const [newName, setNewName] = useState("");
|
||
|
|
const [creating, setCreating] = useState(false);
|
||
|
|
const [renaming, setRenaming] = useState(false);
|
||
|
|
const [renameValue, setRenameValue] = useState("");
|
||
|
|
const [pendingDelete, setPendingDelete] = useState(false);
|
||
|
|
const [error, setError] = useState<string | null>(null);
|
||
|
|
const wrapperRef = useRef<HTMLDivElement | null>(null);
|
||
|
|
|
||
|
|
const sessions = useMemo(() => {
|
||
|
|
// sorted by lastOpenedAt desc, then by createdAt desc
|
||
|
|
void tick;
|
||
|
|
const list = [...service.list()];
|
||
|
|
list.sort((a: Session, b: Session) => {
|
||
|
|
const aKey = a.lastOpenedAt ?? a.createdAt;
|
||
|
|
const bKey = b.lastOpenedAt ?? b.createdAt;
|
||
|
|
return bKey.localeCompare(aKey);
|
||
|
|
});
|
||
|
|
return list;
|
||
|
|
}, [service, tick]);
|
||
|
|
|
||
|
|
// Click outside closes the menu.
|
||
|
|
useEffect(() => {
|
||
|
|
if (!open) return;
|
||
|
|
const handler = (e: MouseEvent) => {
|
||
|
|
if (!wrapperRef.current) return;
|
||
|
|
if (!wrapperRef.current.contains(e.target as Node)) {
|
||
|
|
setOpen(false);
|
||
|
|
setCreating(false);
|
||
|
|
setRenaming(false);
|
||
|
|
setPendingDelete(false);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
window.addEventListener("mousedown", handler);
|
||
|
|
return () => window.removeEventListener("mousedown", handler);
|
||
|
|
}, [open]);
|
||
|
|
|
||
|
|
const switchTo = useCallback(
|
||
|
|
(sessionId: import("@shared/ids").SessionId) => {
|
||
|
|
navigateTo({ sessionId, mode: "review" });
|
||
|
|
setOpen(false);
|
||
|
|
},
|
||
|
|
[],
|
||
|
|
);
|
||
|
|
|
||
|
|
const handleCreate = useCallback(() => {
|
||
|
|
setError(null);
|
||
|
|
try {
|
||
|
|
const created = service.create(newName);
|
||
|
|
setNewName("");
|
||
|
|
setCreating(false);
|
||
|
|
setOpen(false);
|
||
|
|
navigateTo({ sessionId: created.id, mode: "review" });
|
||
|
|
} catch (err) {
|
||
|
|
setError(err instanceof Error ? err.message : String(err));
|
||
|
|
}
|
||
|
|
}, [newName, service]);
|
||
|
|
|
||
|
|
const handleRename = useCallback(() => {
|
||
|
|
if (!active) return;
|
||
|
|
setError(null);
|
||
|
|
try {
|
||
|
|
service.rename(active.id, renameValue);
|
||
|
|
setRenaming(false);
|
||
|
|
setOpen(false);
|
||
|
|
} catch (err) {
|
||
|
|
setError(err instanceof Error ? err.message : String(err));
|
||
|
|
}
|
||
|
|
}, [active, renameValue, service]);
|
||
|
|
|
||
|
|
const handleDelete = useCallback(() => {
|
||
|
|
if (!active) return;
|
||
|
|
if (!pendingDelete) {
|
||
|
|
setPendingDelete(true);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
service.delete(active.id);
|
||
|
|
setPendingDelete(false);
|
||
|
|
setOpen(false);
|
||
|
|
navigateTo({ sessionId: null, mode: "review" });
|
||
|
|
}, [active, pendingDelete, service]);
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div ref={wrapperRef} style={{ position: "relative" }} data-testid="session-menu">
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
aria-haspopup="menu"
|
||
|
|
aria-expanded={open}
|
||
|
|
data-testid="session-menu-toggle"
|
||
|
|
onClick={() => setOpen((v) => !v)}
|
||
|
|
style={{
|
||
|
|
fontSize: 12,
|
||
|
|
padding: "4px 10px",
|
||
|
|
border: "1px solid #888",
|
||
|
|
background: "white",
|
||
|
|
cursor: "pointer",
|
||
|
|
minWidth: 160,
|
||
|
|
textAlign: "left",
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
{active ? active.name : "No session"}
|
||
|
|
<span style={{ float: "right", color: "#888" }}>▾</span>
|
||
|
|
</button>
|
||
|
|
{open && (
|
||
|
|
<div
|
||
|
|
role="menu"
|
||
|
|
data-testid="session-menu-panel"
|
||
|
|
style={{
|
||
|
|
position: "absolute",
|
||
|
|
top: 28,
|
||
|
|
left: 0,
|
||
|
|
zIndex: 30,
|
||
|
|
background: "white",
|
||
|
|
border: "1px solid #888",
|
||
|
|
borderRadius: 3,
|
||
|
|
boxShadow: "0 4px 12px rgba(0,0,0,0.15)",
|
||
|
|
padding: 4,
|
||
|
|
minWidth: 240,
|
||
|
|
fontSize: 12,
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
{sessions.length > 0 && (
|
||
|
|
<>
|
||
|
|
<div style={{ padding: "4px 8px", color: "#666", fontSize: 11 }}>
|
||
|
|
Switch to…
|
||
|
|
</div>
|
||
|
|
{sessions.map((s) => (
|
||
|
|
<button
|
||
|
|
key={s.id}
|
||
|
|
type="button"
|
||
|
|
role="menuitem"
|
||
|
|
data-testid={`session-switch-${s.id}`}
|
||
|
|
onClick={() => switchTo(s.id)}
|
||
|
|
style={{
|
||
|
|
...menuItemStyle,
|
||
|
|
background: active?.id === s.id ? "#e8f0ff" : "transparent",
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
{s.name}
|
||
|
|
{active?.id === s.id ? " · open" : ""}
|
||
|
|
</button>
|
||
|
|
))}
|
||
|
|
<hr style={dividerStyle} />
|
||
|
|
</>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{!creating && (
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
role="menuitem"
|
||
|
|
data-testid="session-menu-new"
|
||
|
|
onClick={() => {
|
||
|
|
setError(null);
|
||
|
|
setCreating(true);
|
||
|
|
setNewName("");
|
||
|
|
}}
|
||
|
|
style={menuItemStyle}
|
||
|
|
>
|
||
|
|
New session…
|
||
|
|
</button>
|
||
|
|
)}
|
||
|
|
{creating && (
|
||
|
|
<div style={{ padding: 4, display: "flex", gap: 4 }}>
|
||
|
|
<input
|
||
|
|
autoFocus
|
||
|
|
type="text"
|
||
|
|
value={newName}
|
||
|
|
onChange={(e) => setNewName(e.target.value)}
|
||
|
|
placeholder="Session name"
|
||
|
|
data-testid="session-new-input"
|
||
|
|
style={{ flex: 1, fontSize: 12, padding: 4 }}
|
||
|
|
onKeyDown={(e) => {
|
||
|
|
if (e.key === "Enter") handleCreate();
|
||
|
|
if (e.key === "Escape") setCreating(false);
|
||
|
|
}}
|
||
|
|
/>
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
onClick={handleCreate}
|
||
|
|
data-testid="session-new-confirm"
|
||
|
|
style={smallButtonStyle}
|
||
|
|
>
|
||
|
|
Create
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{active && (
|
||
|
|
<>
|
||
|
|
<hr style={dividerStyle} />
|
||
|
|
{!renaming && (
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
role="menuitem"
|
||
|
|
data-testid="session-menu-rename"
|
||
|
|
onClick={() => {
|
||
|
|
setError(null);
|
||
|
|
setRenaming(true);
|
||
|
|
setRenameValue(active.name);
|
||
|
|
}}
|
||
|
|
style={menuItemStyle}
|
||
|
|
>
|
||
|
|
Rename…
|
||
|
|
</button>
|
||
|
|
)}
|
||
|
|
{renaming && (
|
||
|
|
<div style={{ padding: 4, display: "flex", gap: 4 }}>
|
||
|
|
<input
|
||
|
|
autoFocus
|
||
|
|
type="text"
|
||
|
|
value={renameValue}
|
||
|
|
onChange={(e) => setRenameValue(e.target.value)}
|
||
|
|
data-testid="session-rename-input"
|
||
|
|
style={{ flex: 1, fontSize: 12, padding: 4 }}
|
||
|
|
onKeyDown={(e) => {
|
||
|
|
if (e.key === "Enter") handleRename();
|
||
|
|
if (e.key === "Escape") setRenaming(false);
|
||
|
|
}}
|
||
|
|
/>
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
onClick={handleRename}
|
||
|
|
data-testid="session-rename-confirm"
|
||
|
|
style={smallButtonStyle}
|
||
|
|
>
|
||
|
|
Save
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
role="menuitem"
|
||
|
|
data-testid="session-menu-delete"
|
||
|
|
onClick={handleDelete}
|
||
|
|
style={{ ...menuItemStyle, color: "#7a0000" }}
|
||
|
|
>
|
||
|
|
{pendingDelete ? "Confirm delete?" : "Delete…"}
|
||
|
|
</button>
|
||
|
|
</>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{(onExportZip || onImportZip || onOpenSamples) && (
|
||
|
|
<hr style={dividerStyle} />
|
||
|
|
)}
|
||
|
|
{onExportZip && active && (
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
role="menuitem"
|
||
|
|
data-testid="session-menu-export"
|
||
|
|
onClick={() => {
|
||
|
|
setOpen(false);
|
||
|
|
onExportZip();
|
||
|
|
}}
|
||
|
|
style={menuItemStyle}
|
||
|
|
>
|
||
|
|
Export ZIP
|
||
|
|
</button>
|
||
|
|
)}
|
||
|
|
{onImportZip && (
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
role="menuitem"
|
||
|
|
data-testid="session-menu-import"
|
||
|
|
onClick={() => {
|
||
|
|
setOpen(false);
|
||
|
|
onImportZip();
|
||
|
|
}}
|
||
|
|
style={menuItemStyle}
|
||
|
|
>
|
||
|
|
Import ZIP
|
||
|
|
</button>
|
||
|
|
)}
|
||
|
|
{onOpenSamples && (
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
role="menuitem"
|
||
|
|
data-testid="session-menu-samples"
|
||
|
|
onClick={() => {
|
||
|
|
setOpen(false);
|
||
|
|
onOpenSamples();
|
||
|
|
}}
|
||
|
|
style={menuItemStyle}
|
||
|
|
>
|
||
|
|
Sample sessions ▸
|
||
|
|
</button>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{error && (
|
||
|
|
<div
|
||
|
|
data-testid="session-menu-error"
|
||
|
|
style={{
|
||
|
|
padding: 6,
|
||
|
|
background: "#fff4f4",
|
||
|
|
color: "#7a0000",
|
||
|
|
fontSize: 11,
|
||
|
|
marginTop: 4,
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
{error}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
const menuItemStyle: CSSProperties = {
|
||
|
|
display: "block",
|
||
|
|
width: "100%",
|
||
|
|
textAlign: "left",
|
||
|
|
background: "transparent",
|
||
|
|
border: "none",
|
||
|
|
padding: "4px 8px",
|
||
|
|
cursor: "pointer",
|
||
|
|
fontSize: 12,
|
||
|
|
};
|
||
|
|
|
||
|
|
const smallButtonStyle: CSSProperties = {
|
||
|
|
fontSize: 12,
|
||
|
|
padding: "2px 8px",
|
||
|
|
border: "1px solid #888",
|
||
|
|
background: "white",
|
||
|
|
cursor: "pointer",
|
||
|
|
};
|
||
|
|
|
||
|
|
const dividerStyle: CSSProperties = {
|
||
|
|
border: "none",
|
||
|
|
borderTop: "1px solid #eee",
|
||
|
|
margin: "4px 0",
|
||
|
|
};
|