diff --git a/src/app/(payload)/custom.scss b/src/app/(payload)/custom.scss index 03106d9..7526c74 100644 --- a/src/app/(payload)/custom.scss +++ b/src/app/(payload)/custom.scss @@ -23,3 +23,43 @@ @layer utilities { @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); + } +} diff --git a/src/components/admin/tech-tree/CategoryBand.tsx b/src/components/admin/tech-tree/CategoryBand.tsx new file mode 100644 index 0000000..ec3974d --- /dev/null +++ b/src/components/admin/tech-tree/CategoryBand.tsx @@ -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; + +/** + * 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) { + const { band, techCount } = data; + const accent = band.color ?? "#22d3ee"; + + return ( +
+
+ + {band.label} + + + {band.position !== null ? `POS ${band.position}` : "AUTO"} · {techCount} TECH + {techCount === 1 ? "" : "S"} + +
+
+
+
+ + TIER BAND // {band.label.toUpperCase()} + +
+ {/* Inert handles so React Flow never treats the band as connectable */} + + +
+ ); +} diff --git a/src/components/admin/tech-tree/CategoryPicker.tsx b/src/components/admin/tech-tree/CategoryPicker.tsx new file mode 100644 index 0000000..2a99e61 --- /dev/null +++ b/src/components/admin/tech-tree/CategoryPicker.tsx @@ -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) => { + 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 ( +
+ { + 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 ? ( +

+ {selected ? `${selected.name} (position ${selected.position})` : `${value} (new)`} +

+ ) : null} + {open ? ( +
+
+ {showCreate ? ( + + ) : null} + {matches.map((category) => ( + + ))} + {matches.length === 0 && !showCreate ? ( +

+ {trimmed.length > 0 ? `No category matches "${trimmed}".` : "No categories yet."} +

+ ) : null} +
+
+ ) : null} +
+ ); +} diff --git a/src/components/admin/tech-tree/PanelSelect.tsx b/src/components/admin/tech-tree/PanelSelect.tsx new file mode 100644 index 0000000..764aae0 --- /dev/null +++ b/src/components/admin/tech-tree/PanelSelect.tsx @@ -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 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} +
+ {filtered.length === 0 ? ( +

+ NO MATCHES +

+ ) : null} + {filtered.map((option) => ( + + ))} +
+
+ ) : null; + + return ( +
+ + + {typeof document !== "undefined" && list ? createPortal(list, document.body) : null} +
+ ); +} diff --git a/src/components/admin/tech-tree/QuickCreateDialog.tsx b/src/components/admin/tech-tree/QuickCreateDialog.tsx new file mode 100644 index 0000000..111583c --- /dev/null +++ b/src/components/admin/tech-tree/QuickCreateDialog.tsx @@ -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(null); + const [categoryIsNew, setCategoryIsNew] = React.useState(false); + const [busy, setBusy] = React.useState(false); + const [error, setError] = React.useState(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 ( + + + + + New Technology // Quick Create + + +
+
+ + setName(e.target.value)} + placeholder="e.g. Composite Armor" + data-testid="tech-create-name" + /> +
+
+ + +
+
+ +