feat(tech-tree): add admin tech tree editor view
Add a custom admin view over the technologies collection so staff can model prerequisites as a left-to-right tree instead of a flat list. - React Flow v12 canvas with category bands, OR-prerequisite edges, a lite side-panel editor, and a quick-create dialog. - Propagating layout: a technology with prerequisites lands one column right of its parents at their average row; uncategorized roots fill a catalog grid keyed by each tech's rank among all uncategorized technologies, so promoting one into the tree leaves an empty slot instead of re-shuffling the grid. - Stability contract: connecting or removing edges never moves a card (the connected node is pinned to its current spot), and a "Save positions" control commits every on-screen node so the arrangement survives reload; "Auto-arrange" clears manual positions and restores the ordered layout. - Progression-path edge colors and per-source lanes keep overlapping edges readable. Wire the view and its nav entry into payload.config.ts and add dark React Flow / scrollbar styling.
This commit is contained in:
parent
15f2d0c573
commit
89482798f4
18 changed files with 3280 additions and 0 deletions
|
|
@ -23,3 +23,43 @@
|
||||||
@layer utilities {
|
@layer utilities {
|
||||||
@import 'tailwindcss/utilities.css';
|
@import 'tailwindcss/utilities.css';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Tech Tree canvas: dark tactical chrome for React Flow controls. */
|
||||||
|
.tech-tree-canvas .react-flow__controls {
|
||||||
|
button {
|
||||||
|
background: rgba(0, 0, 0, 0.72);
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.15);
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
border-radius: 0;
|
||||||
|
&:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.92);
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
svg { fill: currentColor; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.tech-tree-canvas .react-flow__attribution {
|
||||||
|
background: transparent;
|
||||||
|
a { color: rgba(255, 255, 255, 0.35); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tech Tree: dark thin scrollbars for the panel and its dropdowns. */
|
||||||
|
.tech-tree-scroll {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: rgba(255, 255, 255, 0.18) transparent;
|
||||||
|
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
&::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
&::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(255, 255, 255, 0.16);
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
&::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
61
src/components/admin/tech-tree/CategoryBand.tsx
Normal file
61
src/components/admin/tech-tree/CategoryBand.tsx
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Handle, Position, type NodeProps, type Node } from "@xyflow/react";
|
||||||
|
|
||||||
|
import type { CategoryBandLayout } from "./types";
|
||||||
|
|
||||||
|
export type CategoryBandData = {
|
||||||
|
band: CategoryBandLayout;
|
||||||
|
techCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CategoryBandNode = Node<CategoryBandData, "techBand">;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full-height column band behind a category's technologies. Non-interactive:
|
||||||
|
* it is a backdrop, not a node (no handles, no dragging, no selection).
|
||||||
|
*/
|
||||||
|
export function CategoryBand({ data }: NodeProps<CategoryBandNode>) {
|
||||||
|
const { band, techCount } = data;
|
||||||
|
const accent = band.color ?? "#22d3ee";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="pointer-events-none flex h-full flex-col border bg-black/40"
|
||||||
|
style={{
|
||||||
|
borderColor: `${accent}2e`,
|
||||||
|
boxShadow: "0 0 24px rgba(0,0,0,0.35)",
|
||||||
|
}}
|
||||||
|
data-testid={`tech-band-${band.key}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-between border-b bg-black/50 px-3 py-2"
|
||||||
|
style={{ borderColor: `${accent}33` }}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="font-mono text-[9px] tracking-widest uppercase"
|
||||||
|
style={{ color: `${accent}cc` }}
|
||||||
|
>
|
||||||
|
{band.label}
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-[9px] tracking-widest text-white/40 uppercase">
|
||||||
|
{band.position !== null ? `POS ${band.position}` : "AUTO"} · {techCount} TECH
|
||||||
|
{techCount === 1 ? "" : "S"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="h-px w-full"
|
||||||
|
style={{ background: `linear-gradient(90deg, transparent, ${accent}59, transparent)` }}
|
||||||
|
/>
|
||||||
|
<div className="flex-1" />
|
||||||
|
<div className="border-t border-white/10 bg-black/40 px-3 py-1">
|
||||||
|
<span className="font-mono text-[9px] tracking-widest text-white/35 uppercase">
|
||||||
|
TIER BAND // {band.label.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/* Inert handles so React Flow never treats the band as connectable */}
|
||||||
|
<Handle type="source" position={Position.Bottom} className="!hidden" isConnectable={false} />
|
||||||
|
<Handle type="target" position={Position.Top} className="!hidden" isConnectable={false} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
170
src/components/admin/tech-tree/CategoryPicker.tsx
Normal file
170
src/components/admin/tech-tree/CategoryPicker.tsx
Normal file
|
|
@ -0,0 +1,170 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Check, PlusIcon } from "lucide-react";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
import type { TreeCategory } from "./types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Category picker that both selects existing values and creates a new one
|
||||||
|
* inline (enter a name that does not exist yet, then pick the Create entry).
|
||||||
|
* Deliberately a native input + suggestion list instead of a
|
||||||
|
* Popover/Command combo: the admin dialog's Radix layering closed the
|
||||||
|
* portal'd list mid-flow, making options unclickable.
|
||||||
|
*/
|
||||||
|
export function CategoryPicker({
|
||||||
|
categories,
|
||||||
|
canCreateCategories,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
testIdPrefix = "tech-category",
|
||||||
|
tone = "light",
|
||||||
|
}: {
|
||||||
|
categories: TreeCategory[];
|
||||||
|
canCreateCategories: boolean;
|
||||||
|
value: string | null;
|
||||||
|
onChange: (name: string | null, isNew: boolean) => void;
|
||||||
|
testIdPrefix?: string;
|
||||||
|
/** "dark" matches the tactical panel surface; "light" matches shadcn dialogs. */
|
||||||
|
tone?: "light" | "dark";
|
||||||
|
}) {
|
||||||
|
const [search, setSearch] = React.useState("");
|
||||||
|
const [open, setOpen] = React.useState(false);
|
||||||
|
const dark = tone === "dark";
|
||||||
|
|
||||||
|
const trimmed = search.trim();
|
||||||
|
const exactMatch = categories.some(
|
||||||
|
(c) => c.name.toLowerCase() === trimmed.toLowerCase(),
|
||||||
|
);
|
||||||
|
const showCreate = canCreateCategories && trimmed.length > 0 && !exactMatch;
|
||||||
|
const matches = trimmed
|
||||||
|
? categories.filter((c) => c.name.toLowerCase().includes(trimmed.toLowerCase()))
|
||||||
|
: categories;
|
||||||
|
const selected = value ? categories.find((c) => c.name === value) : undefined;
|
||||||
|
|
||||||
|
const commit = (name: string, isNew: boolean) => {
|
||||||
|
onChange(name, isNew);
|
||||||
|
setSearch("");
|
||||||
|
setOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const pickNew = () => commit(trimmed, true);
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
if (showCreate) pickNew();
|
||||||
|
else if (matches.length === 1) commit(matches[0].name, false);
|
||||||
|
else if (exactMatch) {
|
||||||
|
const match = categories.find((c) => c.name.toLowerCase() === trimmed.toLowerCase())!;
|
||||||
|
commit(match.name, false);
|
||||||
|
}
|
||||||
|
} else if (e.key === "Escape") {
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
value={open ? search : value ?? ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSearch(e.target.value);
|
||||||
|
setOpen(true);
|
||||||
|
}}
|
||||||
|
onFocus={() => {
|
||||||
|
setSearch("");
|
||||||
|
setOpen(true);
|
||||||
|
}}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
onBlur={() => {
|
||||||
|
window.setTimeout(() => setOpen(false), 120);
|
||||||
|
}}
|
||||||
|
placeholder="Select or enter category..."
|
||||||
|
data-testid={`${testIdPrefix}-input`}
|
||||||
|
autoComplete="off"
|
||||||
|
className={
|
||||||
|
dark
|
||||||
|
? "rounded-none border-white/20 bg-black/40 text-[15px] text-white placeholder:text-white/35 focus-visible:border-[#22d3ee]/60 focus-visible:ring-[#22d3ee]/20"
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{value && !open ? (
|
||||||
|
<p
|
||||||
|
className={`mt-1 text-xs ${dark ? "text-white/45" : "text-muted-foreground"}`}
|
||||||
|
data-testid={`${testIdPrefix}-value`}
|
||||||
|
>
|
||||||
|
{selected ? `${selected.name} (position ${selected.position})` : `${value} (new)`}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{open ? (
|
||||||
|
<div
|
||||||
|
className={`absolute top-full left-0 z-[1500] mt-1 w-full border shadow-2xl ${
|
||||||
|
dark ? "rounded-none border-white/20 bg-[#0b0f14]" : "bg-popover rounded-md"
|
||||||
|
}`}
|
||||||
|
data-testid={`${testIdPrefix}-options`}
|
||||||
|
>
|
||||||
|
<div className="tech-tree-scroll max-h-48 overflow-y-auto p-1">
|
||||||
|
{showCreate ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`flex w-full items-center gap-2 bg-transparent px-2 py-1.5 text-left text-sm outline-none ${
|
||||||
|
dark
|
||||||
|
? "rounded-none border-0 border-b border-b-white/[0.06] text-white/85 hover:bg-white/[0.06]"
|
||||||
|
: "rounded-sm hover:bg-accent hover:text-accent-foreground"
|
||||||
|
}`}
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
pickNew();
|
||||||
|
}}
|
||||||
|
data-testid={`${testIdPrefix}-create-option`}
|
||||||
|
>
|
||||||
|
<PlusIcon className="size-4" aria-hidden />
|
||||||
|
<span>
|
||||||
|
Create "{trimmed}"
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
{matches.map((category) => (
|
||||||
|
<button
|
||||||
|
key={category.id}
|
||||||
|
type="button"
|
||||||
|
className={`flex w-full items-center gap-2 px-2 py-1.5 text-left text-sm outline-none ${
|
||||||
|
dark
|
||||||
|
? `rounded-none border-0 border-b border-b-white/[0.06] border-l-2 ${
|
||||||
|
value === category.name
|
||||||
|
? "border-l-[#22d3ee] bg-[#22d3ee]/10 text-[#a5f3fc]"
|
||||||
|
: "border-l-transparent bg-transparent text-white/85 hover:border-l-white/25 hover:bg-white/[0.06]"
|
||||||
|
}`
|
||||||
|
: "rounded-sm bg-transparent hover:bg-accent hover:text-accent-foreground"
|
||||||
|
}`}
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
commit(category.name, false);
|
||||||
|
}}
|
||||||
|
data-testid={`${testIdPrefix}-option-${category.id}`}
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={`size-4 shrink-0 ${value === category.name ? "opacity-100" : "opacity-0"}`}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="block truncate">{category.name}</span>
|
||||||
|
<span className={`block truncate ${dark ? "font-mono text-[10px] tracking-wide text-white/40 uppercase" : "text-xs text-muted-foreground"}`}>
|
||||||
|
Position {category.position}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{matches.length === 0 && !showCreate ? (
|
||||||
|
<p className={`px-2 py-1.5 ${dark ? "font-mono text-xs tracking-wide text-white/40 uppercase" : "text-sm text-muted-foreground"}`}>
|
||||||
|
{trimmed.length > 0 ? `No category matches "${trimmed}".` : "No categories yet."}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
243
src/components/admin/tech-tree/PanelSelect.tsx
Normal file
243
src/components/admin/tech-tree/PanelSelect.tsx
Normal file
|
|
@ -0,0 +1,243 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Check, ChevronDown } from "lucide-react";
|
||||||
|
import React from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
|
||||||
|
export type PanelSelectOption = {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
hint?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface PanelSelectProps {
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
options: PanelSelectOption[];
|
||||||
|
placeholder?: string;
|
||||||
|
testId?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
className?: string;
|
||||||
|
/** Show a search field when the option list is long. */
|
||||||
|
searchable?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Placement = {
|
||||||
|
left: number;
|
||||||
|
top: number;
|
||||||
|
width: number;
|
||||||
|
maxHeight: number;
|
||||||
|
openUp: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const LIST_MAX_HEIGHT = 280;
|
||||||
|
const MIN_LIST_HEIGHT = 140;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dark listbox for the tech tree panel. Deliberately NOT a native <select>:
|
||||||
|
* its popup is browser/OS chrome, ignores the panel's dark styling, and cannot
|
||||||
|
* be themed reliably.
|
||||||
|
*
|
||||||
|
* The option list is rendered through a portal as a `position: fixed` overlay
|
||||||
|
* (above the panel, so it never pushes the panel's content down) with its own
|
||||||
|
* coordinates taken from the trigger. It flips above the trigger when there is
|
||||||
|
* more room up there, and closes on scroll/resize so it can never detach from
|
||||||
|
* its trigger.
|
||||||
|
*/
|
||||||
|
export function PanelSelect({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
options,
|
||||||
|
placeholder = "Select...",
|
||||||
|
testId,
|
||||||
|
disabled = false,
|
||||||
|
className = "",
|
||||||
|
searchable = false,
|
||||||
|
}: PanelSelectProps) {
|
||||||
|
const [open, setOpen] = React.useState(false);
|
||||||
|
const [search, setSearch] = React.useState("");
|
||||||
|
const [placement, setPlacement] = React.useState<Placement | null>(null);
|
||||||
|
const triggerRef = React.useRef<HTMLButtonElement>(null);
|
||||||
|
const listRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const measure = React.useCallback(() => {
|
||||||
|
const trigger = triggerRef.current;
|
||||||
|
if (!trigger) return;
|
||||||
|
const rect = trigger.getBoundingClientRect();
|
||||||
|
const spaceBelow = window.innerHeight - rect.bottom - 8;
|
||||||
|
const spaceAbove = rect.top - 8;
|
||||||
|
const openUp = spaceBelow < LIST_MAX_HEIGHT && spaceAbove > spaceBelow;
|
||||||
|
const available = openUp ? spaceAbove : spaceBelow;
|
||||||
|
setPlacement({
|
||||||
|
left: rect.left,
|
||||||
|
top: openUp ? rect.top - 4 : rect.bottom + 4,
|
||||||
|
width: rect.width,
|
||||||
|
maxHeight: Math.max(MIN_LIST_HEIGHT, Math.min(LIST_MAX_HEIGHT, available)),
|
||||||
|
openUp,
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const openList = () => {
|
||||||
|
measure();
|
||||||
|
setSearch("");
|
||||||
|
setOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const rafRef = React.useRef<number>(0);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keep the overlay glued to its trigger. Scrolling INSIDE the option list is
|
||||||
|
* left alone; any other scroll (the panel body, the window) re-measures the
|
||||||
|
* trigger and repositions, closing only when the trigger has left the
|
||||||
|
* viewport. Closing on every scroll made the list vanish when people simply
|
||||||
|
* scrolled the list itself or the panel behind a short list.
|
||||||
|
*/
|
||||||
|
const handleViewportChange = React.useCallback(
|
||||||
|
(event?: Event) => {
|
||||||
|
const target = event?.target as Node | undefined;
|
||||||
|
if (target && listRef.current?.contains(target)) return;
|
||||||
|
if (rafRef.current) return;
|
||||||
|
rafRef.current = window.requestAnimationFrame(() => {
|
||||||
|
rafRef.current = 0;
|
||||||
|
const trigger = triggerRef.current;
|
||||||
|
if (!trigger) return;
|
||||||
|
const rect = trigger.getBoundingClientRect();
|
||||||
|
if (rect.bottom < 0 || rect.top > window.innerHeight) {
|
||||||
|
setOpen(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
measure();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[measure],
|
||||||
|
);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const onDocumentDown = (event: MouseEvent) => {
|
||||||
|
const target = event.target as Node;
|
||||||
|
if (triggerRef.current?.contains(target)) return;
|
||||||
|
if (listRef.current?.contains(target)) return;
|
||||||
|
setOpen(false);
|
||||||
|
};
|
||||||
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === "Escape") setOpen(false);
|
||||||
|
};
|
||||||
|
document.addEventListener("mousedown", onDocumentDown);
|
||||||
|
document.addEventListener("keydown", onKeyDown);
|
||||||
|
// Capture phase catches scrolling inside the panel's own scroll container.
|
||||||
|
document.addEventListener("scroll", handleViewportChange, true);
|
||||||
|
window.addEventListener("resize", handleViewportChange);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousedown", onDocumentDown);
|
||||||
|
document.removeEventListener("keydown", onKeyDown);
|
||||||
|
document.removeEventListener("scroll", handleViewportChange, true);
|
||||||
|
window.removeEventListener("resize", handleViewportChange);
|
||||||
|
};
|
||||||
|
}, [open, handleViewportChange]);
|
||||||
|
|
||||||
|
const selected = options.find((option) => option.value === value);
|
||||||
|
const showSearch = searchable && options.length > 8;
|
||||||
|
const filtered = search
|
||||||
|
? options.filter((option) => option.label.toLowerCase().includes(search.toLowerCase()))
|
||||||
|
: options;
|
||||||
|
|
||||||
|
const list =
|
||||||
|
open && placement ? (
|
||||||
|
<div
|
||||||
|
ref={listRef}
|
||||||
|
data-testid={testId ? `${testId}-list` : undefined}
|
||||||
|
className="tech-tree-scroll rounded-none border border-white/15 bg-[#0b0f14] shadow-2xl"
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
left: placement.left,
|
||||||
|
top: placement.top,
|
||||||
|
width: placement.width,
|
||||||
|
zIndex: 100,
|
||||||
|
transform: placement.openUp ? "translateY(-100%)" : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between border-b border-white/10 bg-black/50 px-2.5 py-1">
|
||||||
|
<span className="font-mono text-[9px] tracking-widest text-white/45 uppercase">
|
||||||
|
SELECT // {placeholder.replace(/\.\.\.$/, "")}
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-[9px] tracking-widest text-white/30 uppercase tabular-nums">
|
||||||
|
{filtered.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{showSearch ? (
|
||||||
|
<input
|
||||||
|
value={search}
|
||||||
|
onChange={(event) => setSearch(event.target.value)}
|
||||||
|
placeholder="SEARCH..."
|
||||||
|
autoFocus
|
||||||
|
className="w-full border-0 border-b border-white/10 bg-black/50 px-2.5 py-1.5 font-mono text-xs tracking-wide text-white outline-none placeholder:text-white/30"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<div
|
||||||
|
role="listbox"
|
||||||
|
className="tech-tree-scroll overflow-y-auto py-1"
|
||||||
|
style={{ maxHeight: placement.maxHeight - (showSearch ? 36 : 0) }}
|
||||||
|
>
|
||||||
|
{filtered.length === 0 ? (
|
||||||
|
<p className="px-2.5 py-1.5 font-mono text-xs tracking-wide text-white/40 uppercase">
|
||||||
|
NO MATCHES
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{filtered.map((option) => (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
type="button"
|
||||||
|
role="option"
|
||||||
|
aria-selected={option.value === value}
|
||||||
|
data-testid={testId ? `${testId}-option-${option.value}` : undefined}
|
||||||
|
onMouseDown={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
onChange(option.value);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
className={`flex w-full items-center gap-2 border-0 border-b border-l-2 border-b-white/[0.06] bg-transparent px-2.5 py-1.5 text-left text-[14px] outline-none ${
|
||||||
|
option.value === value
|
||||||
|
? "border-l-[#22d3ee] bg-[#22d3ee]/10 text-[#a5f3fc]"
|
||||||
|
: "border-l-transparent text-white/85 hover:border-l-white/25 hover:bg-white/[0.06]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={`size-4 shrink-0 ${option.value === value ? "opacity-100" : "opacity-0"}`}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="block truncate">{option.label}</span>
|
||||||
|
{option.hint ? (
|
||||||
|
<span className="block truncate font-mono text-[10px] tracking-wide text-white/40 uppercase">{option.hint}</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`relative ${className}`}>
|
||||||
|
<button
|
||||||
|
ref={triggerRef}
|
||||||
|
type="button"
|
||||||
|
role="combobox"
|
||||||
|
aria-haspopup="listbox"
|
||||||
|
aria-expanded={open}
|
||||||
|
disabled={disabled}
|
||||||
|
data-testid={testId}
|
||||||
|
onClick={() => (open ? setOpen(false) : openList())}
|
||||||
|
className="flex h-9 w-full items-center justify-between gap-2 rounded-none border border-white/20 bg-black/40 px-2.5 py-1.5 text-left text-[15px] text-white shadow-xs outline-none hover:border-white/35 focus-visible:border-[#22d3ee]/60 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<span className={`truncate ${selected ? "" : "text-white/45"}`}>
|
||||||
|
{selected ? selected.label : placeholder}
|
||||||
|
</span>
|
||||||
|
<ChevronDown className="size-4 shrink-0 opacity-60" aria-hidden />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{typeof document !== "undefined" && list ? createPortal(list, document.body) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
239
src/components/admin/tech-tree/QuickCreateDialog.tsx
Normal file
239
src/components/admin/tech-tree/QuickCreateDialog.tsx
Normal file
|
|
@ -0,0 +1,239 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { PlusIcon } from "lucide-react";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
import { CategoryPicker } from "./CategoryPicker";
|
||||||
|
import type { TreeCategory } from "./types";
|
||||||
|
|
||||||
|
export interface QuickCreateResult {
|
||||||
|
techId: number;
|
||||||
|
techName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface QuickCreateDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
categories: TreeCategory[];
|
||||||
|
canCreateCategories: boolean;
|
||||||
|
onCreated: (result: QuickCreateResult) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Canvas quick-create: minimal fields (name + category) plus the collection's
|
||||||
|
* other required fields, creating inline categories as needed.
|
||||||
|
*/
|
||||||
|
export function QuickCreateDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
categories,
|
||||||
|
canCreateCategories,
|
||||||
|
onCreated,
|
||||||
|
}: QuickCreateDialogProps) {
|
||||||
|
const [name, setName] = React.useState("");
|
||||||
|
const [type, setType] = React.useState<"asset" | "upgrade">("upgrade");
|
||||||
|
const [summary, setSummary] = React.useState("");
|
||||||
|
const [minDuration, setMinDuration] = React.useState(1);
|
||||||
|
const [maxDuration, setMaxDuration] = React.useState(10);
|
||||||
|
const [categoryName, setCategoryName] = React.useState<string | null>(null);
|
||||||
|
const [categoryIsNew, setCategoryIsNew] = React.useState(false);
|
||||||
|
const [busy, setBusy] = React.useState(false);
|
||||||
|
const [error, setError] = React.useState<string | null>(null);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setName("");
|
||||||
|
setType("upgrade");
|
||||||
|
setSummary("");
|
||||||
|
setMinDuration(1);
|
||||||
|
setMaxDuration(10);
|
||||||
|
setCategoryName(null);
|
||||||
|
setCategoryIsNew(false);
|
||||||
|
setError(null);
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const maxPosition = categories.reduce((max, c) => Math.max(max, c.position), 0);
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
setError(null);
|
||||||
|
if (!name.trim()) {
|
||||||
|
setError("Name is required.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!summary.trim()) {
|
||||||
|
setError("Summary is required.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(minDuration) || minDuration < 1 || !Number.isFinite(maxDuration) || maxDuration < 1) {
|
||||||
|
setError("Research durations must be at least 1 tick.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
let categoryId: number | null = null;
|
||||||
|
if (categoryName && categoryIsNew) {
|
||||||
|
const res = await fetch("/api/tech-categories", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ name: categoryName.trim(), position: maxPosition + 1 }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => null);
|
||||||
|
throw new Error(
|
||||||
|
body?.errors?.[0]?.message || `Could not create category (HTTP ${res.status}).`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const body = await res.json();
|
||||||
|
categoryId = body?.doc?.id ?? null;
|
||||||
|
} else if (categoryName) {
|
||||||
|
categoryId = categories.find((c) => c.name === categoryName)?.id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch("/api/technologies", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: name.trim(),
|
||||||
|
type,
|
||||||
|
summary: summary.trim(),
|
||||||
|
category: categoryId,
|
||||||
|
researchCosts: {
|
||||||
|
minimumResearchDuration: Math.max(1, Math.round(minDuration)),
|
||||||
|
maximumResearchDuration: Math.max(1, Math.round(maxDuration)),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => null);
|
||||||
|
throw new Error(
|
||||||
|
body?.errors?.[0]?.message || `Could not create technology (HTTP ${res.status}).`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const body = await res.json();
|
||||||
|
const techId = body?.doc?.id;
|
||||||
|
if (!techId) throw new Error("Technology was created but no id came back.");
|
||||||
|
onCreated({ techId, techName: name.trim() });
|
||||||
|
onOpenChange(false);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "Unknown error.");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="rounded-none sm:max-w-[440px]" data-testid="tech-tree-create-dialog">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="font-mono text-xs tracking-widest text-white/70 uppercase">
|
||||||
|
New Technology // Quick Create
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="tech-create-name">Name</Label>
|
||||||
|
<Input
|
||||||
|
id="tech-create-name"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="e.g. Composite Armor"
|
||||||
|
data-testid="tech-create-name"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="tech-create-type">Type</Label>
|
||||||
|
<select
|
||||||
|
id="tech-create-type"
|
||||||
|
value={type}
|
||||||
|
onChange={(e) => setType(e.target.value as "asset" | "upgrade")}
|
||||||
|
data-testid="tech-create-type"
|
||||||
|
className="border-input bg-transparent flex h-9 w-full rounded-md border px-3 py-2 text-sm shadow-xs outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50"
|
||||||
|
>
|
||||||
|
<option value="upgrade">Upgrade</option>
|
||||||
|
<option value="asset">Asset</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="tech-create-summary">Summary</Label>
|
||||||
|
<Textarea
|
||||||
|
id="tech-create-summary"
|
||||||
|
value={summary}
|
||||||
|
onChange={(e) => setSummary(e.target.value)}
|
||||||
|
placeholder="One or two sentences."
|
||||||
|
rows={3}
|
||||||
|
data-testid="tech-create-summary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>Category</Label>
|
||||||
|
<CategoryPicker
|
||||||
|
categories={categories}
|
||||||
|
canCreateCategories={canCreateCategories}
|
||||||
|
value={categoryName}
|
||||||
|
onChange={(nextName, isNew) => {
|
||||||
|
setCategoryName(nextName);
|
||||||
|
setCategoryIsNew(isNew);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{!canCreateCategories ? (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
You can select existing categories; creating new ones needs the
|
||||||
|
tech-categories:create permission.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="tech-create-min-duration">Min research (ticks)</Label>
|
||||||
|
<Input
|
||||||
|
id="tech-create-min-duration"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={minDuration}
|
||||||
|
onChange={(e) => setMinDuration(Number(e.target.value))}
|
||||||
|
data-testid="tech-create-min-duration"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="tech-create-max-duration">Max research (ticks)</Label>
|
||||||
|
<Input
|
||||||
|
id="tech-create-max-duration"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={maxDuration}
|
||||||
|
onChange={(e) => setMaxDuration(Number(e.target.value))}
|
||||||
|
data-testid="tech-create-max-duration"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{error ? (
|
||||||
|
<p className="border border-red-400/40 bg-red-400/10 px-2 py-1.5 text-xs text-red-300" data-testid="tech-create-error">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={busy}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={submit} disabled={busy} data-testid="tech-create-submit">
|
||||||
|
<PlusIcon className="size-4" />
|
||||||
|
{busy ? "Creating..." : "Create"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
128
src/components/admin/tech-tree/TechNode.tsx
Normal file
128
src/components/admin/tech-tree/TechNode.tsx
Normal file
|
|
@ -0,0 +1,128 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Handle, Position, type Node, type NodeProps } from "@xyflow/react";
|
||||||
|
import { TriangleAlertIcon } from "lucide-react";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
import type { TreeTech } from "./types";
|
||||||
|
|
||||||
|
export type TechNodeData = {
|
||||||
|
tech: TreeTech;
|
||||||
|
categoryName: string | null;
|
||||||
|
refLabel: string;
|
||||||
|
hasWarning: boolean;
|
||||||
|
connectable: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TechNodeModel = Node<TechNodeData, "techCard">;
|
||||||
|
|
||||||
|
const STATUS_LABELS: Record<string, { label: string; className: string }> = {
|
||||||
|
in_progress: { label: "In Progress", className: "text-blue-400" },
|
||||||
|
ready_for_review: { label: "Ready for Review", className: "text-amber-400" },
|
||||||
|
approved: { label: "Approved", className: "text-emerald-400" },
|
||||||
|
rejected: { label: "Rejected", className: "text-red-400" },
|
||||||
|
revision_requested: { label: "Revision Requested", className: "text-orange-400" },
|
||||||
|
};
|
||||||
|
|
||||||
|
function CornerBrackets() {
|
||||||
|
const base = "pointer-events-none absolute size-2 border-white/25";
|
||||||
|
return (
|
||||||
|
<div aria-hidden className="pointer-events-none absolute inset-1">
|
||||||
|
<span className={`${base} top-0 left-0 border-t border-l`} />
|
||||||
|
<span className={`${base} top-0 right-0 border-t border-r`} />
|
||||||
|
<span className={`${base} bottom-0 left-0 border-b border-l`} />
|
||||||
|
<span className={`${base} right-0 bottom-0 border-r border-b`} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Technology card node: tactical dossier styling (squared corners,
|
||||||
|
* classification strip, corner brackets, mono readouts).
|
||||||
|
*/
|
||||||
|
export function TechNodeCard({ data, selected }: NodeProps<TechNodeModel>) {
|
||||||
|
const { tech, categoryName, refLabel, hasWarning, connectable } = data;
|
||||||
|
const status = STATUS_LABELS[tech.approvalStatus] ?? {
|
||||||
|
label: tech.approvalStatus,
|
||||||
|
className: "text-white/60",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-testid={`tech-node-${tech.id}`}
|
||||||
|
className="relative h-full w-full"
|
||||||
|
>
|
||||||
|
{/* Clipped card surface (the handles live outside this box) */}
|
||||||
|
<div
|
||||||
|
className={`flex h-full w-full flex-col overflow-hidden rounded-none border bg-black/60 backdrop-blur-sm transition-colors ${
|
||||||
|
selected
|
||||||
|
? "border-[#22d3ee]/70 shadow-[0_0_18px_rgba(34,211,238,0.15)]"
|
||||||
|
: "border-white/15 hover:border-white/40"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<CornerBrackets />
|
||||||
|
|
||||||
|
{/* Classification strip */}
|
||||||
|
<div className="flex shrink-0 items-center justify-between gap-2 border-b border-white/15 bg-black/50 px-3.5 py-1.5">
|
||||||
|
<span className="truncate font-mono text-[8px] tracking-widest text-white/50 uppercase">
|
||||||
|
TECH // {categoryName ?? "UNFILED"}
|
||||||
|
</span>
|
||||||
|
<span className="shrink-0 font-mono text-[8px] tracking-widest text-white/40 uppercase">
|
||||||
|
{refLabel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div className="flex-1 px-3.5 py-2.5">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<span className="line-clamp-2 text-sm leading-snug font-semibold text-white">
|
||||||
|
{tech.name}
|
||||||
|
</span>
|
||||||
|
{hasWarning ? (
|
||||||
|
<TriangleAlertIcon
|
||||||
|
className="mt-0.5 size-3.5 shrink-0 text-amber-400"
|
||||||
|
aria-label="Timeline warning"
|
||||||
|
data-testid={`tech-warning-${tech.id}`}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex items-center gap-2">
|
||||||
|
<span className="rounded-sm border border-white/15 px-1 py-px font-mono text-[8px] tracking-widest text-white/60 uppercase">
|
||||||
|
{tech.type}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`font-mono text-[9px] tracking-widest uppercase ${status.className}`}
|
||||||
|
>
|
||||||
|
{status.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer readout */}
|
||||||
|
<div className="flex shrink-0 items-center justify-between border-t border-white/15 bg-black/60 px-3.5 py-1.5">
|
||||||
|
<span className="font-mono text-[9px] tracking-widest text-white/50 uppercase tabular-nums">
|
||||||
|
{tech.type === "asset" ? "UNLOCK" : "UPGRADE"}
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-[9px] tracking-widest text-white/35 uppercase">
|
||||||
|
{tech.categoryId ? "BAND" : "ZONE"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Handle
|
||||||
|
type="target"
|
||||||
|
position={Position.Left}
|
||||||
|
className="!bg-white/45 hover:!bg-[#22d3ee]"
|
||||||
|
style={{ width: 11, height: 11, border: "1px solid rgba(0,0,0,0.6)" }}
|
||||||
|
isConnectable={connectable}
|
||||||
|
/>
|
||||||
|
<Handle
|
||||||
|
type="source"
|
||||||
|
position={Position.Right}
|
||||||
|
className="!bg-white/45 hover:!bg-[#22d3ee]"
|
||||||
|
style={{ width: 11, height: 11, border: "1px solid rgba(0,0,0,0.6)" }}
|
||||||
|
isConnectable={connectable}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
677
src/components/admin/tech-tree/TechPreviewPanel.tsx
Normal file
677
src/components/admin/tech-tree/TechPreviewPanel.tsx
Normal file
|
|
@ -0,0 +1,677 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { PencilIcon, PlusIcon, RotateCcwIcon, SaveIcon, XIcon } from "lucide-react";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
import { CategoryPicker } from "./CategoryPicker";
|
||||||
|
import { PanelSelect } from "./PanelSelect";
|
||||||
|
import { buildPrereqRows } from "./prereqEdits";
|
||||||
|
import type { TreeCategory, TreeTech } from "./types";
|
||||||
|
|
||||||
|
/** Dark tactical form chrome for the panel (it sits on a dark surface). */
|
||||||
|
const PANEL_CONTROL =
|
||||||
|
"rounded-none border-white/20 bg-black/40 text-[15px] text-white placeholder:text-white/35 focus-visible:border-[#22d3ee]/60 focus-visible:ring-[#22d3ee]/20";
|
||||||
|
const PANEL_BUTTON =
|
||||||
|
"rounded-none border border-white/20 bg-black/50 font-mono text-[10px] tracking-widest uppercase text-white/80 hover:border-white/45 hover:bg-white/10 hover:text-white";
|
||||||
|
const PANEL_BUTTON_ACCENT =
|
||||||
|
"rounded-none border border-[#22d3ee]/50 bg-[#22d3ee]/10 font-mono text-[10px] tracking-widest uppercase text-[#67e8f9] hover:border-[#22d3ee]/80 hover:bg-[#22d3ee]/20 hover:text-[#a5f3fc] disabled:opacity-40";
|
||||||
|
|
||||||
|
const STATUS_OPTIONS: { value: string; label: string }[] = [
|
||||||
|
{ value: "in_progress", label: "In Progress" },
|
||||||
|
{ value: "ready_for_review", label: "Ready for Review" },
|
||||||
|
{ value: "approved", label: "Approved" },
|
||||||
|
{ value: "rejected", label: "Rejected" },
|
||||||
|
{ value: "revision_requested", label: "Revision Requested" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const EDITABLE_STATUS_VALUES = new Set(["in_progress", "ready_for_review"]);
|
||||||
|
|
||||||
|
type CostRow = { resource: number | ""; amount: number };
|
||||||
|
|
||||||
|
type Draft = {
|
||||||
|
name: string;
|
||||||
|
type: "asset" | "upgrade";
|
||||||
|
approvalStatus: string;
|
||||||
|
summary: string;
|
||||||
|
categoryName: string | null;
|
||||||
|
categoryIsNew: boolean;
|
||||||
|
prereqAnd: number[];
|
||||||
|
prereqOr: number[];
|
||||||
|
costs: CostRow[];
|
||||||
|
minDuration: number;
|
||||||
|
maxDuration: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function draftFromTech(tech: TreeTech, categoryName: string | null): Draft {
|
||||||
|
return {
|
||||||
|
name: tech.name,
|
||||||
|
type: tech.type,
|
||||||
|
approvalStatus: tech.approvalStatus,
|
||||||
|
summary: tech.summary,
|
||||||
|
categoryName,
|
||||||
|
categoryIsNew: false,
|
||||||
|
prereqAnd: [...tech.prereqAnd],
|
||||||
|
prereqOr: [...tech.prereqOr],
|
||||||
|
costs: tech.resourceCosts.map((c) => ({ resource: c.resource, amount: c.amount })),
|
||||||
|
minDuration: 1,
|
||||||
|
maxDuration: 10,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TechPreviewPanelProps {
|
||||||
|
tech: TreeTech;
|
||||||
|
categories: TreeCategory[];
|
||||||
|
resources: { id: number; name: string }[];
|
||||||
|
allTechs: TreeTech[];
|
||||||
|
canUpdate: boolean;
|
||||||
|
canCreateCategories: boolean;
|
||||||
|
canSetFinalApproval: boolean;
|
||||||
|
warnings: string[];
|
||||||
|
onClose: () => void;
|
||||||
|
onEdit: () => void;
|
||||||
|
onSaved: (updated: TechTechLike) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shape returned by the REST update (only the fields the tree reads). */
|
||||||
|
export type TechTechLike = Partial<TreeTech> & { id: number };
|
||||||
|
|
||||||
|
function SectionLabel({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<p className="mb-1.5 font-mono text-[10px] tracking-widest text-white/45 uppercase">
|
||||||
|
{children}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Side dossier panel for the selected technology: a "lite" editor over the
|
||||||
|
* fields it displays (name, type, approval status, summary, category,
|
||||||
|
* prerequisites, research costs) plus a jump to the full admin edit view.
|
||||||
|
*/
|
||||||
|
export function TechPreviewPanel({
|
||||||
|
tech,
|
||||||
|
categories,
|
||||||
|
resources,
|
||||||
|
allTechs,
|
||||||
|
canUpdate,
|
||||||
|
canCreateCategories,
|
||||||
|
canSetFinalApproval,
|
||||||
|
warnings,
|
||||||
|
onClose,
|
||||||
|
onEdit,
|
||||||
|
onSaved,
|
||||||
|
}: TechPreviewPanelProps) {
|
||||||
|
const categoryName =
|
||||||
|
tech.categoryId ? categories.find((c) => c.id === tech.categoryId)?.name ?? null : null;
|
||||||
|
|
||||||
|
const [draft, setDraft] = React.useState<Draft>(() => draftFromTech(tech, categoryName));
|
||||||
|
const [busy, setBusy] = React.useState(false);
|
||||||
|
const [error, setError] = React.useState<string | null>(null);
|
||||||
|
const [savedAt, setSavedAt] = React.useState<number | null>(null);
|
||||||
|
|
||||||
|
// Re-seed the draft when a different technology is selected.
|
||||||
|
React.useEffect(() => {
|
||||||
|
setDraft(draftFromTech(tech, categoryName));
|
||||||
|
setError(null);
|
||||||
|
setSavedAt(null);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [tech.id]);
|
||||||
|
|
||||||
|
const baseline = React.useMemo(() => draftFromTech(tech, categoryName), [tech, categoryName]);
|
||||||
|
const dirty = JSON.stringify(draft) !== JSON.stringify(baseline);
|
||||||
|
|
||||||
|
const set = <K extends keyof Draft>(key: K, value: Draft[K]) =>
|
||||||
|
setDraft((prev) => ({ ...prev, [key]: value }));
|
||||||
|
|
||||||
|
const nameOf = (id: number) => allTechs.find((t) => t.id === id)?.name ?? `#${id}`;
|
||||||
|
const resourceName = (id: number) => resources.find((r) => r.id === id)?.name ?? `#${id}`;
|
||||||
|
|
||||||
|
const statusLocked =
|
||||||
|
!canSetFinalApproval && !EDITABLE_STATUS_VALUES.has(draft.approvalStatus);
|
||||||
|
|
||||||
|
const addableTechs = React.useMemo(
|
||||||
|
() =>
|
||||||
|
allTechs
|
||||||
|
.filter(
|
||||||
|
(t) =>
|
||||||
|
t.id !== tech.id &&
|
||||||
|
!draft.prereqAnd.includes(t.id) &&
|
||||||
|
!draft.prereqOr.includes(t.id),
|
||||||
|
)
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name)),
|
||||||
|
[allTechs, tech.id, draft.prereqAnd, draft.prereqOr],
|
||||||
|
);
|
||||||
|
const [prereqToAdd, setPrereqToAdd] = React.useState<string>("");
|
||||||
|
const [prereqKind, setPrereqKind] = React.useState<"and" | "or">("and");
|
||||||
|
|
||||||
|
const movePrereq = (id: number, kind: "and" | "or") => {
|
||||||
|
setDraft((prev) => ({
|
||||||
|
...prev,
|
||||||
|
prereqAnd:
|
||||||
|
kind === "and"
|
||||||
|
? [...new Set([...prev.prereqAnd, id])]
|
||||||
|
: prev.prereqAnd.filter((x) => x !== id),
|
||||||
|
prereqOr:
|
||||||
|
kind === "or"
|
||||||
|
? [...new Set([...prev.prereqOr, id])]
|
||||||
|
: prev.prereqOr.filter((x) => x !== id),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const removePrereq = (id: number) =>
|
||||||
|
setDraft((prev) => ({
|
||||||
|
...prev,
|
||||||
|
prereqAnd: prev.prereqAnd.filter((x) => x !== id),
|
||||||
|
prereqOr: prev.prereqOr.filter((x) => x !== id),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
setError(null);
|
||||||
|
if (!draft.name.trim()) {
|
||||||
|
setError("Name is required.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!draft.summary.trim()) {
|
||||||
|
setError("Summary is required.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (draft.minDuration < 1 || draft.maxDuration < 1) {
|
||||||
|
setError("Research durations must be at least 1 tick.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const costRows = draft.costs.filter((c) => c.resource !== "");
|
||||||
|
if (costRows.some((c) => !Number.isFinite(c.amount) || c.amount < 0)) {
|
||||||
|
setError("Resource amounts must be zero or more.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
let categoryId: number | null = null;
|
||||||
|
if (draft.categoryName && draft.categoryIsNew) {
|
||||||
|
const maxPosition = categories.reduce((max, c) => Math.max(max, c.position), 0);
|
||||||
|
const res = await fetch("/api/tech-categories", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ name: draft.categoryName.trim(), position: maxPosition + 1 }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => null);
|
||||||
|
throw new Error(body?.errors?.[0]?.message || `Could not create category (HTTP ${res.status}).`);
|
||||||
|
}
|
||||||
|
categoryId = (await res.json())?.doc?.id ?? null;
|
||||||
|
} else if (draft.categoryName) {
|
||||||
|
categoryId = categories.find((c) => c.name === draft.categoryName)?.id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: Record<string, unknown> = {
|
||||||
|
name: draft.name.trim(),
|
||||||
|
type: draft.type,
|
||||||
|
summary: draft.summary.trim(),
|
||||||
|
category: categoryId,
|
||||||
|
requirements: {
|
||||||
|
prerequisiteTechnologies: buildPrereqRows({
|
||||||
|
and: draft.prereqAnd,
|
||||||
|
or: draft.prereqOr,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
researchCosts: {
|
||||||
|
resources: costRows.map((c) => ({ resource: c.resource, amount: c.amount })),
|
||||||
|
minimumResearchDuration: Math.max(1, Math.round(draft.minDuration)),
|
||||||
|
maximumResearchDuration: Math.max(1, Math.round(draft.maxDuration)),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if (!statusLocked) data.approvalStatus = draft.approvalStatus;
|
||||||
|
|
||||||
|
const res = await fetch(`/api/technologies/${tech.id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => null);
|
||||||
|
throw new Error(body?.errors?.[0]?.message || `Save failed (HTTP ${res.status}).`);
|
||||||
|
}
|
||||||
|
setSavedAt(Date.now());
|
||||||
|
onSaved({ ...tech, id: tech.id });
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "Unknown error.");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside
|
||||||
|
data-testid="tech-preview-panel"
|
||||||
|
className="absolute top-4 right-4 bottom-4 z-20 flex w-[400px] flex-col rounded-none border border-white/15 bg-black/75 backdrop-blur-md"
|
||||||
|
>
|
||||||
|
{/* Classification strip */}
|
||||||
|
<div className="flex shrink-0 items-center justify-between border-b border-white/15 bg-black/50 px-4 py-2.5">
|
||||||
|
<span className="font-mono text-[10px] tracking-widest text-white/60 uppercase">
|
||||||
|
TECH DOSSIER // {canUpdate ? "EDIT" : "READ"}
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-[10px] tracking-widest text-white/40 uppercase">
|
||||||
|
REF {tech.id.toString().padStart(3, "0")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex shrink-0 items-start justify-between gap-2 border-b border-white/10 px-4 py-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h2 className="truncate text-lg leading-tight font-semibold text-white">
|
||||||
|
{draft.name || "Untitled"}
|
||||||
|
</h2>
|
||||||
|
<p className="mt-1 font-mono text-[10px] tracking-widest text-white/45 uppercase">
|
||||||
|
{draft.categoryName ? `CAT: ${draft.categoryName}` : "CAT: UNFILED"}
|
||||||
|
{dirty ? " // UNSAVED" : ""}
|
||||||
|
{savedAt && !dirty ? " // SAVED" : ""}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="size-8 shrink-0 bg-transparent text-white/70 hover:bg-white/10 hover:text-white"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="Close preview"
|
||||||
|
data-testid="tech-preview-close"
|
||||||
|
>
|
||||||
|
<XIcon className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div className="tech-tree-scroll flex-1 space-y-5 overflow-y-auto px-4 py-4">
|
||||||
|
{canUpdate ? (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<SectionLabel>Name</SectionLabel>
|
||||||
|
<Input
|
||||||
|
value={draft.name}
|
||||||
|
onChange={(e) => set("name", e.target.value)}
|
||||||
|
data-testid="tech-panel-name"
|
||||||
|
className={PANEL_CONTROL}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<SectionLabel>Type</SectionLabel>
|
||||||
|
<PanelSelect
|
||||||
|
value={draft.type}
|
||||||
|
onChange={(value) => set("type", value as "asset" | "upgrade")}
|
||||||
|
options={[
|
||||||
|
{ value: "upgrade", label: "Upgrade" },
|
||||||
|
{ value: "asset", label: "Asset" },
|
||||||
|
]}
|
||||||
|
testId="tech-panel-type"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<SectionLabel>Approval Status</SectionLabel>
|
||||||
|
{statusLocked ? (
|
||||||
|
<p className="text-[15px] text-white/85" data-testid="tech-panel-status-locked">
|
||||||
|
{STATUS_OPTIONS.find((o) => o.value === draft.approvalStatus)?.label ??
|
||||||
|
draft.approvalStatus}
|
||||||
|
<span className="mt-1 block text-xs text-white/45">
|
||||||
|
Final states are set by superusers.
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<PanelSelect
|
||||||
|
value={draft.approvalStatus}
|
||||||
|
onChange={(value) => set("approvalStatus", value)}
|
||||||
|
options={STATUS_OPTIONS.filter(
|
||||||
|
(o) => canSetFinalApproval || EDITABLE_STATUS_VALUES.has(o.value),
|
||||||
|
).map((o) => ({ value: o.value, label: o.label }))}
|
||||||
|
testId="tech-panel-status"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<SectionLabel>Summary</SectionLabel>
|
||||||
|
<Textarea
|
||||||
|
value={draft.summary}
|
||||||
|
onChange={(e) => set("summary", e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
data-testid="tech-panel-summary"
|
||||||
|
className={PANEL_CONTROL}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<SectionLabel>Category</SectionLabel>
|
||||||
|
<CategoryPicker
|
||||||
|
categories={categories}
|
||||||
|
canCreateCategories={canCreateCategories}
|
||||||
|
value={draft.categoryName}
|
||||||
|
onChange={(name, isNew) =>
|
||||||
|
setDraft((prev) => ({ ...prev, categoryName: name, categoryIsNew: isNew }))
|
||||||
|
}
|
||||||
|
testIdPrefix="tech-panel-category"
|
||||||
|
tone="dark"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<SectionLabel>Type</SectionLabel>
|
||||||
|
<p className="text-[15px] text-white/85 capitalize">{draft.type}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<SectionLabel>Approval Status</SectionLabel>
|
||||||
|
<p className="text-[15px] text-white/85">
|
||||||
|
{STATUS_OPTIONS.find((o) => o.value === draft.approvalStatus)?.label ??
|
||||||
|
draft.approvalStatus}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<SectionLabel>Summary</SectionLabel>
|
||||||
|
<p className="text-[15px] leading-relaxed text-white/80">
|
||||||
|
{draft.summary || "None."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Prerequisites */}
|
||||||
|
<div>
|
||||||
|
<SectionLabel>Prerequisites</SectionLabel>
|
||||||
|
{draft.prereqAnd.length === 0 && draft.prereqOr.length === 0 ? (
|
||||||
|
<p className="text-[15px] text-white/50">No prerequisites.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
["and", draft.prereqAnd],
|
||||||
|
["or", draft.prereqOr],
|
||||||
|
] as const
|
||||||
|
).map(([kind, ids]) =>
|
||||||
|
ids.length === 0 ? null : (
|
||||||
|
<div key={kind}>
|
||||||
|
<p className="mb-1 font-mono text-[10px] tracking-widest text-white/40 uppercase">
|
||||||
|
{kind.toUpperCase()}
|
||||||
|
</p>
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{ids.map((id) => (
|
||||||
|
<li
|
||||||
|
key={`${kind}-${id}`}
|
||||||
|
className="flex items-center justify-between gap-2 border border-white/10 bg-black/40 px-2.5 py-1.5"
|
||||||
|
data-testid={`tech-panel-prereq-${kind.toLowerCase()}-${id}`}
|
||||||
|
>
|
||||||
|
<span className="truncate text-[15px] text-white/85">{nameOf(id)}</span>
|
||||||
|
{canUpdate ? (
|
||||||
|
<span className="flex shrink-0 items-center gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="border border-white/20 bg-black/40 px-1.5 py-0.5 font-mono text-[9px] tracking-widest text-white/60 uppercase hover:border-white/45 hover:text-white"
|
||||||
|
onClick={() => movePrereq(id, kind === "and" ? "or" : "and")}
|
||||||
|
title={`Move to ${kind === "and" ? "OR" : "AND"}`}
|
||||||
|
data-testid={`tech-panel-prereq-${id}-toggle`}
|
||||||
|
>
|
||||||
|
{kind === "and" ? "→OR" : "→AND"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="bg-transparent text-white/50 hover:text-red-400"
|
||||||
|
onClick={() => removePrereq(id)}
|
||||||
|
aria-label={`Remove ${nameOf(id)}`}
|
||||||
|
data-testid={`tech-panel-prereq-${id}-remove`}
|
||||||
|
>
|
||||||
|
<XIcon className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{canUpdate && addableTechs.length > 0 ? (
|
||||||
|
<div className="mt-2 flex items-start gap-1.5">
|
||||||
|
<PanelSelect
|
||||||
|
value={prereqToAdd}
|
||||||
|
onChange={setPrereqToAdd}
|
||||||
|
placeholder="Add prerequisite..."
|
||||||
|
options={addableTechs.map((t) => ({ value: String(t.id), label: t.name }))}
|
||||||
|
testId="tech-panel-add-prereq"
|
||||||
|
searchable
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<PanelSelect
|
||||||
|
value={prereqKind}
|
||||||
|
onChange={(value) => setPrereqKind(value as "and" | "or")}
|
||||||
|
options={[
|
||||||
|
{ value: "and", label: "AND" },
|
||||||
|
{ value: "or", label: "OR" },
|
||||||
|
]}
|
||||||
|
testId="tech-panel-add-prereq-kind"
|
||||||
|
className="w-24 shrink-0"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={!prereqToAdd}
|
||||||
|
onClick={() => {
|
||||||
|
if (!prereqToAdd) return;
|
||||||
|
movePrereq(Number(prereqToAdd), prereqKind);
|
||||||
|
setPrereqToAdd("");
|
||||||
|
}}
|
||||||
|
data-testid="tech-panel-add-prereq-confirm"
|
||||||
|
className={`shrink-0 ${PANEL_BUTTON}`}
|
||||||
|
>
|
||||||
|
<PlusIcon className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Research costs */}
|
||||||
|
<div>
|
||||||
|
<SectionLabel>Research Costs</SectionLabel>
|
||||||
|
{canUpdate ? (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{draft.costs.length === 0 ? (
|
||||||
|
<p className="text-[15px] text-white/50">No resource costs recorded.</p>
|
||||||
|
) : null}
|
||||||
|
{draft.costs.map((cost, index) => (
|
||||||
|
<div key={index} className="flex items-start gap-1.5">
|
||||||
|
<PanelSelect
|
||||||
|
value={cost.resource === "" ? "" : String(cost.resource)}
|
||||||
|
onChange={(value) =>
|
||||||
|
setDraft((prev) => ({
|
||||||
|
...prev,
|
||||||
|
costs: prev.costs.map((c, i) =>
|
||||||
|
i === index
|
||||||
|
? { ...c, resource: value === "" ? "" : Number(value) }
|
||||||
|
: c,
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
placeholder="Select resource..."
|
||||||
|
options={resources.map((r) => ({ value: String(r.id), label: r.name }))}
|
||||||
|
testId={`tech-panel-cost-${index}-resource`}
|
||||||
|
searchable
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
value={cost.amount}
|
||||||
|
onChange={(e) =>
|
||||||
|
setDraft((prev) => ({
|
||||||
|
...prev,
|
||||||
|
costs: prev.costs.map((c, i) =>
|
||||||
|
i === index ? { ...c, amount: Number(e.target.value) } : c,
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
className={`${PANEL_CONTROL} w-24 shrink-0`}
|
||||||
|
data-testid={`tech-panel-cost-${index}-amount`}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="shrink-0 bg-transparent text-white/50 hover:text-red-400"
|
||||||
|
onClick={() =>
|
||||||
|
setDraft((prev) => ({
|
||||||
|
...prev,
|
||||||
|
costs: prev.costs.filter((_, i) => i !== index),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
aria-label="Remove cost"
|
||||||
|
>
|
||||||
|
<XIcon className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() =>
|
||||||
|
setDraft((prev) => ({
|
||||||
|
...prev,
|
||||||
|
costs: [...prev.costs, { resource: "", amount: 1 }],
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
data-testid="tech-panel-cost-add"
|
||||||
|
className={PANEL_BUTTON}
|
||||||
|
>
|
||||||
|
<PlusIcon className="size-4" />
|
||||||
|
Add resource
|
||||||
|
</Button>
|
||||||
|
<div className="grid grid-cols-2 gap-3 pt-1">
|
||||||
|
<div>
|
||||||
|
<SectionLabel>Min duration (ticks)</SectionLabel>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={draft.minDuration}
|
||||||
|
onChange={(e) => set("minDuration", Number(e.target.value))}
|
||||||
|
className={PANEL_CONTROL}
|
||||||
|
data-testid="tech-panel-min-duration"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<SectionLabel>Max duration (ticks)</SectionLabel>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={draft.maxDuration}
|
||||||
|
onChange={(e) => set("maxDuration", Number(e.target.value))}
|
||||||
|
className={PANEL_CONTROL}
|
||||||
|
data-testid="tech-panel-max-duration"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : draft.costs.length > 0 ? (
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{draft.costs.map((cost, i) => (
|
||||||
|
<li
|
||||||
|
key={i}
|
||||||
|
className="flex items-center justify-between border border-white/10 bg-black/40 px-2.5 py-1.5"
|
||||||
|
>
|
||||||
|
<span className="truncate text-[15px] text-white/80">
|
||||||
|
{cost.resource === "" ? "Unset" : resourceName(cost.resource)}
|
||||||
|
</span>
|
||||||
|
<span className="ml-2 font-mono text-sm text-white/60 tabular-nums">
|
||||||
|
{cost.amount}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<p className="text-[15px] text-white/50">No resource costs recorded.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{warnings.length > 0 ? (
|
||||||
|
<div className="border border-amber-400/40 bg-amber-400/10 px-2.5 py-2">
|
||||||
|
<SectionLabel>Timeline Warnings</SectionLabel>
|
||||||
|
<ul className="list-inside list-disc space-y-1">
|
||||||
|
{warnings.map((warning, i) => (
|
||||||
|
<li key={i} className="text-sm leading-relaxed text-amber-300">
|
||||||
|
{warning}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<p
|
||||||
|
className="border border-red-400/40 bg-red-400/10 px-2.5 py-1.5 text-sm text-red-300"
|
||||||
|
data-testid="tech-panel-error"
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Readout footer */}
|
||||||
|
<div className="flex shrink-0 border-t border-white/15 bg-black/60 backdrop-blur-sm">
|
||||||
|
<div className="flex-1 border-r border-white/15 px-4 py-2">
|
||||||
|
<p className="font-mono text-sm text-white/85 tabular-nums">
|
||||||
|
{draft.type.toUpperCase()}
|
||||||
|
</p>
|
||||||
|
<p className="font-mono text-[10px] tracking-widest text-white/45 uppercase">KIND</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 px-4 py-2">
|
||||||
|
<p className="font-mono text-sm text-white/85 tabular-nums">
|
||||||
|
{draft.prereqAnd.length + draft.prereqOr.length}
|
||||||
|
</p>
|
||||||
|
<p className="font-mono text-[10px] tracking-widest text-white/45 uppercase">PREREQS</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex shrink-0 items-center gap-2 border-t border-white/15 p-3">
|
||||||
|
{canUpdate ? (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
onClick={save}
|
||||||
|
disabled={busy || !dirty}
|
||||||
|
className={`flex-1 ${PANEL_BUTTON_ACCENT}`}
|
||||||
|
data-testid="tech-panel-save"
|
||||||
|
>
|
||||||
|
<SaveIcon className="size-4" />
|
||||||
|
{busy ? "Saving..." : dirty ? "Save changes" : "Saved"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
disabled={busy || !dirty}
|
||||||
|
onClick={() => {
|
||||||
|
setDraft(draftFromTech(tech, categoryName));
|
||||||
|
setError(null);
|
||||||
|
}}
|
||||||
|
title="Discard changes"
|
||||||
|
data-testid="tech-panel-reset"
|
||||||
|
className={PANEL_BUTTON}
|
||||||
|
>
|
||||||
|
<RotateCcwIcon className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
<Button
|
||||||
|
variant={canUpdate ? "outline" : "default"}
|
||||||
|
onClick={onEdit}
|
||||||
|
className={`${canUpdate ? "flex-1" : "w-full"} ${PANEL_BUTTON}`}
|
||||||
|
data-testid="tech-preview-edit"
|
||||||
|
>
|
||||||
|
<PencilIcon className="size-4" />
|
||||||
|
Edit in full view
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
694
src/components/admin/tech-tree/TechTreeCanvas.tsx
Normal file
694
src/components/admin/tech-tree/TechTreeCanvas.tsx
Normal file
|
|
@ -0,0 +1,694 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Background,
|
||||||
|
BackgroundVariant,
|
||||||
|
Controls,
|
||||||
|
MarkerType,
|
||||||
|
ReactFlow,
|
||||||
|
useEdgesState,
|
||||||
|
useNodesState,
|
||||||
|
type Edge,
|
||||||
|
type EdgeChange,
|
||||||
|
type Node,
|
||||||
|
type NodeChange,
|
||||||
|
type NodeTypes,
|
||||||
|
ReactFlowProvider,
|
||||||
|
} from "@xyflow/react";
|
||||||
|
import "@xyflow/react/dist/style.css";
|
||||||
|
import { LayoutGridIcon, PlusIcon, SaveIcon, TriangleAlertIcon } from "lucide-react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
import { CategoryBand, type CategoryBandData, type CategoryBandNode } from "./CategoryBand";
|
||||||
|
import { edgeVisuals, type EdgeRef } from "./edgeVisuals";
|
||||||
|
import { applyPins, pinsToFreeze, type CanvasPosition } from "./freeze";
|
||||||
|
import { computeLayout } from "./layout";
|
||||||
|
import { mapCategoryDoc, mapTechDoc } from "./mappers";
|
||||||
|
import { addPrerequisite, buildPrereqRows, removePrerequisite, withPrereqs } from "./prereqEdits";
|
||||||
|
import { QuickCreateDialog, type QuickCreateResult } from "./QuickCreateDialog";
|
||||||
|
import { TechNodeCard, type TechNodeData, type TechNodeModel } from "./TechNode";
|
||||||
|
import { TechPreviewPanel } from "./TechPreviewPanel";
|
||||||
|
import {
|
||||||
|
COLUMN_STEP,
|
||||||
|
NODE_HEIGHT,
|
||||||
|
NODE_WIDTH,
|
||||||
|
type TreeCategory,
|
||||||
|
type TreeTech,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
type ResourceEntry = { id: number; name: string };
|
||||||
|
|
||||||
|
export interface TechTreeData {
|
||||||
|
techs: TreeTech[];
|
||||||
|
categories: TreeCategory[];
|
||||||
|
resources: ResourceEntry[];
|
||||||
|
canCreate: boolean;
|
||||||
|
canCreateCategories: boolean;
|
||||||
|
canUpdate: boolean;
|
||||||
|
canSetFinalApproval: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tactical chrome for the canvas toolbar controls. */
|
||||||
|
const TACTICAL_BUTTON =
|
||||||
|
"rounded-none border border-white/20 bg-black/50 font-mono text-[10px] tracking-widest uppercase text-white/80 hover:border-white/45 hover:bg-white/10 hover:text-white";
|
||||||
|
|
||||||
|
const nodeTypes: NodeTypes = {
|
||||||
|
techCard: TechNodeCard,
|
||||||
|
techBand: CategoryBand,
|
||||||
|
};
|
||||||
|
|
||||||
|
function refLabel(id: number): string {
|
||||||
|
return `REF ${id.toString().padStart(3, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFlowNodes(
|
||||||
|
techs: TreeTech[],
|
||||||
|
categories: TreeCategory[],
|
||||||
|
layout: ReturnType<typeof computeLayout>,
|
||||||
|
connectable: boolean,
|
||||||
|
): Node[] {
|
||||||
|
const warningsByTech = new Map<number, number>();
|
||||||
|
for (const warning of layout.warnings) {
|
||||||
|
warningsByTech.set(warning.techId, (warningsByTech.get(warning.techId) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
const categoryById = new Map(categories.map((c) => [c.id, c]));
|
||||||
|
|
||||||
|
const bandNodes: CategoryBandNode[] = layout.bands.map((band) => {
|
||||||
|
const techCount =
|
||||||
|
band.key === "uncategorized"
|
||||||
|
? techs.filter((t) => t.categoryId === null || !categoryById.has(t.categoryId)).length
|
||||||
|
: techs.filter((t) => t.categoryId === Number(band.key.replace("cat-", ""))).length;
|
||||||
|
return {
|
||||||
|
id: band.key,
|
||||||
|
type: "techBand",
|
||||||
|
position: { x: band.x, y: band.y },
|
||||||
|
draggable: false,
|
||||||
|
selectable: false,
|
||||||
|
deletable: false,
|
||||||
|
zIndex: 0,
|
||||||
|
// Bands are pure backdrops: they must never swallow pointer events,
|
||||||
|
// otherwise edges under a band cannot be selected and pane clicks die.
|
||||||
|
style: { width: band.width, height: band.height, pointerEvents: "none" },
|
||||||
|
data: { band, techCount },
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const techById = new Map(techs.map((t) => [t.id, t]));
|
||||||
|
const techNodes: TechNodeModel[] = layout.nodes.map((node) => {
|
||||||
|
const tech = techById.get(node.techId)!;
|
||||||
|
return {
|
||||||
|
id: String(tech.id),
|
||||||
|
type: "techCard",
|
||||||
|
position: { x: node.x, y: node.y },
|
||||||
|
zIndex: 1,
|
||||||
|
style: { width: NODE_WIDTH, height: NODE_HEIGHT },
|
||||||
|
data: {
|
||||||
|
tech,
|
||||||
|
categoryName: tech.categoryId ? categoryById.get(tech.categoryId)?.name ?? null : null,
|
||||||
|
refLabel: refLabel(tech.id),
|
||||||
|
hasWarning: warningsByTech.has(tech.id),
|
||||||
|
connectable,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return [...bandNodes, ...techNodes];
|
||||||
|
}
|
||||||
|
|
||||||
|
function withAlpha(hex: string, alpha: number): string {
|
||||||
|
const value = hex.replace("#", "");
|
||||||
|
const r = parseInt(value.slice(0, 2), 16);
|
||||||
|
const g = parseInt(value.slice(2, 4), 16);
|
||||||
|
const b = parseInt(value.slice(4, 6), 16);
|
||||||
|
return `rgba(${r},${g},${b},${alpha})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prerequisite edges. Each edge takes its progression path's color and its own
|
||||||
|
* lane (see edgeVisuals). With a technology selected, edges that do not touch
|
||||||
|
* it fade back so its connections stand out.
|
||||||
|
*/
|
||||||
|
function buildFlowEdges(
|
||||||
|
techs: TreeTech[],
|
||||||
|
layout: ReturnType<typeof computeLayout>,
|
||||||
|
selectedId: string | null,
|
||||||
|
): Edge[] {
|
||||||
|
const known = new Set(techs.map((t) => t.id));
|
||||||
|
const refs: EdgeRef[] = [];
|
||||||
|
const isOr = new Set<string>();
|
||||||
|
for (const tech of techs) {
|
||||||
|
for (const prereqId of tech.prereqAnd) {
|
||||||
|
if (prereqId === tech.id || !known.has(prereqId)) continue;
|
||||||
|
refs.push({ id: `and-${prereqId}-${tech.id}`, source: prereqId, target: tech.id });
|
||||||
|
}
|
||||||
|
for (const prereqId of tech.prereqOr) {
|
||||||
|
if (prereqId === tech.id || !known.has(prereqId)) continue;
|
||||||
|
const id = `or-${prereqId}-${tech.id}`;
|
||||||
|
if (refs.some((r) => r.id === id)) continue;
|
||||||
|
refs.push({ id, source: prereqId, target: tech.id });
|
||||||
|
isOr.add(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const positions = new Map(layout.nodes.map((n) => [n.techId, { x: n.x, y: n.y }]));
|
||||||
|
const visuals = edgeVisuals(techs, refs, positions, COLUMN_STEP);
|
||||||
|
const focus = selectedId ? Number(selectedId) : null;
|
||||||
|
|
||||||
|
return refs.map((ref) => {
|
||||||
|
const visual = visuals.get(ref.id)!;
|
||||||
|
const or = isOr.has(ref.id);
|
||||||
|
const related = focus === null || ref.source === focus || ref.target === focus;
|
||||||
|
const alpha = focus === null ? 0.8 : related ? 1 : 0.12;
|
||||||
|
const stroke = withAlpha(visual.color, alpha);
|
||||||
|
return {
|
||||||
|
id: ref.id,
|
||||||
|
source: String(ref.source),
|
||||||
|
target: String(ref.target),
|
||||||
|
type: "smoothstep",
|
||||||
|
pathOptions: { stepPosition: visual.stepPosition, borderRadius: 8 },
|
||||||
|
animated: or,
|
||||||
|
zIndex: focus !== null && related ? 2 : 0,
|
||||||
|
style: {
|
||||||
|
stroke,
|
||||||
|
strokeWidth: focus !== null && related ? 2.25 : 1.5,
|
||||||
|
...(or ? { strokeDasharray: "6 4" } : {}),
|
||||||
|
},
|
||||||
|
markerEnd: { type: MarkerType.ArrowClosed, color: stroke },
|
||||||
|
...(or
|
||||||
|
? {
|
||||||
|
label: "OR",
|
||||||
|
labelStyle: { fill: stroke, fontSize: 9, fontFamily: "monospace" },
|
||||||
|
labelBgStyle: { fill: "rgba(0,0,0,0.85)", fillOpacity: 0.9 },
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
data: { pathRoot: visual.pathRoot },
|
||||||
|
} as Edge;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchCollection<T>(slug: string): Promise<T[]> {
|
||||||
|
const res = await fetch(`/api/${slug}?limit=0&depth=0&sort=name`, {
|
||||||
|
credentials: "same-origin",
|
||||||
|
});
|
||||||
|
if (!res.ok) return [];
|
||||||
|
const body = await res.json();
|
||||||
|
return body?.docs ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function CanvasInner({ data }: { data: TechTreeData }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [techs, setTechs] = React.useState(data.techs);
|
||||||
|
const [categories, setCategories] = React.useState(data.categories);
|
||||||
|
const [resources, setResources] = React.useState(data.resources);
|
||||||
|
const [selectedId, setSelectedId] = React.useState<string | null>(null);
|
||||||
|
const [createOpen, setCreateOpen] = React.useState(false);
|
||||||
|
const [arranging, setArranging] = React.useState(false);
|
||||||
|
|
||||||
|
const layout = React.useMemo(() => computeLayout(techs, categories), [techs, categories]);
|
||||||
|
const flowNodes = React.useMemo(
|
||||||
|
() => buildFlowNodes(techs, categories, layout, data.canUpdate),
|
||||||
|
[techs, categories, layout, data.canUpdate],
|
||||||
|
);
|
||||||
|
const flowEdges = React.useMemo(
|
||||||
|
() => buildFlowEdges(techs, layout, selectedId),
|
||||||
|
[techs, layout, selectedId],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Controlled nodes/edges: React Flow needs the change handlers to apply
|
||||||
|
// drag and selection updates in controlled mode.
|
||||||
|
const [nodes, setNodes, onNodesChange] = useNodesState<Node>([]);
|
||||||
|
React.useEffect(() => {
|
||||||
|
setNodes(flowNodes);
|
||||||
|
}, [flowNodes, setNodes]);
|
||||||
|
|
||||||
|
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
|
||||||
|
React.useEffect(() => {
|
||||||
|
setEdges(flowEdges);
|
||||||
|
}, [flowEdges, setEdges]);
|
||||||
|
|
||||||
|
// Latest rendered card positions, read by the freeze diff below.
|
||||||
|
const nodesRef = React.useRef<Node[]>([]);
|
||||||
|
nodesRef.current = nodes;
|
||||||
|
const currentPositions = React.useCallback((): Map<number, CanvasPosition> => {
|
||||||
|
const map = new Map<number, CanvasPosition>();
|
||||||
|
for (const node of nodesRef.current) {
|
||||||
|
if (node.type !== "techCard") continue;
|
||||||
|
map.set(Number(node.id), { x: node.position.x, y: node.position.y });
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/** Persist freeze pins (skipping ids whose own PATCH already carries one). */
|
||||||
|
const persistPins = React.useCallback(
|
||||||
|
async (pins: Map<number, CanvasPosition>, skip: Set<number> = new Set()) => {
|
||||||
|
await Promise.all(
|
||||||
|
[...pins]
|
||||||
|
.filter(([id]) => !skip.has(id))
|
||||||
|
.map(([id, pin]) =>
|
||||||
|
fetch(`/api/technologies/${id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ treePosition: pin }),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Nodes are data-driven: block React Flow's own remove changes so Backspace
|
||||||
|
// can never blank a node out of the canvas (positions/selection still flow).
|
||||||
|
const handleNodesChange = React.useCallback(
|
||||||
|
(changes: NodeChange<Node>[]) => {
|
||||||
|
const allowed = changes.filter((change) => change.type !== "remove");
|
||||||
|
if (allowed.length > 0) onNodesChange(allowed);
|
||||||
|
},
|
||||||
|
[onNodesChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Edge removal is a data change (it drops a prerequisite), handled in
|
||||||
|
// onEdgesDelete; selection changes still flow so edges can be picked.
|
||||||
|
const handleEdgesChange = React.useCallback(
|
||||||
|
(changes: EdgeChange[]) => {
|
||||||
|
const allowed = changes.filter((change) => change.type !== "remove");
|
||||||
|
if (allowed.length > 0) onEdgesChange(allowed);
|
||||||
|
},
|
||||||
|
[onEdgesChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
const [connectError, setConnectError] = React.useState<string | null>(null);
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!connectError) return;
|
||||||
|
const timer = window.setTimeout(() => setConnectError(null), 6000);
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [connectError]);
|
||||||
|
|
||||||
|
const hasManualPositions = techs.some((t) => t.treePosition !== null);
|
||||||
|
|
||||||
|
const [saveStatus, setSaveStatus] = React.useState<string | null>(null);
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!saveStatus) return;
|
||||||
|
const timer = window.setTimeout(() => setSaveStatus(null), 3000);
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [saveStatus]);
|
||||||
|
|
||||||
|
const patchPrereqs = React.useCallback(
|
||||||
|
async (
|
||||||
|
targetId: number,
|
||||||
|
lists: { and: number[]; or: number[] },
|
||||||
|
pin?: { x: number; y: number },
|
||||||
|
) => {
|
||||||
|
const res = await fetch(`/api/technologies/${targetId}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
requirements: { prerequisiteTechnologies: buildPrereqRows(lists) },
|
||||||
|
...(pin ? { treePosition: pin } : {}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => null);
|
||||||
|
throw new Error(body?.errors?.[0]?.message || `Save failed (HTTP ${res.status}).`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleConnect = React.useCallback(
|
||||||
|
async (connection: { source: string | null; target: string | null }) => {
|
||||||
|
if (!connection.source || !connection.target) return;
|
||||||
|
const sourceId = Number(connection.source);
|
||||||
|
const targetId = Number(connection.target);
|
||||||
|
const result = addPrerequisite(techs, targetId, sourceId, "and");
|
||||||
|
if (!result.ok) {
|
||||||
|
setConnectError(result.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setConnectError(null);
|
||||||
|
|
||||||
|
// Stability: nothing on screen moves because of a connection. Every card
|
||||||
|
// the ordered layout would relocate (the target, its dependents, column
|
||||||
|
// neighbours) is pinned where it sits; "Auto-arrange" re-orders them.
|
||||||
|
const updated = techs.map((t) =>
|
||||||
|
t.id === targetId ? withPrereqs(t, result.lists) : t,
|
||||||
|
);
|
||||||
|
const pins = pinsToFreeze(currentPositions(), updated, categories);
|
||||||
|
setTechs(applyPins(updated, pins));
|
||||||
|
try {
|
||||||
|
await patchPrereqs(targetId, result.lists, pins.get(targetId));
|
||||||
|
await persistPins(pins, new Set([targetId]));
|
||||||
|
} catch (e) {
|
||||||
|
setConnectError(e instanceof Error ? e.message : "Could not save the connection.");
|
||||||
|
await refresh();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
[techs, categories, patchPrereqs, currentPositions, persistPins],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleEdgesDelete = React.useCallback(
|
||||||
|
async (deleted: Edge[]) => {
|
||||||
|
let next = techs;
|
||||||
|
const listsByTarget = new Map<number, { and: number[]; or: number[] }>();
|
||||||
|
for (const edge of deleted) {
|
||||||
|
const targetId = Number(edge.target);
|
||||||
|
const result = removePrerequisite(next, targetId, Number(edge.source));
|
||||||
|
if (!result.ok) continue;
|
||||||
|
listsByTarget.set(targetId, result.lists);
|
||||||
|
next = next.map((t) => (t.id === targetId ? withPrereqs(t, result.lists) : t));
|
||||||
|
}
|
||||||
|
if (listsByTarget.size === 0) return;
|
||||||
|
const pins = pinsToFreeze(currentPositions(), next, categories);
|
||||||
|
setTechs(applyPins(next, pins));
|
||||||
|
try {
|
||||||
|
for (const [targetId, lists] of listsByTarget) {
|
||||||
|
await patchPrereqs(targetId, lists, pins.get(targetId));
|
||||||
|
}
|
||||||
|
await persistPins(pins, new Set(listsByTarget.keys()));
|
||||||
|
} catch (e) {
|
||||||
|
setConnectError(e instanceof Error ? e.message : "Could not remove the prerequisite.");
|
||||||
|
await refresh();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
[techs, categories, patchPrereqs, currentPositions, persistPins],
|
||||||
|
);
|
||||||
|
|
||||||
|
const persistPosition = React.useCallback(async (techId: number, x: number, y: number) => {
|
||||||
|
setTechs((prev) =>
|
||||||
|
prev.map((t) => (t.id === techId ? { ...t, treePosition: { x, y } } : t)),
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
await fetch(`/api/technologies/${techId}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ treePosition: { x, y } }),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Optimistic position stays locally; a reload restores the stored value.
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSavePositions = React.useCallback(async () => {
|
||||||
|
// Commit the current on-screen position of every node to treePosition, so
|
||||||
|
// the exact arrangement survives a reload. Unlike a drag (one node) or a
|
||||||
|
// connection (only what would move), this pins the whole tree to whatever
|
||||||
|
// React Flow actually rendered.
|
||||||
|
const pins = new Map<number, CanvasPosition>();
|
||||||
|
const current = currentPositions();
|
||||||
|
for (const tech of techs) {
|
||||||
|
const pos = current.get(tech.id);
|
||||||
|
if (pos) {
|
||||||
|
pins.set(tech.id, { x: Math.round(pos.x), y: Math.round(pos.y) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (pins.size === 0) {
|
||||||
|
setSaveStatus("Nothing to save.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setTechs((prev) =>
|
||||||
|
prev.map((t) => {
|
||||||
|
const pin = pins.get(t.id);
|
||||||
|
return pin ? { ...t, treePosition: pin } : t;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
await persistPins(pins);
|
||||||
|
setSaveStatus(`Positions saved. ${pins.size} node(s).`);
|
||||||
|
} catch (e) {
|
||||||
|
setSaveStatus("Could not save positions.");
|
||||||
|
}
|
||||||
|
}, [techs, currentPositions, persistPins]);
|
||||||
|
|
||||||
|
const handleAutoArrange = React.useCallback(async () => {
|
||||||
|
const withPositions = techs.filter((t) => t.treePosition !== null);
|
||||||
|
if (withPositions.length === 0) return;
|
||||||
|
setArranging(true);
|
||||||
|
try {
|
||||||
|
await Promise.all(
|
||||||
|
withPositions.map((t) =>
|
||||||
|
fetch(`/api/technologies/${t.id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ treePosition: { x: null, y: null } }),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
setTechs((prev) => prev.map((t) => ({ ...t, treePosition: null })));
|
||||||
|
} finally {
|
||||||
|
setArranging(false);
|
||||||
|
}
|
||||||
|
}, [techs]);
|
||||||
|
|
||||||
|
const techById = React.useMemo(
|
||||||
|
() => new Map(techs.map((t) => [String(t.id), t])),
|
||||||
|
[techs],
|
||||||
|
);
|
||||||
|
const selectedTech = selectedId ? techById.get(selectedId) ?? null : null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-read the tree. With `freeze`, cards the fresh data would relocate are
|
||||||
|
* pinned at their current spot first (panel saves, quick-create), so the
|
||||||
|
* canvas stays still. Error recovery calls it without freeze to show truth.
|
||||||
|
*/
|
||||||
|
const refresh = React.useCallback(
|
||||||
|
async (options: { freeze?: boolean } = {}) => {
|
||||||
|
const [techDocs, categoryDocs, resourceDocs] = await Promise.all([
|
||||||
|
fetchCollection<Record<string, unknown>>("technologies"),
|
||||||
|
fetchCollection<Record<string, unknown>>("tech-categories"),
|
||||||
|
fetchCollection<Record<string, unknown>>("resources"),
|
||||||
|
]);
|
||||||
|
let nextTechs = techDocs.map(mapTechDoc);
|
||||||
|
const nextCategories = categoryDocs.map(mapCategoryDoc);
|
||||||
|
if (options.freeze) {
|
||||||
|
const pins = pinsToFreeze(currentPositions(), nextTechs, nextCategories);
|
||||||
|
nextTechs = applyPins(nextTechs, pins);
|
||||||
|
void persistPins(pins);
|
||||||
|
}
|
||||||
|
setTechs(nextTechs);
|
||||||
|
setCategories(nextCategories);
|
||||||
|
setResources(resourceDocs.map((d) => ({ id: d.id as number, name: String(d.name ?? "") })));
|
||||||
|
},
|
||||||
|
[currentPositions, persistPins],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCreated = React.useCallback(
|
||||||
|
async (result: QuickCreateResult) => {
|
||||||
|
await refresh({ freeze: true });
|
||||||
|
setSelectedId(String(result.techId));
|
||||||
|
router.push(`/admin/collections/technologies/${result.techId}`);
|
||||||
|
},
|
||||||
|
[refresh, router],
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectedWarnings = React.useMemo(
|
||||||
|
() =>
|
||||||
|
selectedTech
|
||||||
|
? layout.warnings.filter((w) => w.techId === selectedTech.id).map((w) => w.message)
|
||||||
|
: [],
|
||||||
|
[selectedTech, layout],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="relative tech-tree-canvas"
|
||||||
|
style={{
|
||||||
|
height: "calc(100dvh - 56px)",
|
||||||
|
backgroundColor: "#05070a",
|
||||||
|
}}
|
||||||
|
data-testid="tech-tree-canvas"
|
||||||
|
>
|
||||||
|
<ReactFlow
|
||||||
|
nodes={nodes}
|
||||||
|
edges={edges}
|
||||||
|
onNodesChange={handleNodesChange}
|
||||||
|
onEdgesChange={handleEdgesChange}
|
||||||
|
nodeTypes={nodeTypes}
|
||||||
|
fitView
|
||||||
|
fitViewOptions={{ padding: 0.15, maxZoom: 0.85 }}
|
||||||
|
minZoom={0.08}
|
||||||
|
nodesConnectable={data.canUpdate}
|
||||||
|
onConnect={(connection) => {
|
||||||
|
void handleConnect(connection);
|
||||||
|
}}
|
||||||
|
onConnectEnd={(event, connectionState) => {
|
||||||
|
// React Flow only resolves drops onto a handle. If the pointer was
|
||||||
|
// released over a node card instead, resolve that card ourselves so
|
||||||
|
// "drag onto the technology" works the way people expect. Validation
|
||||||
|
// (duplicates, cycles) lives in handleConnect, which reports errors.
|
||||||
|
if (!data.canUpdate) return;
|
||||||
|
if (!("fromNode" in connectionState)) return;
|
||||||
|
if (connectionState.toHandle) return; // dropped on a handle: onConnect leads
|
||||||
|
const sourceId = connectionState.fromNode?.id;
|
||||||
|
if (!sourceId) return;
|
||||||
|
const point = event as MouseEvent;
|
||||||
|
const element = document.elementFromPoint(point.clientX ?? 0, point.clientY ?? 0);
|
||||||
|
const nodeElement = element?.closest?.(".react-flow__node") as HTMLElement | null;
|
||||||
|
const targetId = nodeElement?.getAttribute("data-id");
|
||||||
|
if (!targetId || targetId === sourceId) return;
|
||||||
|
void handleConnect({ source: sourceId, target: targetId });
|
||||||
|
}}
|
||||||
|
onEdgesDelete={(deleted) => {
|
||||||
|
void handleEdgesDelete(deleted);
|
||||||
|
}}
|
||||||
|
onNodeClick={(_, node) => {
|
||||||
|
if (node.type === "techCard") setSelectedId(node.id);
|
||||||
|
}}
|
||||||
|
onNodeDragStop={(_, node) => {
|
||||||
|
if (node.type !== "techCard") return;
|
||||||
|
void persistPosition(Number(node.id), Math.round(node.position.x), Math.round(node.position.y));
|
||||||
|
}}
|
||||||
|
onPaneClick={() => setSelectedId(null)}
|
||||||
|
proOptions={{ hideAttribution: false }}
|
||||||
|
>
|
||||||
|
<Background
|
||||||
|
variant={BackgroundVariant.Lines}
|
||||||
|
gap={28}
|
||||||
|
size={1}
|
||||||
|
color="rgba(255,255,255,0.055)"
|
||||||
|
/>
|
||||||
|
<Controls showInteractive={false} />
|
||||||
|
</ReactFlow>
|
||||||
|
|
||||||
|
{/* Toolbar */}
|
||||||
|
<div className="absolute top-4 left-4 z-20 flex items-center gap-3 rounded-none border border-white/15 bg-black/60 px-3 py-2 backdrop-blur-sm">
|
||||||
|
<span className="font-mono text-[10px] tracking-widest text-white/60 uppercase">
|
||||||
|
TECH TREE // COMMAND
|
||||||
|
</span>
|
||||||
|
{layout.warnings.length > 0 ? (
|
||||||
|
<span
|
||||||
|
className="flex items-center gap-1 font-mono text-[10px] tracking-widest text-amber-400 uppercase"
|
||||||
|
title={layout.warnings.map((w) => w.message).join("\n")}
|
||||||
|
data-testid="tech-tree-warnings"
|
||||||
|
>
|
||||||
|
<TriangleAlertIcon className="size-3" />
|
||||||
|
{layout.warnings.length} TIMELINE WARNING{layout.warnings.length === 1 ? "" : "S"}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{connectError ? (
|
||||||
|
<span
|
||||||
|
className="font-mono text-[10px] tracking-widest text-red-400 uppercase"
|
||||||
|
data-testid="tech-tree-connect-error"
|
||||||
|
>
|
||||||
|
{connectError}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{saveStatus ? (
|
||||||
|
<span
|
||||||
|
className="font-mono text-[10px] tracking-widest text-emerald-400 uppercase"
|
||||||
|
data-testid="tech-tree-save-status"
|
||||||
|
>
|
||||||
|
{saveStatus}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{data.canUpdate ? (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={handleSavePositions}
|
||||||
|
title="Save the current positions of every node so the layout stays the same on your next visit"
|
||||||
|
data-testid="tech-tree-save-positions"
|
||||||
|
className={TACTICAL_BUTTON}
|
||||||
|
>
|
||||||
|
<SaveIcon className="size-3.5" />
|
||||||
|
Save positions
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{data.canUpdate && hasManualPositions ? (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={handleAutoArrange}
|
||||||
|
disabled={arranging}
|
||||||
|
title="Clear manual node positions and restore the automatic layout"
|
||||||
|
data-testid="tech-tree-auto-arrange"
|
||||||
|
className={TACTICAL_BUTTON}
|
||||||
|
>
|
||||||
|
<LayoutGridIcon className="size-3.5" />
|
||||||
|
{arranging ? "Arranging..." : "Auto-arrange"}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{data.canCreate ? (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setCreateOpen(true)}
|
||||||
|
data-testid="tech-tree-create"
|
||||||
|
className={`${TACTICAL_BUTTON} border-[#22d3ee]/40 text-[#67e8f9] hover:border-[#22d3ee]/70 hover:bg-[#22d3ee]/10 hover:text-[#a5f3fc]`}
|
||||||
|
>
|
||||||
|
<PlusIcon className="size-3.5" />
|
||||||
|
New Technology
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{data.canUpdate ? (
|
||||||
|
<span className="hidden font-mono text-[9px] tracking-widest text-white/35 uppercase lg:inline">
|
||||||
|
DRAG NODE EDGE TO LINK A PREREQUISITE · SELECT AN EDGE + BACKSPACE TO REMOVE
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Empty state */}
|
||||||
|
{techs.length === 0 ? (
|
||||||
|
<div className="pointer-events-none absolute inset-0 z-10 flex items-center justify-center">
|
||||||
|
<div className="pointer-events-auto rounded-none border border-white/15 bg-black/70 px-6 py-5 text-center backdrop-blur-sm">
|
||||||
|
<p className="font-mono text-[10px] tracking-widest text-white/50 uppercase">
|
||||||
|
NO TECHNOLOGIES ON RECORD
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-sm text-white/70">
|
||||||
|
Create the first technology to start the tree.
|
||||||
|
</p>
|
||||||
|
{data.canCreate ? (
|
||||||
|
<Button
|
||||||
|
className={`${TACTICAL_BUTTON} mt-3`}
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setCreateOpen(true)}
|
||||||
|
>
|
||||||
|
<PlusIcon className="size-3.5" />
|
||||||
|
New Technology
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Preview / edit panel */}
|
||||||
|
{selectedTech ? (
|
||||||
|
<TechPreviewPanel
|
||||||
|
tech={selectedTech}
|
||||||
|
categories={categories}
|
||||||
|
resources={resources}
|
||||||
|
allTechs={techs}
|
||||||
|
canUpdate={data.canUpdate}
|
||||||
|
canCreateCategories={data.canCreateCategories}
|
||||||
|
canSetFinalApproval={data.canSetFinalApproval}
|
||||||
|
warnings={selectedWarnings}
|
||||||
|
onClose={() => setSelectedId(null)}
|
||||||
|
onEdit={() => router.push(`/admin/collections/technologies/${selectedTech.id}`)}
|
||||||
|
onSaved={() => {
|
||||||
|
void refresh({ freeze: true });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<QuickCreateDialog
|
||||||
|
open={createOpen}
|
||||||
|
onOpenChange={setCreateOpen}
|
||||||
|
categories={categories}
|
||||||
|
canCreateCategories={data.canCreateCategories}
|
||||||
|
onCreated={handleCreated}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TechTreeCanvas({ data }: { data: TechTreeData }) {
|
||||||
|
return (
|
||||||
|
<ReactFlowProvider>
|
||||||
|
<CanvasInner data={data} />
|
||||||
|
</ReactFlowProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
151
src/components/admin/tech-tree/TechTreeNav.tsx
Normal file
151
src/components/admin/tech-tree/TechTreeNav.tsx
Normal file
|
|
@ -0,0 +1,151 @@
|
||||||
|
import { NavHamburger, NavWrapper } from "@payloadcms/next/client";
|
||||||
|
import { Logout } from "@payloadcms/ui";
|
||||||
|
import { RenderServerComponent } from "@payloadcms/ui/elements/RenderServerComponent";
|
||||||
|
import { EntityType, groupNavItems, type EntityToGroup } from "@payloadcms/ui/shared";
|
||||||
|
import { PREFERENCE_KEYS } from "payload/shared";
|
||||||
|
import type { PayloadRequest, SanitizedPermissions, ServerProps } from "payload";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
import { TechTreeNavClient, type TechTreeNavGroup, type TechTreeNavEntity } from "./TechTreeNavClient";
|
||||||
|
|
||||||
|
const baseClass = "nav";
|
||||||
|
|
||||||
|
type NavProps = ServerProps & { req?: PayloadRequest };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replaces the default admin Nav so a "Tech Tree" entry can be injected into
|
||||||
|
* the Intelligence group (the group containing the technologies collection).
|
||||||
|
* Everything else mirrors Payload's DefaultNav: groups from visible entities,
|
||||||
|
* nav preferences, before/after components, and the logout control. The
|
||||||
|
* Tech Tree entry rides along with the technologies collection's visibility,
|
||||||
|
* so users who cannot see Technologies never see the tree entry either.
|
||||||
|
*/
|
||||||
|
export const TechTreeNav = async ({
|
||||||
|
i18n,
|
||||||
|
locale,
|
||||||
|
params,
|
||||||
|
payload,
|
||||||
|
permissions,
|
||||||
|
req,
|
||||||
|
searchParams,
|
||||||
|
user,
|
||||||
|
visibleEntities,
|
||||||
|
}: NavProps) => {
|
||||||
|
if (!payload?.config) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
admin: {
|
||||||
|
components: { afterNav, afterNavLinks, beforeNav, beforeNavLinks, logout },
|
||||||
|
},
|
||||||
|
collections,
|
||||||
|
globals,
|
||||||
|
} = payload.config;
|
||||||
|
|
||||||
|
const visibleCollections = visibleEntities?.collections ?? [];
|
||||||
|
const visibleGlobals = visibleEntities?.globals ?? [];
|
||||||
|
|
||||||
|
const groups: TechTreeNavGroup[] = groupNavItems(
|
||||||
|
[
|
||||||
|
...visibleCollections.map((slug) => {
|
||||||
|
const collection = collections.find((c) => c.slug === slug)!;
|
||||||
|
return { type: EntityType.collection, entity: collection } satisfies EntityToGroup;
|
||||||
|
}),
|
||||||
|
...visibleGlobals.map((slug) => {
|
||||||
|
const global = globals.find((g) => g.slug === slug)!;
|
||||||
|
return { type: EntityType.global, entity: global } satisfies EntityToGroup;
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
permissions ?? ({} as SanitizedPermissions),
|
||||||
|
i18n,
|
||||||
|
).map((group): TechTreeNavGroup => {
|
||||||
|
const techIndex = group.entities.findIndex(
|
||||||
|
(entity) => entity.type === EntityType.collection && entity.slug === "technologies",
|
||||||
|
);
|
||||||
|
if (techIndex === -1) return group;
|
||||||
|
const entities: TechTreeNavEntity[] = [...group.entities];
|
||||||
|
entities.splice(techIndex + 1, 0, {
|
||||||
|
slug: "tech-tree",
|
||||||
|
label: "Tech Tree",
|
||||||
|
type: "custom",
|
||||||
|
href: "/tech-tree",
|
||||||
|
});
|
||||||
|
return { ...group, entities };
|
||||||
|
});
|
||||||
|
|
||||||
|
let navPreferences: { groups?: { [key: string]: { open: boolean } } } | null = null;
|
||||||
|
if (req?.user?.collection) {
|
||||||
|
const prefs = await req.payload.find({
|
||||||
|
collection: "payload-preferences",
|
||||||
|
depth: 0,
|
||||||
|
limit: 1,
|
||||||
|
pagination: false,
|
||||||
|
req,
|
||||||
|
where: {
|
||||||
|
and: [
|
||||||
|
{ key: { equals: PREFERENCE_KEYS.NAV } },
|
||||||
|
{ "user.relationTo": { equals: req.user.collection } },
|
||||||
|
{ "user.value": { equals: req?.user?.id } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
navPreferences = (prefs?.docs?.[0]?.value as typeof navPreferences) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const serverProps = { i18n, locale, params, payload, permissions, searchParams, user };
|
||||||
|
const clientProps = { documentSubViewType: undefined, viewType: undefined };
|
||||||
|
|
||||||
|
const LogoutComponent = RenderServerComponent({
|
||||||
|
clientProps,
|
||||||
|
Component: logout?.Button,
|
||||||
|
Fallback: Logout,
|
||||||
|
importMap: payload.importMap,
|
||||||
|
serverProps,
|
||||||
|
});
|
||||||
|
|
||||||
|
const RenderedBeforeNav = RenderServerComponent({
|
||||||
|
clientProps,
|
||||||
|
Component: beforeNav,
|
||||||
|
importMap: payload.importMap,
|
||||||
|
serverProps,
|
||||||
|
});
|
||||||
|
const RenderedBeforeNavLinks = RenderServerComponent({
|
||||||
|
clientProps,
|
||||||
|
Component: beforeNavLinks,
|
||||||
|
importMap: payload.importMap,
|
||||||
|
serverProps,
|
||||||
|
});
|
||||||
|
const RenderedAfterNavLinks = RenderServerComponent({
|
||||||
|
clientProps,
|
||||||
|
Component: afterNavLinks,
|
||||||
|
importMap: payload.importMap,
|
||||||
|
serverProps,
|
||||||
|
});
|
||||||
|
const RenderedAfterNav = RenderServerComponent({
|
||||||
|
clientProps,
|
||||||
|
Component: afterNav,
|
||||||
|
importMap: payload.importMap,
|
||||||
|
serverProps,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NavWrapper baseClass={baseClass}>
|
||||||
|
{RenderedBeforeNav}
|
||||||
|
<nav className={`${baseClass}__wrap`}>
|
||||||
|
{RenderedBeforeNavLinks}
|
||||||
|
<TechTreeNavClient groups={groups} navPreferences={navPreferences} />
|
||||||
|
{RenderedAfterNavLinks}
|
||||||
|
<div className={`${baseClass}__controls`}>{LogoutComponent}</div>
|
||||||
|
</nav>
|
||||||
|
{RenderedAfterNav}
|
||||||
|
<div className={`${baseClass}__header`}>
|
||||||
|
<div className={`${baseClass}__header-content`}>
|
||||||
|
<NavHamburger baseClass={baseClass} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</NavWrapper>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TechTreeNav;
|
||||||
103
src/components/admin/tech-tree/TechTreeNavClient.tsx
Normal file
103
src/components/admin/tech-tree/TechTreeNavClient.tsx
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Link, NavGroup, useConfig, useTranslation } from "@payloadcms/ui";
|
||||||
|
import { EntityType } from "@payloadcms/ui/shared";
|
||||||
|
import { usePathname } from "next/navigation.js";
|
||||||
|
import { formatAdminURL } from "payload/shared";
|
||||||
|
import React, { Fragment } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A nav entity: either a standard Payload entity (collection/global) or a
|
||||||
|
* custom view entry injected by TechTreeNav (type "custom", href given).
|
||||||
|
*/
|
||||||
|
export type TechTreeNavEntity = {
|
||||||
|
href?: `/${string}`;
|
||||||
|
label: unknown;
|
||||||
|
slug: string;
|
||||||
|
type: EntityType | "custom";
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TechTreeNavGroup = {
|
||||||
|
entities: TechTreeNavEntity[];
|
||||||
|
label: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
groups: TechTreeNavGroup[];
|
||||||
|
navPreferences?: {
|
||||||
|
groups?: { [key: string]: { open: boolean } };
|
||||||
|
} | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const baseClass = "nav";
|
||||||
|
|
||||||
|
export const TechTreeNavClient = ({ groups, navPreferences }: Props) => {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const {
|
||||||
|
config: { routes, admin: adminConfig },
|
||||||
|
} = useConfig();
|
||||||
|
const { i18n } = useTranslation();
|
||||||
|
|
||||||
|
const adminRoute = routes.admin;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Fragment>
|
||||||
|
{groups.map((group, groupKey) => {
|
||||||
|
const { entities, label } = group;
|
||||||
|
return (
|
||||||
|
<NavGroup
|
||||||
|
key={groupKey}
|
||||||
|
isOpen={navPreferences?.groups?.[label]?.open}
|
||||||
|
label={label}
|
||||||
|
>
|
||||||
|
{entities.map((entity, i) => {
|
||||||
|
const { label: entityLabel, slug, type } = entity;
|
||||||
|
|
||||||
|
let href: string | undefined;
|
||||||
|
let id: string | undefined;
|
||||||
|
if (type === "custom" && entity.href) {
|
||||||
|
href = formatAdminURL({ adminRoute, path: entity.href });
|
||||||
|
id = `nav-${slug}`;
|
||||||
|
} else if (type === EntityType.collection) {
|
||||||
|
href = formatAdminURL({ adminRoute, path: `/collections/${slug}` });
|
||||||
|
id = `nav-${slug}`;
|
||||||
|
} else if (type === EntityType.global) {
|
||||||
|
href = formatAdminURL({ adminRoute, path: `/globals/${slug}` });
|
||||||
|
id = `nav-global-${slug}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!href) return null;
|
||||||
|
|
||||||
|
const isActive =
|
||||||
|
pathname === href ||
|
||||||
|
(pathname.startsWith(href) && ["/", undefined].includes(pathname[href.length]));
|
||||||
|
|
||||||
|
const Label = (
|
||||||
|
<Fragment>
|
||||||
|
{isActive && <div className={`${baseClass}__link-indicator`} />}
|
||||||
|
<span className={`${baseClass}__link-label`}>
|
||||||
|
{typeof entityLabel === "string" ? entityLabel : String(entityLabel)}
|
||||||
|
</span>
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isActive) {
|
||||||
|
return (
|
||||||
|
<div className={`${baseClass}__link`} id={id} key={i}>
|
||||||
|
{Label}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link className={`${baseClass}__link`} href={href} id={id} key={i} prefetch={false}>
|
||||||
|
{Label}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</NavGroup>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
};
|
||||||
104
src/components/admin/tech-tree/TechTreeView.tsx
Normal file
104
src/components/admin/tech-tree/TechTreeView.tsx
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
import { DefaultTemplate } from "@payloadcms/next/templates";
|
||||||
|
import type { AdminViewServerProps } from "payload";
|
||||||
|
|
||||||
|
import { hasPermission } from "@/utils/access-control/hasPermission";
|
||||||
|
import { hasIntelligenceQualification } from "@/utils/access-control/hasIntelligenceQualification";
|
||||||
|
|
||||||
|
import { TechTreeCanvas, type TechTreeData } from "./TechTreeCanvas";
|
||||||
|
import { mapCategoryDoc, mapTechDoc } from "./mappers";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Root admin view at /admin/tech-tree. Custom views are public by default, so
|
||||||
|
* this gates access itself: logged-in users holding technologies:read (or the
|
||||||
|
* Intelligence division qualification, mirroring the technologies collection's
|
||||||
|
* scoped admin-panel access).
|
||||||
|
*/
|
||||||
|
export async function TechTreeView({
|
||||||
|
initPageResult,
|
||||||
|
params,
|
||||||
|
searchParams,
|
||||||
|
}: AdminViewServerProps) {
|
||||||
|
const {
|
||||||
|
req: { payload, user },
|
||||||
|
} = initPageResult;
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return (
|
||||||
|
<DefaultTemplate
|
||||||
|
i18n={initPageResult.req.i18n}
|
||||||
|
locale={initPageResult.locale}
|
||||||
|
params={params}
|
||||||
|
payload={payload}
|
||||||
|
permissions={initPageResult.permissions}
|
||||||
|
searchParams={searchParams}
|
||||||
|
user={user ?? undefined}
|
||||||
|
visibleEntities={initPageResult.visibleEntities}
|
||||||
|
>
|
||||||
|
<div className="p-8">
|
||||||
|
<p className="text-sm text-white/70">You must be logged in to view the Tech Tree.</p>
|
||||||
|
</div>
|
||||||
|
</DefaultTemplate>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasRead = await hasPermission(payload, user, "technologies:read");
|
||||||
|
const intelQualified = await hasIntelligenceQualification(payload, user);
|
||||||
|
|
||||||
|
if (!hasRead && !intelQualified) {
|
||||||
|
return (
|
||||||
|
<DefaultTemplate
|
||||||
|
i18n={initPageResult.req.i18n}
|
||||||
|
locale={initPageResult.locale}
|
||||||
|
params={params}
|
||||||
|
payload={payload}
|
||||||
|
permissions={initPageResult.permissions}
|
||||||
|
searchParams={searchParams}
|
||||||
|
user={user ?? undefined}
|
||||||
|
visibleEntities={initPageResult.visibleEntities}
|
||||||
|
>
|
||||||
|
<div className="p-8">
|
||||||
|
<p className="text-sm text-white/70">
|
||||||
|
You do not have permission to view the Tech Tree.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</DefaultTemplate>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [techDocs, categoryDocs, resourceDocs] = await Promise.all([
|
||||||
|
payload.find({ collection: "technologies", depth: 0, limit: 0, sort: "name" }),
|
||||||
|
payload.find({ collection: "tech-categories", depth: 0, limit: 0, sort: "name" }),
|
||||||
|
payload.find({ collection: "resources", depth: 0, limit: 0, sort: "name" }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const data: TechTreeData = {
|
||||||
|
techs: techDocs.docs.map((doc) => mapTechDoc(doc as unknown as Record<string, unknown>)),
|
||||||
|
categories: categoryDocs.docs.map((doc) =>
|
||||||
|
mapCategoryDoc(doc as unknown as Record<string, unknown>),
|
||||||
|
),
|
||||||
|
resources: resourceDocs.docs.map((doc) => ({ id: doc.id, name: doc.name })),
|
||||||
|
canCreate:
|
||||||
|
(await hasPermission(payload, user, "technologies:create")) || intelQualified,
|
||||||
|
canCreateCategories:
|
||||||
|
(await hasPermission(payload, user, "tech-categories:create")) || intelQualified,
|
||||||
|
canUpdate: (await hasPermission(payload, user, "technologies:update")) || intelQualified,
|
||||||
|
canSetFinalApproval: await hasPermission(payload, user, "system:admin-access"),
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DefaultTemplate
|
||||||
|
i18n={initPageResult.req.i18n}
|
||||||
|
locale={initPageResult.locale}
|
||||||
|
params={params}
|
||||||
|
payload={payload}
|
||||||
|
permissions={initPageResult.permissions}
|
||||||
|
searchParams={searchParams}
|
||||||
|
user={user}
|
||||||
|
visibleEntities={initPageResult.visibleEntities}
|
||||||
|
>
|
||||||
|
<TechTreeCanvas data={data} />
|
||||||
|
</DefaultTemplate>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TechTreeView;
|
||||||
99
src/components/admin/tech-tree/edgeVisuals.ts
Normal file
99
src/components/admin/tech-tree/edgeVisuals.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
import type { TreeTech } from "./types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Progression-path palette: high-contrast on the dark canvas, distinct from the
|
||||||
|
* amber warning and red error accents used elsewhere in the tree.
|
||||||
|
*/
|
||||||
|
export const PATH_PALETTE = [
|
||||||
|
"#22d3ee", // cyan
|
||||||
|
"#a78bfa", // violet
|
||||||
|
"#34d399", // emerald
|
||||||
|
"#f472b6", // pink
|
||||||
|
"#38bdf8", // sky
|
||||||
|
"#a3e635", // lime
|
||||||
|
"#fb923c", // orange
|
||||||
|
"#e879f9", // fuchsia
|
||||||
|
];
|
||||||
|
|
||||||
|
export type EdgeRef = { id: string; source: number; target: number };
|
||||||
|
export type EdgeVisual = { color: string; stepPosition: number; pathRoot: number };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Visual treatment for each prerequisite edge:
|
||||||
|
*
|
||||||
|
* - **color**: the edge inherits the color of the progression path it belongs
|
||||||
|
* to, identified by the root technology the chain grows from (the lowest-id
|
||||||
|
* root reachable through the source's prerequisites). Separate chains read as
|
||||||
|
* separate colors; one chain keeps one color end to end.
|
||||||
|
* - **stepPosition**: smoothstep edges turn at the midpoint by default, so
|
||||||
|
* edges from DIFFERENT sources crossing the same column gap stack their
|
||||||
|
* vertical runs on top of each other. Each source in a gap gets its own lane
|
||||||
|
* (spread across 20%..80% of the horizontal run). Edges from the SAME source
|
||||||
|
* share a lane on purpose: they read as one bus fanning out from a parent.
|
||||||
|
*/
|
||||||
|
export function edgeVisuals(
|
||||||
|
techs: TreeTech[],
|
||||||
|
edges: EdgeRef[],
|
||||||
|
positions: Map<number, { x: number; y: number }>,
|
||||||
|
columnStep: number,
|
||||||
|
): Map<string, EdgeVisual> {
|
||||||
|
const byId = new Map(techs.map((t) => [t.id, t]));
|
||||||
|
const parentsOf = (id: number): number[] => {
|
||||||
|
const tech = byId.get(id);
|
||||||
|
if (!tech) return [];
|
||||||
|
return [...new Set([...tech.prereqAnd, ...tech.prereqOr])].filter(
|
||||||
|
(p) => p !== id && byId.has(p),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Root of the progression path (cycle-safe, memoized).
|
||||||
|
const rootMemo = new Map<number, number>();
|
||||||
|
const rootOf = (id: number, stack: Set<number> = new Set()): number => {
|
||||||
|
const cached = rootMemo.get(id);
|
||||||
|
if (cached !== undefined) return cached;
|
||||||
|
if (stack.has(id)) return id;
|
||||||
|
stack.add(id);
|
||||||
|
const parents = parentsOf(id);
|
||||||
|
const root = parents.length === 0 ? id : Math.min(...parents.map((p) => rootOf(p, stack)));
|
||||||
|
stack.delete(id);
|
||||||
|
rootMemo.set(id, root);
|
||||||
|
return root;
|
||||||
|
};
|
||||||
|
|
||||||
|
const roots = [...new Set(edges.map((e) => rootOf(e.source)))].sort((a, b) => a - b);
|
||||||
|
const colorOfRoot = new Map(roots.map((r, i) => [r, PATH_PALETTE[i % PATH_PALETTE.length]]));
|
||||||
|
|
||||||
|
// Lanes: group distinct sources by the column gap they leave from.
|
||||||
|
const sourcesByGap = new Map<number, number[]>();
|
||||||
|
for (const edge of edges) {
|
||||||
|
const pos = positions.get(edge.source);
|
||||||
|
if (!pos) continue;
|
||||||
|
const gap = Math.round(pos.x / columnStep);
|
||||||
|
const list = sourcesByGap.get(gap) ?? [];
|
||||||
|
if (!list.includes(edge.source)) list.push(edge.source);
|
||||||
|
sourcesByGap.set(gap, list);
|
||||||
|
}
|
||||||
|
const laneOf = new Map<number, number>();
|
||||||
|
for (const sources of sourcesByGap.values()) {
|
||||||
|
sources.sort((a, b) => {
|
||||||
|
const pa = positions.get(a)!;
|
||||||
|
const pb = positions.get(b)!;
|
||||||
|
return pa.y - pb.y || a - b;
|
||||||
|
});
|
||||||
|
const n = sources.length;
|
||||||
|
sources.forEach((source, i) => {
|
||||||
|
laneOf.set(source, n === 1 ? 0.5 : 0.2 + (0.6 * i) / (n - 1));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const visuals = new Map<string, EdgeVisual>();
|
||||||
|
for (const edge of edges) {
|
||||||
|
const pathRoot = rootOf(edge.source);
|
||||||
|
visuals.set(edge.id, {
|
||||||
|
color: colorOfRoot.get(pathRoot) ?? PATH_PALETTE[0],
|
||||||
|
stepPosition: laneOf.get(edge.source) ?? 0.5,
|
||||||
|
pathRoot,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return visuals;
|
||||||
|
}
|
||||||
45
src/components/admin/tech-tree/freeze.ts
Normal file
45
src/components/admin/tech-tree/freeze.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
import { computeLayout } from "./layout";
|
||||||
|
import type { TreeCategory, TreeTech } from "./types";
|
||||||
|
|
||||||
|
export type CanvasPosition = { x: number; y: number };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Layout stability contract: a data change (new prerequisite, removed edge,
|
||||||
|
* panel save, new technology) must not move cards on screen. Only dragging and
|
||||||
|
* "Auto-arrange" move things.
|
||||||
|
*
|
||||||
|
* Given where every card currently sits and the technologies AFTER the change,
|
||||||
|
* this returns a pin for each technology the ordered layout would relocate, at
|
||||||
|
* its current on-screen position. Writing those pins as `treePosition` keeps
|
||||||
|
* the canvas visually still; Auto-arrange later clears them.
|
||||||
|
*
|
||||||
|
* Technologies with no current position (e.g. just created) are skipped: they
|
||||||
|
* take their ordered slot.
|
||||||
|
*/
|
||||||
|
export function pinsToFreeze(
|
||||||
|
current: Map<number, CanvasPosition>,
|
||||||
|
nextTechs: TreeTech[],
|
||||||
|
categories: TreeCategory[],
|
||||||
|
exclude: Set<number> = new Set(),
|
||||||
|
): Map<number, CanvasPosition> {
|
||||||
|
const next = computeLayout(nextTechs, categories);
|
||||||
|
const pins = new Map<number, CanvasPosition>();
|
||||||
|
for (const node of next.nodes) {
|
||||||
|
if (exclude.has(node.techId)) continue;
|
||||||
|
const before = current.get(node.techId);
|
||||||
|
if (!before) continue;
|
||||||
|
if (Math.abs(node.x - before.x) > 1 || Math.abs(node.y - before.y) > 1) {
|
||||||
|
pins.set(node.techId, { x: Math.round(before.x), y: Math.round(before.y) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return pins;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Apply pins to a technology list (returns new objects only where pinned). */
|
||||||
|
export function applyPins(techs: TreeTech[], pins: Map<number, CanvasPosition>): TreeTech[] {
|
||||||
|
if (pins.size === 0) return techs;
|
||||||
|
return techs.map((t) => {
|
||||||
|
const pin = pins.get(t.id);
|
||||||
|
return pin ? { ...t, treePosition: pin } : t;
|
||||||
|
});
|
||||||
|
}
|
||||||
262
src/components/admin/tech-tree/layout.ts
Normal file
262
src/components/admin/tech-tree/layout.ts
Normal file
|
|
@ -0,0 +1,262 @@
|
||||||
|
import {
|
||||||
|
BAND_PADDING_BOTTOM,
|
||||||
|
BAND_PADDING_TOP,
|
||||||
|
BAND_PADDING_X,
|
||||||
|
COLUMN_STEP,
|
||||||
|
NODE_HEIGHT,
|
||||||
|
NODE_WIDTH,
|
||||||
|
ROW_STEP,
|
||||||
|
type CategoryBandLayout,
|
||||||
|
type LayoutWarning,
|
||||||
|
type TechLayoutNode,
|
||||||
|
type TechTreeLayout,
|
||||||
|
type TreeCategory,
|
||||||
|
type TreeTech,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How many rows a column holds before placement spills into the next column.
|
||||||
|
* Keeps a large root pool (or a huge category) readable instead of one
|
||||||
|
* 8000px-tall strip.
|
||||||
|
*/
|
||||||
|
export const MAX_ROWS_PER_COLUMN = 10;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Propagating left-to-right tree layout.
|
||||||
|
*
|
||||||
|
* The rule that gives the tree its shape: a technology WITH prerequisites is
|
||||||
|
* placed one column to the right of its parents, at their average row. So
|
||||||
|
* linking A -> B makes B appear immediately to the right of A (short, local
|
||||||
|
* edge) and B's own dependents continue further right. Nothing is teleported
|
||||||
|
* to a distant column just because its depth changed.
|
||||||
|
*
|
||||||
|
* - Technologies in a category are pinned to that category's column (the
|
||||||
|
* category contract), stacked in depth/name order.
|
||||||
|
* - Uncategorized technologies with no prerequisites form a compact root grid
|
||||||
|
* starting after the category columns.
|
||||||
|
* - Uncategorized technologies with prerequisites follow their parents inside
|
||||||
|
* the uncategorized zone.
|
||||||
|
* - Collisions resolve by searching rows outward from the desired row, then
|
||||||
|
* spilling into the next column.
|
||||||
|
* - A manual `treePosition` always wins; category bands are the bounding box of
|
||||||
|
* their members' final positions.
|
||||||
|
*/
|
||||||
|
export function computeLayout(techs: TreeTech[], categories: TreeCategory[]): TechTreeLayout {
|
||||||
|
const byId = new Map<number, TreeTech>(techs.map((t) => [t.id, t]));
|
||||||
|
|
||||||
|
const parentsOf = (tech: TreeTech): number[] => {
|
||||||
|
const ids = [...tech.prereqAnd, ...tech.prereqOr];
|
||||||
|
return [...new Set(ids)].filter((id) => byId.has(id) && id !== tech.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Longest-path depth, used only for placement ORDER (parents before children)
|
||||||
|
// and for the forward-edge warnings. Cycle-safe.
|
||||||
|
const depthById = new Map<number, number>();
|
||||||
|
const inStack = new Set<number>();
|
||||||
|
const depthOf = (tech: TreeTech): number => {
|
||||||
|
const cached = depthById.get(tech.id);
|
||||||
|
if (cached !== undefined) return cached;
|
||||||
|
if (inStack.has(tech.id)) return 0;
|
||||||
|
inStack.add(tech.id);
|
||||||
|
const parents = parentsOf(tech);
|
||||||
|
const d = parents.length === 0 ? 0 : 1 + Math.max(...parents.map((id) => depthOf(byId.get(id)!)));
|
||||||
|
inStack.delete(tech.id);
|
||||||
|
depthById.set(tech.id, d);
|
||||||
|
return d;
|
||||||
|
};
|
||||||
|
techs.forEach(depthOf);
|
||||||
|
|
||||||
|
const sortedCategories = [...categories].sort(
|
||||||
|
(a, b) => a.position - b.position || a.name.localeCompare(b.name),
|
||||||
|
);
|
||||||
|
const columnOfCategory = new Map<number, number>(sortedCategories.map((c, i) => [c.id, i]));
|
||||||
|
const zoneStart = sortedCategories.length;
|
||||||
|
|
||||||
|
const isCategorized = (tech: TreeTech): boolean =>
|
||||||
|
tech.categoryId !== null && columnOfCategory.has(tech.categoryId);
|
||||||
|
|
||||||
|
const MAX_ROWS = MAX_ROWS_PER_COLUMN;
|
||||||
|
const occupancy = new Map<string, number>();
|
||||||
|
const positions = new Map<number, { col: number; row: number }>();
|
||||||
|
const cellKey = (col: number, row: number) => `${col}:${row}`;
|
||||||
|
|
||||||
|
const unbounded = Number.POSITIVE_INFINITY;
|
||||||
|
const findFreeRow = (col: number, desiredRow: number, maxRows: number): number | null => {
|
||||||
|
// Unbounded placement (dependents) may also grow UPWARDS, so a technology
|
||||||
|
// linked from a full column still lands right beside its parent.
|
||||||
|
const allowNegative = maxRows === unbounded;
|
||||||
|
for (let offset = 0; offset < 500; offset += 1) {
|
||||||
|
const down = desiredRow + offset;
|
||||||
|
if (down < maxRows && !occupancy.has(cellKey(col, down))) return down;
|
||||||
|
const up = desiredRow - offset;
|
||||||
|
if ((allowNegative || up >= 0) && !occupancy.has(cellKey(col, up))) return up;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `maxRows` caps how tall a column may grow before placement spills right.
|
||||||
|
* Grid-style placement (roots, category members) is capped; dependents are
|
||||||
|
* NOT, so a technology linked from a full column still lands immediately
|
||||||
|
* beside its parent instead of being pushed to a distant column.
|
||||||
|
*/
|
||||||
|
const placeNear = (
|
||||||
|
tech: TreeTech,
|
||||||
|
desiredCol: number,
|
||||||
|
desiredRow: number,
|
||||||
|
maxRows: number = MAX_ROWS,
|
||||||
|
) => {
|
||||||
|
let col = Math.max(0, desiredCol);
|
||||||
|
for (let attempt = 0; attempt < 200; attempt += 1) {
|
||||||
|
const row = findFreeRow(col, Math.max(0, desiredRow), maxRows);
|
||||||
|
if (row !== null) {
|
||||||
|
occupancy.set(cellKey(col, row), tech.id);
|
||||||
|
positions.set(tech.id, { col, row });
|
||||||
|
return { col, row };
|
||||||
|
}
|
||||||
|
col += 1; // column full: spill to the right
|
||||||
|
}
|
||||||
|
occupancy.set(cellKey(col, 0), tech.id);
|
||||||
|
positions.set(tech.id, { col, row: 0 });
|
||||||
|
return { col, row: 0 };
|
||||||
|
};
|
||||||
|
|
||||||
|
const byDepthThenName = (a: TreeTech, b: TreeTech) =>
|
||||||
|
depthOf(a) - depthOf(b) || a.name.localeCompare(b.name);
|
||||||
|
|
||||||
|
// 1) Categorized technologies are pinned to their category's column.
|
||||||
|
const nextRowByColumn = new Map<number, number>();
|
||||||
|
for (const tech of techs.filter(isCategorized).sort(byDepthThenName)) {
|
||||||
|
const col = columnOfCategory.get(tech.categoryId!)!;
|
||||||
|
const desired = nextRowByColumn.get(col) ?? 0;
|
||||||
|
const placed = placeNear(tech, col, desired);
|
||||||
|
nextRowByColumn.set(col, placed.row + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Uncategorized roots: a catalog grid starting after the categories.
|
||||||
|
//
|
||||||
|
// Slots come from each tech's rank among ALL uncategorized technologies, not
|
||||||
|
// among the current roots, so a tech that becomes a dependent leaves its
|
||||||
|
// catalog slot EMPTY instead of shifting every other root. That is what keeps
|
||||||
|
// connecting two nodes from re-shuffling the whole grid.
|
||||||
|
const catalogRank = new Map<number, number>();
|
||||||
|
[...techs]
|
||||||
|
.filter((tech) => !isCategorized(tech))
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name))
|
||||||
|
.forEach((tech, index) => catalogRank.set(tech.id, index));
|
||||||
|
for (const tech of techs) {
|
||||||
|
if (isCategorized(tech) || parentsOf(tech).length > 0) continue;
|
||||||
|
const slot = catalogRank.get(tech.id) ?? 0;
|
||||||
|
placeNear(tech, zoneStart + Math.floor(slot / MAX_ROWS), slot % MAX_ROWS);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) Uncategorized dependents: one column right of their parents, at their row.
|
||||||
|
const dependents = techs
|
||||||
|
.filter((tech) => !isCategorized(tech) && parentsOf(tech).length > 0)
|
||||||
|
.sort(byDepthThenName);
|
||||||
|
for (const tech of dependents) {
|
||||||
|
const parentPositions = parentsOf(tech)
|
||||||
|
.map((id) => positions.get(id))
|
||||||
|
.filter((p): p is { col: number; row: number } => Boolean(p));
|
||||||
|
if (parentPositions.length === 0) {
|
||||||
|
// Unplaced parents (cycle): park it in the uncategorized zone.
|
||||||
|
placeNear(tech, zoneStart, 0);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const desiredCol = Math.max(zoneStart, Math.max(...parentPositions.map((p) => p.col)) + 1);
|
||||||
|
const desiredRow = Math.round(
|
||||||
|
parentPositions.reduce((sum, p) => sum + p.row, 0) / parentPositions.length,
|
||||||
|
);
|
||||||
|
placeNear(tech, desiredCol, desiredRow, unbounded);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manual overrides win over the automatic grid.
|
||||||
|
const finalPosition = (tech: TreeTech): { x: number; y: number } => {
|
||||||
|
const manual = tech.treePosition;
|
||||||
|
if (manual && Number.isFinite(manual.x) && Number.isFinite(manual.y)) {
|
||||||
|
return { x: manual.x, y: manual.y };
|
||||||
|
}
|
||||||
|
const placed = positions.get(tech.id) ?? { col: 0, row: 0 };
|
||||||
|
return { x: placed.col * COLUMN_STEP, y: placed.row * ROW_STEP };
|
||||||
|
};
|
||||||
|
|
||||||
|
const nodes: TechLayoutNode[] = techs.map((tech) => {
|
||||||
|
const placed = positions.get(tech.id) ?? { col: 0, row: 0 };
|
||||||
|
const { x, y } = finalPosition(tech);
|
||||||
|
return { techId: tech.id, column: placed.col, row: placed.row, x, y };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Forward-edge warnings: a prerequisite placed to the RIGHT of its dependent.
|
||||||
|
const columnById = new Map(nodes.map((n) => [n.techId, n.column]));
|
||||||
|
const warnings: LayoutWarning[] = [];
|
||||||
|
for (const tech of techs) {
|
||||||
|
for (const parentId of parentsOf(tech)) {
|
||||||
|
const parent = byId.get(parentId)!;
|
||||||
|
if ((columnById.get(parentId) ?? 0) > (columnById.get(tech.id) ?? 0)) {
|
||||||
|
warnings.push({
|
||||||
|
techId: tech.id,
|
||||||
|
message: `Prerequisite "${parent.name}" sits to the right of "${tech.name}" on the timeline.`,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Category bands: bounding box of each group's final node positions.
|
||||||
|
const bandFor = (
|
||||||
|
key: string,
|
||||||
|
label: string,
|
||||||
|
color: string | null,
|
||||||
|
position: number | null,
|
||||||
|
members: TreeTech[],
|
||||||
|
): CategoryBandLayout | null => {
|
||||||
|
if (members.length === 0) return null;
|
||||||
|
const coords = members.map((m) => finalPosition(m));
|
||||||
|
const minX = Math.min(...coords.map((c) => c.x));
|
||||||
|
const maxX = Math.max(...coords.map((c) => c.x));
|
||||||
|
const minY = Math.min(...coords.map((c) => c.y));
|
||||||
|
const maxY = Math.max(...coords.map((c) => c.y));
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
label,
|
||||||
|
color,
|
||||||
|
x: minX - BAND_PADDING_X,
|
||||||
|
y: minY - BAND_PADDING_TOP,
|
||||||
|
width: maxX + NODE_WIDTH - minX + BAND_PADDING_X * 2,
|
||||||
|
height: maxY + NODE_HEIGHT - minY + BAND_PADDING_TOP + BAND_PADDING_BOTTOM,
|
||||||
|
position,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const bands: CategoryBandLayout[] = [];
|
||||||
|
for (const category of sortedCategories) {
|
||||||
|
const members = techs.filter((t) => t.categoryId === category.id);
|
||||||
|
const band = bandFor(
|
||||||
|
`cat-${category.id}`,
|
||||||
|
category.name,
|
||||||
|
category.color ?? null,
|
||||||
|
category.position,
|
||||||
|
members,
|
||||||
|
);
|
||||||
|
if (band) bands.push(band);
|
||||||
|
}
|
||||||
|
const uncategorizedBand = bandFor(
|
||||||
|
"uncategorized",
|
||||||
|
"Uncategorized",
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
techs.filter((t) => !isCategorized(t)),
|
||||||
|
);
|
||||||
|
if (uncategorizedBand) bands.push(uncategorizedBand);
|
||||||
|
|
||||||
|
const maxColumn = nodes.reduce((max, n) => Math.max(max, n.column), 0);
|
||||||
|
const maxRow = nodes.reduce((max, n) => Math.max(max, n.row), 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
nodes,
|
||||||
|
bands,
|
||||||
|
warnings,
|
||||||
|
columns: nodes.length === 0 ? 0 : maxColumn + 1,
|
||||||
|
rows: nodes.length === 0 ? 0 : maxRow + 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
67
src/components/admin/tech-tree/mappers.ts
Normal file
67
src/components/admin/tech-tree/mappers.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
import type { TreeCategory, TreeTech } from "./types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Project a `technologies` REST/local API doc (depth 0: relationships are
|
||||||
|
* plain ids) into the lightweight TreeTech shape used by the tree.
|
||||||
|
*/
|
||||||
|
export function mapTechDoc(doc: Record<string, unknown>): TreeTech {
|
||||||
|
const requirements = doc.requirements as { prerequisiteTechnologies?: unknown[] } | null;
|
||||||
|
const rows = Array.isArray(requirements?.prerequisiteTechnologies)
|
||||||
|
? (requirements!.prerequisiteTechnologies as Record<string, unknown>[])
|
||||||
|
: [];
|
||||||
|
const prereqAnd: number[] = [];
|
||||||
|
const prereqOr: number[] = [];
|
||||||
|
for (const row of rows) {
|
||||||
|
const requiresAnd = row?.requiresAnd as { technology?: unknown[] } | undefined;
|
||||||
|
const requiresOr = row?.requiresOr as { technology?: unknown[] } | undefined;
|
||||||
|
for (const t of requiresAnd?.technology ?? []) {
|
||||||
|
if (typeof t === "number") prereqAnd.push(t);
|
||||||
|
}
|
||||||
|
for (const t of requiresOr?.technology ?? []) {
|
||||||
|
if (typeof t === "number") prereqOr.push(t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const researchCosts = doc.researchCosts as { resources?: unknown[] } | null;
|
||||||
|
const costRows = Array.isArray(researchCosts?.resources)
|
||||||
|
? (researchCosts!.resources as Record<string, unknown>[])
|
||||||
|
: [];
|
||||||
|
const resourceCosts = costRows
|
||||||
|
.map((row) => ({
|
||||||
|
resource: row?.resource as number,
|
||||||
|
amount: typeof row?.amount === "number" ? row.amount : 0,
|
||||||
|
}))
|
||||||
|
.filter((row) => typeof row.resource === "number");
|
||||||
|
|
||||||
|
const manualPosition = doc.treePosition as { x?: unknown; y?: unknown } | null | undefined;
|
||||||
|
const treePosition =
|
||||||
|
manualPosition &&
|
||||||
|
typeof manualPosition.x === "number" &&
|
||||||
|
typeof manualPosition.y === "number" &&
|
||||||
|
Number.isFinite(manualPosition.x) &&
|
||||||
|
Number.isFinite(manualPosition.y)
|
||||||
|
? { x: manualPosition.x, y: manualPosition.y }
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: doc.id as number,
|
||||||
|
name: String(doc.name ?? ""),
|
||||||
|
type: doc.type === "asset" ? "asset" : "upgrade",
|
||||||
|
approvalStatus: String(doc.approvalStatus ?? "in_progress"),
|
||||||
|
summary: String(doc.summary ?? ""),
|
||||||
|
categoryId: typeof doc.category === "number" ? doc.category : null,
|
||||||
|
prereqAnd: [...new Set(prereqAnd)],
|
||||||
|
prereqOr: [...new Set(prereqOr)].filter((id) => !prereqAnd.includes(id)),
|
||||||
|
resourceCosts,
|
||||||
|
treePosition,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Project a `tech-categories` doc into the TreeCategory shape. */
|
||||||
|
export function mapCategoryDoc(doc: Record<string, unknown>): TreeCategory {
|
||||||
|
return {
|
||||||
|
id: doc.id as number,
|
||||||
|
name: String(doc.name ?? ""),
|
||||||
|
position: typeof doc.position === "number" ? doc.position : 1,
|
||||||
|
color: typeof doc.color === "string" && doc.color ? doc.color : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
114
src/components/admin/tech-tree/prereqEdits.ts
Normal file
114
src/components/admin/tech-tree/prereqEdits.ts
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
import type { TreeTech } from "./types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure helpers for editing a technology's prerequisites from the tree canvas
|
||||||
|
* and the side panel. The tree flattens the document's prerequisite rows into
|
||||||
|
* two unions (AND / OR), and edits are written back as a single row, which
|
||||||
|
* preserves the meaning of the rows the tree renders.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type PrereqLists = { and: number[]; or: number[] };
|
||||||
|
|
||||||
|
export type PrereqEditResult =
|
||||||
|
| { ok: true; lists: PrereqLists }
|
||||||
|
| { ok: false; error: string };
|
||||||
|
|
||||||
|
/** Does `fromId` (transitively) depend on `toId`? Cycle probe over prereq edges. */
|
||||||
|
export function dependsOn(techs: TreeTech[], fromId: number, toId: number): boolean {
|
||||||
|
const byId = new Map(techs.map((t) => [t.id, t]));
|
||||||
|
const seen = new Set<number>();
|
||||||
|
const stack = [fromId];
|
||||||
|
while (stack.length > 0) {
|
||||||
|
const current = stack.pop()!;
|
||||||
|
if (current === toId) return true;
|
||||||
|
if (seen.has(current)) continue;
|
||||||
|
seen.add(current);
|
||||||
|
const tech = byId.get(current);
|
||||||
|
if (!tech) continue;
|
||||||
|
stack.push(...tech.prereqAnd, ...tech.prereqOr);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The document-shaped prerequisite rows for a pair of id lists. */
|
||||||
|
export function buildPrereqRows(lists: PrereqLists) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
requiresAnd: { technology: lists.and },
|
||||||
|
requiresOr: { technology: lists.or },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add `sourceId` as a prerequisite of `targetId`. Rejects self-references,
|
||||||
|
* duplicates, and connections that would create a circular dependency.
|
||||||
|
*/
|
||||||
|
export function addPrerequisite(
|
||||||
|
techs: TreeTech[],
|
||||||
|
targetId: number,
|
||||||
|
sourceId: number,
|
||||||
|
kind: "and" | "or",
|
||||||
|
): PrereqEditResult {
|
||||||
|
const target = techs.find((t) => t.id === targetId);
|
||||||
|
if (!target) return { ok: false, error: "Technology not found." };
|
||||||
|
if (targetId === sourceId) {
|
||||||
|
return { ok: false, error: "A technology cannot be its own prerequisite." };
|
||||||
|
}
|
||||||
|
if (target.prereqAnd.includes(sourceId) || target.prereqOr.includes(sourceId)) {
|
||||||
|
return { ok: false, error: "That technology is already a prerequisite." };
|
||||||
|
}
|
||||||
|
// Adding source -> target means target depends on source. If source already
|
||||||
|
// depends on target, this closes a loop.
|
||||||
|
if (dependsOn(techs, sourceId, targetId)) {
|
||||||
|
return { ok: false, error: "That would create a circular dependency." };
|
||||||
|
}
|
||||||
|
const lists: PrereqLists = {
|
||||||
|
and: kind === "and" ? [...target.prereqAnd, sourceId] : [...target.prereqAnd],
|
||||||
|
or: kind === "or" ? [...target.prereqOr, sourceId] : [...target.prereqOr],
|
||||||
|
};
|
||||||
|
return { ok: true, lists };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove `sourceId` from both prerequisite lists of `targetId`. */
|
||||||
|
export function removePrerequisite(
|
||||||
|
techs: TreeTech[],
|
||||||
|
targetId: number,
|
||||||
|
sourceId: number,
|
||||||
|
): PrereqEditResult {
|
||||||
|
const target = techs.find((t) => t.id === targetId);
|
||||||
|
if (!target) return { ok: false, error: "Technology not found." };
|
||||||
|
const lists: PrereqLists = {
|
||||||
|
and: target.prereqAnd.filter((id) => id !== sourceId),
|
||||||
|
or: target.prereqOr.filter((id) => id !== sourceId),
|
||||||
|
};
|
||||||
|
return { ok: true, lists };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Move an existing prerequisite between the AND and OR groups. */
|
||||||
|
export function setPrerequisiteKind(
|
||||||
|
techs: TreeTech[],
|
||||||
|
targetId: number,
|
||||||
|
sourceId: number,
|
||||||
|
kind: "and" | "or",
|
||||||
|
): PrereqEditResult {
|
||||||
|
const target = techs.find((t) => t.id === targetId);
|
||||||
|
if (!target) return { ok: false, error: "Technology not found." };
|
||||||
|
if (!target.prereqAnd.includes(sourceId) && !target.prereqOr.includes(sourceId)) {
|
||||||
|
return { ok: false, error: "That technology is not a prerequisite yet." };
|
||||||
|
}
|
||||||
|
const lists: PrereqLists = {
|
||||||
|
and: kind === "and"
|
||||||
|
? [...new Set([...target.prereqAnd, sourceId])]
|
||||||
|
: target.prereqAnd.filter((id) => id !== sourceId),
|
||||||
|
or: kind === "or"
|
||||||
|
? [...new Set([...target.prereqOr, sourceId])]
|
||||||
|
: target.prereqOr.filter((id) => id !== sourceId),
|
||||||
|
};
|
||||||
|
return { ok: true, lists };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Apply a set of prerequisite lists to a TreeTech-shaped object. */
|
||||||
|
export function withPrereqs(tech: TreeTech, lists: PrereqLists): TreeTech {
|
||||||
|
return { ...tech, prereqAnd: lists.and, prereqOr: lists.or };
|
||||||
|
}
|
||||||
74
src/components/admin/tech-tree/types.ts
Normal file
74
src/components/admin/tech-tree/types.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
/**
|
||||||
|
* Shared shapes for the Tech Tree admin view. These are lightweight
|
||||||
|
* serializable projections of the `technologies` and `tech-categories`
|
||||||
|
* documents, built server-side by TechTreeView and consumed by the canvas.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type TreeTech = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
type: "asset" | "upgrade";
|
||||||
|
approvalStatus: string;
|
||||||
|
summary: string;
|
||||||
|
categoryId: number | null;
|
||||||
|
/** Union of all AND-row prerequisite technology ids. */
|
||||||
|
prereqAnd: number[];
|
||||||
|
/** Union of all OR-row prerequisite technology ids. */
|
||||||
|
prereqOr: number[];
|
||||||
|
/** Resource costs: resource id + amount. */
|
||||||
|
resourceCosts: { resource: number; amount: number }[];
|
||||||
|
/** Manual canvas position (dragged node); null = automatic layout. */
|
||||||
|
treePosition: { x: number; y: number } | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TreeCategory = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
position: number;
|
||||||
|
color: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LayoutWarning = {
|
||||||
|
techId: number;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TechLayoutNode = {
|
||||||
|
techId: number;
|
||||||
|
column: number;
|
||||||
|
row: number;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CategoryBandLayout = {
|
||||||
|
/** tech-categories id, or "uncategorized" for the trailing auto zone. */
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
color: string | null;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
position: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TechTreeLayout = {
|
||||||
|
nodes: TechLayoutNode[];
|
||||||
|
bands: CategoryBandLayout[];
|
||||||
|
warnings: LayoutWarning[];
|
||||||
|
columns: number;
|
||||||
|
rows: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Canvas geometry (px). */
|
||||||
|
export const NODE_WIDTH = 236;
|
||||||
|
export const NODE_HEIGHT = 148;
|
||||||
|
export const COLUMN_GAP = 132;
|
||||||
|
export const ROW_GAP = 36;
|
||||||
|
export const BAND_PADDING_X = 20;
|
||||||
|
export const BAND_PADDING_TOP = 64;
|
||||||
|
export const BAND_PADDING_BOTTOM = 28;
|
||||||
|
|
||||||
|
export const COLUMN_STEP = NODE_WIDTH + COLUMN_GAP;
|
||||||
|
export const ROW_STEP = NODE_HEIGHT + ROW_GAP;
|
||||||
|
|
@ -45,6 +45,7 @@ import { Shipments } from "@/collections/logistics/Shipments";
|
||||||
import { Assignments } from "@/collections/users/Assignments";
|
import { Assignments } from "@/collections/users/Assignments";
|
||||||
import { AssignmentTransfers } from "@/collections/users/AssignmentTransfers";
|
import { AssignmentTransfers } from "@/collections/users/AssignmentTransfers";
|
||||||
import { Technologies } from "@/collections/intelligence/Technologies";
|
import { Technologies } from "@/collections/intelligence/Technologies";
|
||||||
|
import { TechCategories } from "@/collections/intelligence/TechCategories";
|
||||||
import { GameRules } from "@/collections/game/GameRules";
|
import { GameRules } from "@/collections/game/GameRules";
|
||||||
import { GameStructures } from "@/collections/game/GameStructures";
|
import { GameStructures } from "@/collections/game/GameStructures";
|
||||||
import { GameHardResources } from "@/collections/game/GameHardResources";
|
import { GameHardResources } from "@/collections/game/GameHardResources";
|
||||||
|
|
@ -275,6 +276,7 @@ const collections = [
|
||||||
Campaigns,
|
Campaigns,
|
||||||
Factions,
|
Factions,
|
||||||
Technologies,
|
Technologies,
|
||||||
|
TechCategories,
|
||||||
|
|
||||||
// Logistics
|
// Logistics
|
||||||
Assets,
|
Assets,
|
||||||
|
|
@ -415,6 +417,13 @@ export default buildConfig({
|
||||||
Logo: "@/components/graphics/AppLogo",
|
Logo: "@/components/graphics/AppLogo",
|
||||||
Icon: "@/components/graphics/AppIcon",
|
Icon: "@/components/graphics/AppIcon",
|
||||||
},
|
},
|
||||||
|
Nav: "@/components/admin/tech-tree/TechTreeNav",
|
||||||
|
views: {
|
||||||
|
techTree: {
|
||||||
|
path: "/tech-tree",
|
||||||
|
Component: "@/components/admin/tech-tree/TechTreeView",
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
globals: [GameRules, Shims],
|
globals: [GameRules, Shims],
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue