Compare commits
No commits in common. "308a522764306a2dae9fc9b949ecd3dd06f9b5fc" and "9ca85fce9b2692d8ca283cfc51f64405b7f4be61" have entirely different histories.
308a522764
...
9ca85fce9b
39 changed files with 816 additions and 1969 deletions
|
|
@ -198,13 +198,13 @@ Labor/staffing simulation for structures. Collections in `src/collections/game/`
|
|||
|
||||
Leaflet world map at `/map` (top-level sidebar entry, whole unit views it). Full docs: `docs/map-system.md`.
|
||||
|
||||
- **Collections** (`src/collections/world/`): `maps` (redesigned: `worldSizeWidth`/`worldSizeHeight` meters, `basemapMode` select `image`/`tiles`, `basemapImage` upload, `tileUrlTemplate`, zoom defaults, `isActive`), `map-roads` (name, description, map rel, surface select, `speedMultiplier`, strokeColor/strokeWeight/strokeStyle/labelVisible (tri-state: unset = auto, paved labels always, dirt/trail only at close zoom via `roadLabelShown` in style.ts, 4 m/px threshold), `points` JSON array of [x, y] meters), `map-zones` (name, description, type select water/land/territory/border (territory/border are informational political boundaries that never gate placement; default fills amber 0.1 / violet 0.06), fillColor/strokeColor/fillOpacity/strokeWeight/labelVisible, polygon points JSON), `resource-nodes` (`resources` hasMany rel, `position` JSON [x, y], richness, name, description). Feature collections read: any logged-in user; write: `maps:*` permissions. Admin-panel visibility needs `<slug>:read` + `admin:<slug>:manage` ("Map Features" permission group).
|
||||
- **Collections** (`src/collections/world/`): `maps` (redesigned: `worldSizeWidth`/`worldSizeHeight` meters, `basemapMode` select `image`/`tiles`, `basemapImage` upload, `tileUrlTemplate`, zoom defaults, `isActive`), `map-roads` (name, description, map rel, surface select, `speedMultiplier`, strokeColor/strokeWeight/strokeStyle/labelVisible, `points` JSON array of [x, y] meters), `map-zones` (name, description, type select water/land, fillColor/strokeColor/fillOpacity/strokeWeight/labelVisible, polygon points JSON), `resource-nodes` (`resources` hasMany rel, `position` JSON [x, y], richness, name, description). Feature collections read: any logged-in user; write: `maps:*` permissions. Admin-panel visibility needs `<slug>:read` + `admin:<slug>:manage` ("Map Features" permission group).
|
||||
- **Coordinates**: real meters, per-map Cartesian grid, origin top-left, y runs south (Arma convention). JSON geometry for map features; `game-structures.coordinates` keeps its PostGIS point but stores meters (`validate: () => true` overrides Payload's geographic lng/lat validation; meters are not degrees).
|
||||
- **Routing**: `src/lib/map/routing.ts` (pure) builds a graph from road polylines (1 m vertex epsilon; shared vertex = junction), Dijkstra weighted by `length / speedMultiplier`; origin/destination snap to the nearest vertex within 1000 m; no connected route refuses ground shipments with a clear error. Air/sea (`vehicles.transportMode`) go straight-line. Distance = geometric route length in meters; `routePath` JSON snapshot on shipments (auto-return reverses it). Speed math converts meters to km once in `calculateTransitTime`; fuel stays `meters x rate`.
|
||||
- **Placement**: `src/lib/map/placement.ts` (pure: terrain point-in-polygon, resourcesInRange vs multi-resource nodes OR resource-bearing zones at the site, structuresInRange) + `placeStructure` action (`src/app/(frontend)/logistics/map/actions.ts`, hasLogisticsQualification gate, emits `structure:placed`). Map UI placement mode for logistics-qualified users (click to place, dialog with blueprint/name/faction, client pre-validation + server enforcement). Water zones block land blueprints; water blueprints need a water zone; no zones = everything allowed.
|
||||
- **Construction**: `src/lib/map/construction.ts`. Construction runs on timestamps: `maybeStartConstruction(payload, siteId, now)` is called at delivery time (shipment `completeShipment`, the structures `addResource`/`transferResource` actions via `addResourceInternal`, and `placeStructure` for materialless blueprints) and, when every blueprint material is stored, consumes them via `consumeStorage`, sets `constructionStartedAt`/`constructionCompletesAt` from the blueprint's `constructionDurationMinutes` (minutes, replacing the old tick-based `constructionTime`), and flips `awaiting_materials` to `building`. The game tick's `processConstructionTick` is a sweeper only: building sites past `constructionCompletesAt` flip to `complete` + `structure:construction-complete` (idempotent). Default constructionStatus on game-structures is `complete` (backward compat). ConstructionPanel on the structure detail page shows required vs delivered + a timestamp-derived progress bar; map markers interpolate progress from the timestamps.
|
||||
- **UI**: `/map` (`src/app/(frontend)/map/page.tsx` + `MapLoader` ssr:false + `src/components/frontend/map/MapClient.tsx`): CRS.Simple with y-flip (latlng = [-y, x]), image (ImageOverlay) or XYZ (TileLayer) basemap, map switcher, layer toggles (structures/nodes/roads/zones/shipments/ranges), structure markers as status-colored pins (emerald building icon = operational, blue hammer = building, amber package = awaiting materials, with a progress bar under construction sites), popovers linking to `/logistics/structures/[id]`, shipment markers interpolated along `routePath` from `dispatchedAt`/`estimatedArrival`, refreshed by `useGameTick`. Feature data served by `GET /api/map-features?mapId=` (view surface, logged-in). Map labels: zone names render at the polygon centroid, road names render as curved SVG textPath labels following a Catmull-Rom smoothed road line every 400 screen px (max 12/road, flipped to a reversed path when the direction would render upside down or straight down, so vertical labels read bottom-to-top; dark casing stroke for contrast), structure names render as persistent labels with cluster priority (`labelVisible` + `labelPriority` on game-structures; winner = highest `labelPriority`, then stored mass, then id; cluster radius 56 screen px, so zooming in reveals suppressed labels). Road/zone stroke + fill colors, widths, dash styles, and opacities are editable in the authoring toolbar (`strokeColor`/`strokeWeight`/`strokeStyle` on roads; `fillColor`/`strokeColor`/`fillOpacity`/`strokeWeight` on zones; hex validated by `validateHexColor` in `src/lib/map/style.ts`, empty = per-type defaults). Pure label/styling math lives in `src/lib/map/style.ts` (`metersPerPx`, `roadLabelPoints`, `polygonCentroid`, `dashArrayFor`, `roadLabelShown`, `labelScreenAngle`; roads under 600 screen px render one straight midpoint label instead of curved text, since glyphs fold on tiny wiggly paths).
|
||||
- **Authoring**: edit mode on `/map` (maps:*-gated) draws roads/zones/nodes, edits existing features (popup Edit loads geometry + attributes into the authoring toolbar; Save passes the feature id and requires `maps:update`), and deletes features (server actions in the same actions.ts). Drafts get vertex editing: draggable square handles per draft vertex (drag-end snaps), right-click deletes (min 2 road / 3 zone points), cyan midpoint handles insert a vertex; clicks and drags snap within 12 screen px to feature vertices and zone/road edges (closing segments included; vertices beat edges unless the edge is 2x closer) via pure `src/lib/map/snapping.ts` (`findSnap`, `closestPointOnSegment`, `segmentMidpoints`; `AuthoringSnapLayer` + `DraftHandles` in MapClient). `POST /api/map-import` imports a GeoJSON FeatureCollection (LineString to roads, Polygon to zones, Point to nodes; [x, y] positions used directly as map meters). Mapping table in `docs/map-system.md`.
|
||||
- **UI**: `/map` (`src/app/(frontend)/map/page.tsx` + `MapLoader` ssr:false + `src/components/frontend/map/MapClient.tsx`): CRS.Simple with y-flip (latlng = [-y, x]), image (ImageOverlay) or XYZ (TileLayer) basemap, map switcher, layer toggles (structures/nodes/roads/zones/shipments/ranges), structure markers as status-colored pins (emerald building icon = operational, blue hammer = building, amber package = awaiting materials, with a progress bar under construction sites), popovers linking to `/logistics/structures/[id]`, shipment markers interpolated along `routePath` from `dispatchedAt`/`estimatedArrival`, refreshed by `useGameTick`. Feature data served by `GET /api/map-features?mapId=` (view surface, logged-in). Map labels: zone names render at the polygon centroid, road names render as curved SVG textPath labels following a Catmull-Rom smoothed road line every 400 screen px (max 12/road, flipped to a reversed path when the direction would render upside down or straight down, so vertical labels read bottom-to-top; dark casing stroke for contrast), structure names render as persistent labels with cluster priority (`labelVisible` + `labelPriority` on game-structures; winner = highest `labelPriority`, then stored mass, then id; cluster radius 56 screen px, so zooming in reveals suppressed labels). Road/zone stroke + fill colors, widths, dash styles, and opacities are editable in the authoring toolbar (`strokeColor`/`strokeWeight`/`strokeStyle` on roads; `fillColor`/`strokeColor`/`fillOpacity`/`strokeWeight` on zones; hex validated by `validateHexColor` in `src/lib/map/style.ts`, empty = per-type defaults). Pure label/styling math lives in `src/lib/map/style.ts` (`metersPerPx`, `roadLabelPoints`, `polygonCentroid`, `dashArrayFor`).
|
||||
- **Authoring**: edit mode on `/map` (maps:*-gated) draws roads/zones/nodes, edits existing features (popup Edit loads geometry + attributes into the authoring toolbar; Save passes the feature id and requires `maps:update`), and deletes features (server actions in the same actions.ts); `POST /api/map-import` imports a GeoJSON FeatureCollection (LineString to roads, Polygon to zones, Point to nodes; [x, y] positions used directly as map meters). Mapping table in `docs/map-system.md`.
|
||||
- **GameRules**: `proximityThreshold` now means meters (default 1000; migration backfilled rows still at the old default 10). Both call sites (shipments/actions.ts, structures/actions.ts transferResource) use meters.
|
||||
- **Tests**: `tests/int/map-system.int.spec.ts` (routing graph/snap/refusals, cross-map refusal, ground route + path snapshot, air straight-line, proximity, terrain/range validation, placeStructure happy/water/range cases, construction tick lifecycle, permission gating, label/styling helpers + save-action styling validation). Gotcha: Payload applies select `filterOptions` server-side, so test fixtures must use `approvalStatus: "in_progress"`.
|
||||
|
||||
|
|
|
|||
|
|
@ -100,14 +100,10 @@ stays display-only in the placement dialog (no research system exists).
|
|||
|
||||
- **New road**: click vertices along the route, at least 2; set name,
|
||||
description, surface (paved/dirt/trail), speed multiplier, and styling
|
||||
(stroke color hex, width 1-10, dash style, name-label select: Auto/Shown/
|
||||
Hidden, where Auto labels paved roads always and dirt/trail roads only at
|
||||
close zoom, 4 m/px or finer); Save.
|
||||
(stroke color hex, width 1-10, dash style, name-label toggle); Save.
|
||||
- **New zone**: click at least 3 vertices; set name, description, type
|
||||
(water/land/territory/border), and styling (fill color, edge color, fill
|
||||
opacity 0-1, edge width, name-label toggle); Save. Territory and Country
|
||||
Border are informational political boundaries (they never gate placement;
|
||||
only water does) and default to amber 0.1 / violet 0.06 fill.
|
||||
(water/land), and styling (fill color, edge color, fill opacity 0-1, edge
|
||||
width, name-label toggle); Save.
|
||||
- **New node**: click once to set the position; set name, description,
|
||||
resource, richness; Save.
|
||||
- Existing roads/zones/nodes show Edit + Delete buttons in their popup while
|
||||
|
|
@ -116,20 +112,6 @@ stays display-only in the placement dialog (no research system exists).
|
|||
the fields, then "Save changes" (requires `maps:update`). Renaming, retyping,
|
||||
restyling, and description edits work the same way; the feature's map cannot
|
||||
be changed.
|
||||
- **Vertex editing**: while a road/zone/node draft is active, every draft
|
||||
vertex renders as a draggable square handle: drag to move it (drag-end
|
||||
snaps), right-click to delete it (roads keep 2+ points, zones 3+), and the
|
||||
small cyan square at the middle of each segment inserts a vertex there when
|
||||
clicked.
|
||||
- **Snapping**: while authoring, clicks and dragged vertices snap to existing
|
||||
geometry within 12 screen px: feature vertices (roads/zones/nodes/
|
||||
structures, including the draft's own other vertices for closing polygons)
|
||||
and edges (nearest point on any road segment or zone side, closing segment
|
||||
included) so a road can run exactly along a zone border or two zones can
|
||||
share an edge without gaps or overlap. Vertices beat edges near corners (an
|
||||
edge must be at least twice as close as the nearest vertex to win); a
|
||||
moving square preview shows where the click will land (emerald = vertex,
|
||||
cyan = edge).
|
||||
|
||||
### Labels and styling on the live map
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "polaris-task-force",
|
||||
"version": "0.2.41",
|
||||
"version": "0.2.40",
|
||||
"description": "Web app for the Arma 3 unit Polaris Task Force. Uses Next.js for client- and server-side rendering, with a Postgres database and some other neat libraries.",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
|
|
|||
|
|
@ -240,7 +240,7 @@ export interface MapZoneInput {
|
|||
mapId: number;
|
||||
name?: string | null;
|
||||
description?: string | null;
|
||||
type: "water" | "land" | "territory" | "border";
|
||||
type: "water" | "land";
|
||||
resourceIds?: number[];
|
||||
fillColor?: string | null;
|
||||
strokeColor?: string | null;
|
||||
|
|
|
|||
|
|
@ -3253,130 +3253,6 @@
|
|||
border-radius: 1.5px;
|
||||
}
|
||||
|
||||
.map-structure-pin.map-pin-md {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.map-structure-pin.map-pin-lg {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
}
|
||||
|
||||
.map-structure-ribbon {
|
||||
position: absolute;
|
||||
left: calc(100% + 6px);
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
padding: 2px 8px 3px;
|
||||
border: 1px solid rgb(255 255 255 / 15%);
|
||||
border-left-width: 2px;
|
||||
border-radius: 1px;
|
||||
background: rgb(8 10 14 / 88%);
|
||||
box-shadow: 0 1px 4px rgb(0 0 0 / 50%);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.map-structure-ribbon .ribbon-name {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: rgb(255 255 255 / 95%);
|
||||
max-width: 190px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.map-structure-ribbon .ribbon-meta {
|
||||
font-family:
|
||||
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New",
|
||||
monospace;
|
||||
font-size: 9px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
color: rgb(255 255 255 / 65%);
|
||||
}
|
||||
|
||||
.map-tooltip-dossier .map-tooltip-name {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
color: rgb(255 255 255 / 95%);
|
||||
}
|
||||
|
||||
/* Must out-rank leaflet.css, which styles the SAME selector later (it loads
|
||||
in the MapClient chunk after styles.css) with a white background. */
|
||||
.map-host .leaflet-container .leaflet-control-attribution {
|
||||
background: rgb(8 10 14 / 92%);
|
||||
color: rgb(255 255 255 / 55%);
|
||||
font-family:
|
||||
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New",
|
||||
monospace;
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
padding: 1px 6px;
|
||||
border: 1px solid rgb(255 255 255 / 15%);
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.leaflet-container .leaflet-control-attribution a {
|
||||
color: rgb(148 163 184 / 85%);
|
||||
}
|
||||
|
||||
.map-tooltip-dossier .map-tooltip-meta {
|
||||
margin-top: 1px;
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.12em;
|
||||
color: rgb(255 255 255 / 60%);
|
||||
}
|
||||
|
||||
/* A hovered structure (pin or ribbon) jumps above every other marker. The
|
||||
!important is required to beat Leaflet's inline z-index management. */
|
||||
.map-structure-icon.leaflet-marker-icon:hover {
|
||||
z-index: 1200 !important;
|
||||
}
|
||||
|
||||
.map-vertex-handle {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border: 1.5px solid #f8fafc;
|
||||
border-radius: 2px;
|
||||
background: rgb(8 10 14 / 75%);
|
||||
box-shadow: 0 0 0 1px rgb(0 0 0 / 60%);
|
||||
}
|
||||
|
||||
.map-midpoint-handle {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border: 1.5px solid #22d3ee;
|
||||
border-radius: 1px;
|
||||
background: rgb(8 10 14 / 40%);
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.map-snap-preview {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
margin-left: -6px;
|
||||
margin-top: -6px;
|
||||
border-radius: 2px;
|
||||
border: 1.5px solid;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.map-snap-vertex {
|
||||
border-color: #34d399;
|
||||
background: rgb(52 211 153 / 25%);
|
||||
}
|
||||
|
||||
.map-snap-edge {
|
||||
border-color: #22d3ee;
|
||||
background: rgb(34 211 238 / 25%);
|
||||
}
|
||||
|
||||
.map-structure-progress {
|
||||
position: absolute;
|
||||
left: 3px;
|
||||
|
|
@ -3521,29 +3397,6 @@
|
|||
overflow: visible;
|
||||
}
|
||||
|
||||
.map-road-label {
|
||||
color: rgb(255 255 255 / 94%);
|
||||
font-family:
|
||||
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New",
|
||||
monospace;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.2em;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
/* Size to the text itself: inside the 0x0 divIcon container a block div
|
||||
resolves to 0 width, which makes the percentage centering transform a
|
||||
no-op and leaves the label hanging off the road. */
|
||||
width: max-content;
|
||||
background: rgb(8 10 14 / 62%);
|
||||
padding: 1px 5px;
|
||||
border-radius: 1px;
|
||||
text-shadow:
|
||||
0 0 3px rgb(0 0 0 / 95%),
|
||||
0 0 7px rgb(0 0 0 / 90%),
|
||||
0 0 2px rgb(0 0 0 / 90%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.map-road-label-path {
|
||||
fill: none;
|
||||
stroke: none;
|
||||
|
|
|
|||
|
|
@ -96,10 +96,10 @@ export const MapRoads: CollectionConfig = {
|
|||
{
|
||||
name: "labelVisible",
|
||||
type: "checkbox",
|
||||
defaultValue: true,
|
||||
label: "Show Name Label",
|
||||
admin: {
|
||||
description:
|
||||
"Auto (unset): only paved roads show labels. Check to force on, uncheck to force off. The map authoring toolbar's Label select writes this.",
|
||||
description: "Renders the road's name along the line at zoom-dependent intervals.",
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -48,12 +48,10 @@ export const MapZones: CollectionConfig = {
|
|||
options: [
|
||||
{ label: "Water", value: "water" },
|
||||
{ label: "Land", value: "land" },
|
||||
{ label: "Territory", value: "territory" },
|
||||
{ label: "Country Border", value: "border" },
|
||||
],
|
||||
admin: {
|
||||
description:
|
||||
"Water zones block land-blueprint placement. Land zones are informational; default terrain is buildable land. Territory and Country Border are political boundaries (informational).",
|
||||
"Water zones block land-blueprint placement. Land zones are informational; default terrain is buildable land.",
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
@ -78,8 +76,7 @@ export const MapZones: CollectionConfig = {
|
|||
min: 0,
|
||||
max: 1,
|
||||
admin: {
|
||||
description:
|
||||
"Fill opacity 0-1. Empty = default (0.25 water, 0.08 land, 0.1 territory, 0.06 border).",
|
||||
description: "Fill opacity 0-1. Empty = default (0.25 water, 0.08 land).",
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
@ -117,8 +114,7 @@ export const MapZones: CollectionConfig = {
|
|||
required: true,
|
||||
validate: validatePolygon,
|
||||
admin: {
|
||||
description:
|
||||
"Polygon ring as an array of [x, y] meter tuples (need not repeat the first point).",
|
||||
description: "Polygon ring as an array of [x, y] meter tuples (need not repeat the first point).",
|
||||
},
|
||||
},
|
||||
],
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import { useRouter } from "next/navigation";
|
|||
import { toast } from "sonner";
|
||||
import { ArrowLeftRightIcon, Loader2Icon } from "lucide-react";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Combobox } from "@/components/ui/combobox";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
|
|
@ -17,14 +16,7 @@ import {
|
|||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { requestTransfer } from "@/app/(frontend)/transfers/actions";
|
||||
|
|
@ -207,20 +199,22 @@ export function RequestTransferDialog({
|
|||
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="transfer-source">Current assignment</Label>
|
||||
<Combobox
|
||||
id="transfer-source"
|
||||
value={sourceId}
|
||||
onValueChange={setSourceId}
|
||||
disabled={pending}
|
||||
placeholder="Select current assignment"
|
||||
searchPlaceholder="Search assignments..."
|
||||
options={requestableAssignments.map((assignment) => ({
|
||||
value: String(assignment.id),
|
||||
label: assignment.callsign
|
||||
? `${assignment.name} · ${assignment.callsign}`
|
||||
: assignment.name,
|
||||
}))}
|
||||
/>
|
||||
<Select value={sourceId} onValueChange={setSourceId} disabled={pending}>
|
||||
<SelectTrigger id="transfer-source">
|
||||
<SelectValue placeholder="Select current assignment" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{requestableAssignments.map((assignment) => (
|
||||
<SelectItem key={assignment.id} value={String(assignment.id)}>
|
||||
{assignment.callsign
|
||||
? `${assignment.name} · ${assignment.callsign}`
|
||||
: assignment.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5" aria-busy={loadingDestinations}>
|
||||
|
|
@ -228,20 +222,26 @@ export function RequestTransferDialog({
|
|||
{loadingDestinations ? (
|
||||
<Skeleton className="h-9 w-full" />
|
||||
) : destinations.length > 0 ? (
|
||||
<Combobox
|
||||
id="transfer-destination"
|
||||
<Select
|
||||
value={destinationId}
|
||||
onValueChange={setDestinationId}
|
||||
disabled={pending}
|
||||
placeholder="Select destination assignment"
|
||||
searchPlaceholder="Search assignments..."
|
||||
options={destinations.map((assignment) => ({
|
||||
value: String(assignment.id),
|
||||
label: assignment.callsign
|
||||
? `${assignment.name} · ${assignment.callsign}`
|
||||
: assignment.name,
|
||||
}))}
|
||||
/>
|
||||
>
|
||||
<SelectTrigger id="transfer-destination">
|
||||
<SelectValue placeholder="Select destination assignment" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{destinations.map((assignment) => (
|
||||
<SelectItem key={assignment.id} value={String(assignment.id)}>
|
||||
{assignment.callsign
|
||||
? `${assignment.name} · ${assignment.callsign}`
|
||||
: assignment.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
|
|
@ -271,18 +271,10 @@ export function RequestTransferDialog({
|
|||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setInternalOpen(false)}
|
||||
disabled={pending}
|
||||
>
|
||||
<Button type="button" variant="outline" onClick={() => setInternalOpen(false)} disabled={pending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={pending || loadingDestinations || destinations.length === 0}
|
||||
>
|
||||
<Button type="submit" disabled={pending || loadingDestinations || destinations.length === 0}>
|
||||
{pending && <Loader2Icon data-icon="inline-start" className="animate-spin" />}
|
||||
{pending ? "Submitting..." : "Submit request"}
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import { useRouter } from "next/navigation";
|
|||
import { ArrowDownLeftIcon, ArrowRightLeftIcon, ArrowUpRightIcon } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Combobox } from "@/components/ui/combobox";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
|
|
@ -165,17 +164,18 @@ export function BankTransactionDialog({
|
|||
{mode === "transfer" && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>Destination account</Label>
|
||||
<Combobox
|
||||
value={toId}
|
||||
onValueChange={setToId}
|
||||
placeholder="Select account"
|
||||
searchPlaceholder="Search accounts..."
|
||||
emptyMessage="No other accounts."
|
||||
options={otherAccounts.map((a) => ({
|
||||
value: String(a.id),
|
||||
label: a.name,
|
||||
}))}
|
||||
/>
|
||||
<Select value={toId} onValueChange={setToId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select account" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{otherAccounts.map((a) => (
|
||||
<SelectItem key={a.id} value={String(a.id)}>
|
||||
{a.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import { useRouter } from "next/navigation";
|
|||
import { PlusIcon } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Combobox } from "@/components/ui/combobox";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
|
|
@ -118,16 +117,18 @@ export function CreateAccountDialog({
|
|||
{accountType === "faction" && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>Owner faction</Label>
|
||||
<Combobox
|
||||
value={ownerFactionId}
|
||||
onValueChange={setOwnerFactionId}
|
||||
placeholder="Select faction"
|
||||
searchPlaceholder="Search factions..."
|
||||
options={factions.map((f) => ({
|
||||
value: String(f.id),
|
||||
label: f.name,
|
||||
}))}
|
||||
/>
|
||||
<Select value={ownerFactionId} onValueChange={setOwnerFactionId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select faction" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{factions.map((f) => (
|
||||
<SelectItem key={f.id} value={String(f.id)}>
|
||||
{f.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
{accountType === "personal" && !isManager && (
|
||||
|
|
|
|||
|
|
@ -15,8 +15,15 @@ import {
|
|||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Combobox } from "@/components/ui/combobox";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { LaborClassificationOption, StructureStaffingOption } from "@/lib/base";
|
||||
import { currencyLabel, type CurrencyConfig } from "@/lib/banking/format";
|
||||
|
|
@ -346,51 +353,61 @@ export function HireStaffDialog({
|
|||
{fixedNpc?.name ?? `NPC #${fixedNpcId}`}
|
||||
</p>
|
||||
) : (
|
||||
<Combobox
|
||||
value={npcId}
|
||||
onValueChange={setNpcId}
|
||||
placeholder="Select an NPC"
|
||||
searchPlaceholder="Search NPCs..."
|
||||
emptyMessage="No candidates available."
|
||||
options={npcs.map((npc) => ({
|
||||
value: String(npc.id),
|
||||
label: npc.name,
|
||||
}))}
|
||||
/>
|
||||
<Select value={npcId} onValueChange={setNpcId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select an NPC" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{npcs.map((npc) => (
|
||||
<SelectItem key={npc.id} value={String(npc.id)}>
|
||||
{npc.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>Structure</Label>
|
||||
<Combobox
|
||||
value={structureId}
|
||||
onValueChange={handleStructureChange}
|
||||
placeholder="Select a structure"
|
||||
searchPlaceholder="Search structures..."
|
||||
options={structures.map((structure) => ({
|
||||
value: String(structure.id),
|
||||
label: structure.name,
|
||||
}))}
|
||||
/>
|
||||
<Select value={structureId} onValueChange={handleStructureChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a structure" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{structures.map((structure) => (
|
||||
<SelectItem key={structure.id} value={String(structure.id)}>
|
||||
{structure.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>Position type</Label>
|
||||
<Combobox
|
||||
value={positionType}
|
||||
onValueChange={handlePositionTypeChange}
|
||||
searchPlaceholder="Search positions..."
|
||||
options={[
|
||||
...(fixedNpcId == null && approvedClassifications.length > 0
|
||||
? [{ value: "labor", label: "Labor" }]
|
||||
: []),
|
||||
...approvedPositionTypes.map((position) => ({
|
||||
value: position.value,
|
||||
label: position.label,
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
<Select value={positionType} onValueChange={handlePositionTypeChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{fixedNpcId == null && approvedClassifications.length > 0 && (
|
||||
<SelectItem value="labor">Labor</SelectItem>
|
||||
)}
|
||||
{approvedPositionTypes.map((position) => (
|
||||
<SelectItem key={position.value} value={position.value}>
|
||||
{position.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{selectedStructure &&
|
||||
approvedClassifications.length === 0 &&
|
||||
approvedPositionTypes.length === 0 && (
|
||||
|
|
@ -404,15 +421,20 @@ export function HireStaffDialog({
|
|||
<>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>Classification</Label>
|
||||
<Combobox
|
||||
value={laborClassification}
|
||||
onValueChange={handleClassificationChange}
|
||||
searchPlaceholder="Search classifications..."
|
||||
options={approvedClassifications.map((classification) => ({
|
||||
value: String(classification.id),
|
||||
label: classification.name,
|
||||
}))}
|
||||
/>
|
||||
<Select value={laborClassification} onValueChange={handleClassificationChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{approvedClassifications.map((classification) => (
|
||||
<SelectItem key={classification.id} value={String(classification.id)}>
|
||||
{classification.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{selectedStructure && approvedClassifications.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This structure has no approved labor positions.
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import * as React from "react";
|
|||
import { ChevronLeft, ChevronRight, RotateCcw, Search } from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Combobox } from "@/components/ui/combobox";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
|
|
@ -306,20 +305,19 @@ export default function EventLogTable() {
|
|||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<Combobox
|
||||
className="h-8 w-44"
|
||||
value={targetCollection}
|
||||
onValueChange={setTargetCollection}
|
||||
placeholder="Target"
|
||||
searchPlaceholder="Search targets..."
|
||||
options={[
|
||||
{ value: "all", label: "All targets" },
|
||||
...TARGET_COLLECTION_OPTIONS.map(([value, label]) => ({
|
||||
value,
|
||||
label,
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
<Select value={targetCollection} onValueChange={setTargetCollection}>
|
||||
<SelectTrigger className="h-8 w-44">
|
||||
<SelectValue placeholder="Target" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-h-72">
|
||||
<SelectItem value="all">All targets</SelectItem>
|
||||
{TARGET_COLLECTION_OPTIONS.map(([value, label]) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select value={source} onValueChange={(v) => setSource(v as SourceFilter)}>
|
||||
<SelectTrigger className="h-8 w-36">
|
||||
|
|
@ -444,7 +442,10 @@ export default function EventLogTable() {
|
|||
Showing {from.toLocaleString()}-{to.toLocaleString()} of {totalDocs.toLocaleString()}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={String(pageSize)} onValueChange={(v) => setPageSize(Number(v))}>
|
||||
<Select
|
||||
value={String(pageSize)}
|
||||
onValueChange={(v) => setPageSize(Number(v))}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-28">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ import { useRouter } from "next/navigation";
|
|||
import { toast } from "sonner";
|
||||
import type { Ticket, User } from "@/payload-types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Combobox } from "@/components/ui/combobox";
|
||||
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
|
|
@ -265,21 +264,23 @@ export function TicketDetail({
|
|||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="ticket-assignee">Assignee</Label>
|
||||
<div className="flex gap-2">
|
||||
<Combobox
|
||||
id="ticket-assignee"
|
||||
className="min-w-0 flex-1"
|
||||
<Select
|
||||
value={assignee}
|
||||
onValueChange={setAssignee}
|
||||
disabled={controlBusy !== null}
|
||||
searchPlaceholder="Search members..."
|
||||
options={[
|
||||
{ value: "unassigned", label: "Unassigned" },
|
||||
...assigneeOptions.map((option) => ({
|
||||
value: String(option.id),
|
||||
label: option.label,
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
>
|
||||
<SelectTrigger id="ticket-assignee" className="min-w-0 flex-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="unassigned">Unassigned</SelectItem>
|
||||
{assigneeOptions.map((option) => (
|
||||
<SelectItem key={option.id} value={String(option.id)}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => void applyControl("assignee")}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ import {
|
|||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Combobox } from "@/components/ui/combobox";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
|
@ -22,7 +21,11 @@ import {
|
|||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import {
|
||||
createFridaySideOp,
|
||||
updateFridaySideOp,
|
||||
|
|
@ -235,9 +238,7 @@ export function CreateSideOpDialog({
|
|||
}
|
||||
|
||||
function modsInput() {
|
||||
return requiredModsId === "none"
|
||||
? { requiredModsId: null }
|
||||
: { requiredModsId: Number(requiredModsId) };
|
||||
return requiredModsId === "none" ? { requiredModsId: null } : { requiredModsId: Number(requiredModsId) };
|
||||
}
|
||||
|
||||
async function handleModlistUpload(file: File) {
|
||||
|
|
@ -343,8 +344,7 @@ export function CreateSideOpDialog({
|
|||
estimatedDuration: Number(estimatedDuration) || 240,
|
||||
maxPlayers: Number(maxPlayers) || 32,
|
||||
...modsInput(),
|
||||
campaignId:
|
||||
campaignChoice === "new" || campaignChoice === "none" ? null : Number(campaignChoice),
|
||||
campaignId: campaignChoice === "new" || campaignChoice === "none" ? null : Number(campaignChoice),
|
||||
newCampaignName: campaignChoice === "new" ? newCampaignName || null : null,
|
||||
noCampaign: campaignChoice === "none",
|
||||
...narrativeInput(),
|
||||
|
|
@ -430,20 +430,25 @@ export function CreateSideOpDialog({
|
|||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="grid gap-1.5">
|
||||
<Label>Map</Label>
|
||||
<Combobox
|
||||
className="w-full"
|
||||
<Select
|
||||
value={mapChoice}
|
||||
onValueChange={(v) => {
|
||||
setMapChoice(v);
|
||||
if (v !== "custom" && v !== "") setMapId(v);
|
||||
}}
|
||||
placeholder="Choose a map"
|
||||
searchPlaceholder="Search maps..."
|
||||
options={[
|
||||
...maps.map((m) => ({ value: String(m.id), label: m.name })),
|
||||
{ value: "custom", label: "+ Custom map…" },
|
||||
]}
|
||||
/>
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Choose a map" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{maps.map((m) => (
|
||||
<SelectItem key={m.id} value={String(m.id)}>
|
||||
{m.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectItem value="custom">+ Custom map…</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label>Mission type</Label>
|
||||
|
|
@ -485,22 +490,20 @@ export function CreateSideOpDialog({
|
|||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label>Campaign</Label>
|
||||
<Combobox
|
||||
className="w-full"
|
||||
value={campaignChoice}
|
||||
onValueChange={setCampaignChoice}
|
||||
placeholder="Choose a campaign"
|
||||
searchPlaceholder="Search campaigns..."
|
||||
options={[
|
||||
{ value: "none", label: "No campaign (one-off op)" },
|
||||
...campaigns.map((c) => ({
|
||||
value: String(c.id),
|
||||
label: c.name,
|
||||
disabled: c.disabled,
|
||||
})),
|
||||
{ value: "new", label: "+ New campaign…" },
|
||||
]}
|
||||
/>
|
||||
<Select value={campaignChoice} onValueChange={setCampaignChoice}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Choose a campaign" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">No campaign (one-off op)</SelectItem>
|
||||
{campaigns.map((c) => (
|
||||
<SelectItem key={c.id} value={String(c.id)} disabled={c.disabled}>
|
||||
{c.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectItem value="new">+ New campaign…</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
{mapChoice === "custom" && (
|
||||
|
|
@ -528,17 +531,19 @@ export function CreateSideOpDialog({
|
|||
|
||||
<div className="grid gap-1.5">
|
||||
<Label>Mod list</Label>
|
||||
<Combobox
|
||||
className="w-full"
|
||||
value={requiredModsId}
|
||||
onValueChange={setRequiredModsId}
|
||||
placeholder="No mod list (vanilla)"
|
||||
searchPlaceholder="Search mod lists..."
|
||||
options={[
|
||||
{ value: "none", label: "No mod list (vanilla)" },
|
||||
...modLists.map((l) => ({ value: String(l.id), label: l.name })),
|
||||
]}
|
||||
/>
|
||||
<Select value={requiredModsId} onValueChange={setRequiredModsId}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="No mod list (vanilla)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">No mod list (vanilla)</SelectItem>
|
||||
{modLists.map((l) => (
|
||||
<SelectItem key={l.id} value={String(l.id)}>
|
||||
{l.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input
|
||||
type="file"
|
||||
accept=".html,.htm,.txt,text/html,text/plain"
|
||||
|
|
@ -550,8 +555,8 @@ export function CreateSideOpDialog({
|
|||
}}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Upload the mod list file exported from the Arma 3 launcher (.html) or a .txt mod list,
|
||||
or pick an existing one.
|
||||
Upload the mod list file exported from the Arma 3 launcher (.html) or a .txt mod
|
||||
list, or pick an existing one.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -5,9 +5,15 @@ import { useRouter } from "next/navigation";
|
|||
import { PlusIcon } from "lucide-react";
|
||||
import type { Asset } from "@/payload-types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Combobox } from "@/components/ui/combobox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
|
|
@ -80,17 +86,18 @@ export function AddLockerItemDialog({ assets }: AddLockerItemDialogProps) {
|
|||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>Asset</Label>
|
||||
<Combobox
|
||||
value={assetId}
|
||||
onValueChange={setAssetId}
|
||||
placeholder="Select an asset"
|
||||
searchPlaceholder="Search assets..."
|
||||
emptyMessage="No assets in your locker."
|
||||
options={assets.map((a) => ({
|
||||
value: String(a.id),
|
||||
label: `${a.name} · ${getAssetTypeLabel(a.assetType)}`,
|
||||
}))}
|
||||
/>
|
||||
<Select value={assetId} onValueChange={setAssetId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select an asset" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{assets.map((a) => (
|
||||
<SelectItem key={a.id} value={String(a.id)}>
|
||||
{a.name} · {getAssetTypeLabel(a.assetType)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>Quantity</Label>
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import { useEffect, useMemo, useState } from "react";
|
|||
import { useRouter } from "next/navigation";
|
||||
import type { Asset } from "@/payload-types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Combobox } from "@/components/ui/combobox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
|
|
@ -15,6 +14,13 @@ import {
|
|||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
getAssetTypeLabel,
|
||||
getAttachmentOptionsForItem,
|
||||
|
|
@ -196,24 +202,30 @@ export function EquipmentEditorDialog({
|
|||
className="grid grid-cols-[100px_1fr] items-center gap-2"
|
||||
>
|
||||
<span className="text-xs text-muted-foreground">{category.label}</span>
|
||||
<Combobox
|
||||
<Select
|
||||
value={installed ? String(installed.id) : "none"}
|
||||
onValueChange={(v) => handleCategoryPick(category, v)}
|
||||
disabled={busy}
|
||||
placeholder={
|
||||
categoryOptions.length === 0
|
||||
? "No compatible items"
|
||||
: "Choose an attachment"
|
||||
}
|
||||
searchPlaceholder="Search attachments..."
|
||||
options={[
|
||||
{ value: "none", label: "None" },
|
||||
...categoryOptions.map((o) => ({
|
||||
value: String(o.assetId),
|
||||
label: `${o.asset.name} · ${o.quantity > 0 ? `${o.quantity.toLocaleString()}×` : "installed"}`,
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={
|
||||
categoryOptions.length === 0
|
||||
? "No compatible items"
|
||||
: "Choose an attachment"
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">None</SelectItem>
|
||||
{categoryOptions.map((o) => (
|
||||
<SelectItem key={o.assetId} value={String(o.assetId)}>
|
||||
{o.asset.name} ·{" "}
|
||||
{o.quantity > 0 ? `${o.quantity.toLocaleString()}×` : "installed"}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
|
@ -228,22 +240,28 @@ export function EquipmentEditorDialog({
|
|||
<Label className="text-sm font-semibold">Skin</Label>
|
||||
<div className="grid grid-cols-[100px_1fr] items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">Applied</span>
|
||||
<Combobox
|
||||
<Select
|
||||
value={installedSkin ? String(installedSkin.id) : "none"}
|
||||
onValueChange={handleSkinPick}
|
||||
disabled={busy}
|
||||
placeholder={
|
||||
mergedSkinOptions.length === 0 ? "No compatible skins" : "Choose a skin"
|
||||
}
|
||||
searchPlaceholder="Search skins..."
|
||||
options={[
|
||||
{ value: "none", label: "None" },
|
||||
...mergedSkinOptions.map((o) => ({
|
||||
value: String(o.assetId),
|
||||
label: `${o.asset.name} · ${o.quantity > 0 ? `${o.quantity.toLocaleString()}×` : "applied"}`,
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={
|
||||
mergedSkinOptions.length === 0 ? "No compatible skins" : "Choose a skin"
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">None</SelectItem>
|
||||
{mergedSkinOptions.map((o) => (
|
||||
<SelectItem key={o.assetId} value={String(o.assetId)}>
|
||||
{o.asset.name} ·{" "}
|
||||
{o.quantity > 0 ? `${o.quantity.toLocaleString()}×` : "applied"}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{installedSkin && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import { useEffect, useState } from "react";
|
|||
import { useRouter } from "next/navigation";
|
||||
import type { Loadout } from "@/payload-types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Combobox } from "@/components/ui/combobox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
|
@ -161,21 +160,24 @@ export function LoadoutEditor({
|
|||
return (
|
||||
<div key={slot.name} className="flex flex-col gap-1.5">
|
||||
<Label>{slot.label}</Label>
|
||||
<Combobox
|
||||
<Select
|
||||
value={slots[slot.name] ?? ""}
|
||||
onValueChange={(v) =>
|
||||
setSlots((prev) => ({ ...prev, [slot.name]: v === "none" ? "" : v }))
|
||||
}
|
||||
placeholder={slot.description}
|
||||
searchPlaceholder="Search items..."
|
||||
options={[
|
||||
{ value: "none", label: "Empty" },
|
||||
...options.map((item) => ({
|
||||
value: String(item.assetId),
|
||||
label: item.asset.name,
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={slot.description} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">Empty</SelectItem>
|
||||
{options.map((item) => (
|
||||
<SelectItem key={item.assetId} value={String(item.assetId)}>
|
||||
{item.asset.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{options.length === 0 && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
No matching assets in your locker.
|
||||
|
|
|
|||
|
|
@ -4,7 +4,13 @@ import { useState } from "react";
|
|||
import { useRouter } from "next/navigation";
|
||||
import type { Loadout } from "@/payload-types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Combobox } from "@/components/ui/combobox";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { setLoadoutSlot, equipLoadout } from "@/app/(frontend)/locker/actions";
|
||||
import {
|
||||
getLoadoutSlot,
|
||||
|
|
@ -71,18 +77,22 @@ export function LockerCharacterPanel({ loadouts, items }: LockerCharacterPanelPr
|
|||
</div>
|
||||
|
||||
<div className="border-b border-border/50 p-2">
|
||||
<Combobox
|
||||
className="h-8 rounded-sm border-border/60 bg-background/40 text-xs uppercase tracking-wide"
|
||||
<Select
|
||||
value={equippedLoadout ? String(equippedLoadout.id) : ""}
|
||||
onValueChange={changeLoadout}
|
||||
disabled={busy || loadouts.length === 0}
|
||||
placeholder="SELECT LOADOUT"
|
||||
searchPlaceholder="Search loadouts..."
|
||||
options={loadouts.map((loadout) => ({
|
||||
value: String(loadout.id),
|
||||
label: loadout.name,
|
||||
}))}
|
||||
/>
|
||||
>
|
||||
<SelectTrigger className="h-8 rounded-sm border-border/60 bg-background/40 text-xs uppercase tracking-wide">
|
||||
<SelectValue placeholder="SELECT LOADOUT" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{loadouts.map((loadout) => (
|
||||
<SelectItem key={loadout.id} value={String(loadout.id)}>
|
||||
{loadout.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="p-2">
|
||||
|
|
|
|||
|
|
@ -4,8 +4,14 @@ import { useState } from "react";
|
|||
import { useRouter } from "next/navigation";
|
||||
import type { Asset, Loadout } from "@/payload-types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Combobox } from "@/components/ui/combobox";
|
||||
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Wardrobe } from "./Wardrobe";
|
||||
import { WardrobeSlotPicker } from "./WardrobeSlotPicker";
|
||||
import {
|
||||
|
|
@ -109,20 +115,26 @@ export function WardrobePanel({ userName, loadouts, equippedLoadout, items }: Wa
|
|||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Combobox
|
||||
className="flex-1"
|
||||
<Select
|
||||
value={equippedLoadout ? String(equippedLoadout.id) : ""}
|
||||
onValueChange={(v) => handleEquip(Number(v))}
|
||||
disabled={busy}
|
||||
placeholder={
|
||||
equippedLoadout ? equippedLoadout.name : "Select a loadout to equip"
|
||||
}
|
||||
searchPlaceholder="Search loadouts..."
|
||||
options={loadouts.map((l) => ({
|
||||
value: String(l.id),
|
||||
label: l.name,
|
||||
}))}
|
||||
/>
|
||||
>
|
||||
<SelectTrigger className="flex-1">
|
||||
<SelectValue
|
||||
placeholder={
|
||||
equippedLoadout ? equippedLoadout.name : "Select a loadout to equip"
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{loadouts.map((l) => (
|
||||
<SelectItem key={l.id} value={String(l.id)}>
|
||||
{l.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{equippedLoadout && (
|
||||
<Button
|
||||
variant="outline"
|
||||
|
|
|
|||
|
|
@ -56,16 +56,14 @@ export interface MapAuthoring {
|
|||
setStrokeWeight: (value: string) => void;
|
||||
strokeStyle: "solid" | "dashed" | "dotted";
|
||||
setStrokeStyle: (value: "solid" | "dashed" | "dotted") => void;
|
||||
roadLabelVisible: boolean | null;
|
||||
setRoadLabelVisible: (value: boolean | null) => void;
|
||||
labelVisible: boolean;
|
||||
setLabelVisible: (value: boolean) => void;
|
||||
zoneName: string;
|
||||
setZoneName: (value: string) => void;
|
||||
zoneDescription: string;
|
||||
setZoneDescription: (value: string) => void;
|
||||
zoneType: "water" | "land" | "territory" | "border";
|
||||
setZoneType: (value: "water" | "land" | "territory" | "border") => void;
|
||||
zoneLabelVisible: boolean;
|
||||
setZoneLabelVisible: (value: boolean) => void;
|
||||
zoneType: "water" | "land";
|
||||
setZoneType: (value: "water" | "land") => void;
|
||||
fillColor: string;
|
||||
setFillColor: (value: string) => void;
|
||||
zoneStrokeColor: string;
|
||||
|
|
@ -87,9 +85,6 @@ export interface MapAuthoring {
|
|||
};
|
||||
setKind: (kind: AuthoringKind) => void;
|
||||
pushPoint: (point: MapPoint) => void;
|
||||
updateDraftPoint: (index: number, point: MapPoint) => void;
|
||||
insertDraftPoint: (index: number, point: MapPoint) => void;
|
||||
removeDraftPoint: (index: number) => void;
|
||||
clearDraft: () => void;
|
||||
save: () => Promise<void>;
|
||||
removeFeature: (target: "road" | "zone" | "node", id: number) => Promise<void>;
|
||||
|
|
@ -109,11 +104,10 @@ export function useMapAuthoring(options: { mapId: number; onSaved: () => void })
|
|||
const [strokeColor, setStrokeColor] = useState("");
|
||||
const [strokeWeight, setStrokeWeight] = useState("");
|
||||
const [strokeStyle, setStrokeStyle] = useState<"solid" | "dashed" | "dotted">("solid");
|
||||
const [roadLabelVisible, setRoadLabelVisible] = useState<boolean | null>(null);
|
||||
const [labelVisible, setLabelVisible] = useState(true);
|
||||
const [zoneName, setZoneName] = useState("");
|
||||
const [zoneDescription, setZoneDescription] = useState("");
|
||||
const [zoneType, setZoneType] = useState<"water" | "land" | "territory" | "border">("water");
|
||||
const [zoneLabelVisible, setZoneLabelVisible] = useState(true);
|
||||
const [zoneType, setZoneType] = useState<"water" | "land">("water");
|
||||
const [fillColor, setFillColor] = useState("");
|
||||
const [zoneStrokeColor, setZoneStrokeColor] = useState("");
|
||||
const [fillOpacity, setFillOpacity] = useState("");
|
||||
|
|
@ -142,31 +136,6 @@ export function useMapAuthoring(options: { mapId: number; onSaved: () => void })
|
|||
setDraftPoints((current) => [...current, point]);
|
||||
}, []);
|
||||
|
||||
const updateDraftPoint = useCallback((index: number, point: MapPoint) => {
|
||||
setDraftPoints((current) => current.map((existing, i) => (i === index ? point : existing)));
|
||||
}, []);
|
||||
|
||||
const insertDraftPoint = useCallback((index: number, point: MapPoint) => {
|
||||
setDraftPoints((current) => {
|
||||
if (index < 0 || index > current.length) return current;
|
||||
return [...current.slice(0, index), point, ...current.slice(index)];
|
||||
});
|
||||
}, []);
|
||||
|
||||
const removeDraftPoint = useCallback(
|
||||
(index: number) => {
|
||||
setDraftPoints((current) => {
|
||||
const minimum = kind === "zone" ? 3 : kind === "road" ? 2 : 1;
|
||||
if (current.length <= minimum) {
|
||||
toast.error(`A ${kind ?? "feature"} needs at least ${minimum} points.`);
|
||||
return current;
|
||||
}
|
||||
return current.filter((_, i) => i !== index);
|
||||
});
|
||||
},
|
||||
[kind],
|
||||
);
|
||||
|
||||
const clearDraft = useCallback(() => setDraftPoints([]), []);
|
||||
|
||||
const setKind = useCallback((next: AuthoringKind) => {
|
||||
|
|
@ -185,7 +154,7 @@ export function useMapAuthoring(options: { mapId: number; onSaved: () => void })
|
|||
setStrokeColor(road.strokeColor ?? "");
|
||||
setStrokeWeight(road.strokeWeight != null ? String(road.strokeWeight) : "");
|
||||
setStrokeStyle(road.strokeStyle ?? "solid");
|
||||
setRoadLabelVisible(road.labelVisible);
|
||||
setLabelVisible(road.labelVisible !== false);
|
||||
setEditing({ kind: "road", id: road.id, label: road.name ?? `Road #${road.id}` });
|
||||
}, []);
|
||||
|
||||
|
|
@ -200,7 +169,7 @@ export function useMapAuthoring(options: { mapId: number; onSaved: () => void })
|
|||
setZoneStrokeColor(zone.strokeColor ?? "");
|
||||
setFillOpacity(zone.fillOpacity != null ? String(zone.fillOpacity) : "");
|
||||
setZoneStrokeWeight(zone.strokeWeight != null ? String(zone.strokeWeight) : "");
|
||||
setZoneLabelVisible(zone.labelVisible !== false);
|
||||
setLabelVisible(zone.labelVisible !== false);
|
||||
setEditing({ kind: "zone", id: zone.id, label: zone.name ?? `Zone #${zone.id}` });
|
||||
}, []);
|
||||
|
||||
|
|
@ -243,7 +212,7 @@ export function useMapAuthoring(options: { mapId: number; onSaved: () => void })
|
|||
strokeColor: strokeColor.trim() || null,
|
||||
strokeWeight: strokeWeight.trim() ? Number(strokeWeight) : null,
|
||||
strokeStyle,
|
||||
labelVisible: roadLabelVisible,
|
||||
labelVisible,
|
||||
points: draftPoints,
|
||||
});
|
||||
if (!result.success) throw new Error(result.error ?? "Save failed.");
|
||||
|
|
@ -260,7 +229,7 @@ export function useMapAuthoring(options: { mapId: number; onSaved: () => void })
|
|||
strokeColor: zoneStrokeColor.trim() || null,
|
||||
fillOpacity: fillOpacity.trim() ? Number(fillOpacity) : null,
|
||||
strokeWeight: zoneStrokeWeight.trim() ? Number(zoneStrokeWeight) : null,
|
||||
labelVisible: zoneLabelVisible,
|
||||
labelVisible,
|
||||
points: draftPoints,
|
||||
});
|
||||
if (!result.success) throw new Error(result.error ?? "Save failed.");
|
||||
|
|
@ -296,8 +265,7 @@ export function useMapAuthoring(options: { mapId: number; onSaved: () => void })
|
|||
setZoneStrokeColor("");
|
||||
setFillOpacity("");
|
||||
setZoneStrokeWeight("");
|
||||
setRoadLabelVisible(null);
|
||||
setZoneLabelVisible(true);
|
||||
setLabelVisible(true);
|
||||
onSaved();
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "Save failed.");
|
||||
|
|
@ -315,12 +283,11 @@ export function useMapAuthoring(options: { mapId: number; onSaved: () => void })
|
|||
strokeColor,
|
||||
strokeWeight,
|
||||
strokeStyle,
|
||||
roadLabelVisible,
|
||||
labelVisible,
|
||||
draftPoints,
|
||||
zoneName,
|
||||
zoneDescription,
|
||||
zoneType,
|
||||
zoneLabelVisible,
|
||||
fillColor,
|
||||
zoneStrokeColor,
|
||||
fillOpacity,
|
||||
|
|
@ -361,16 +328,14 @@ export function useMapAuthoring(options: { mapId: number; onSaved: () => void })
|
|||
setStrokeWeight,
|
||||
strokeStyle,
|
||||
setStrokeStyle,
|
||||
roadLabelVisible,
|
||||
setRoadLabelVisible,
|
||||
labelVisible,
|
||||
setLabelVisible,
|
||||
zoneName,
|
||||
setZoneName,
|
||||
zoneDescription,
|
||||
setZoneDescription,
|
||||
zoneType,
|
||||
setZoneType,
|
||||
zoneLabelVisible,
|
||||
setZoneLabelVisible,
|
||||
fillColor,
|
||||
setFillColor,
|
||||
zoneStrokeColor,
|
||||
|
|
@ -392,9 +357,6 @@ export function useMapAuthoring(options: { mapId: number; onSaved: () => void })
|
|||
},
|
||||
setKind,
|
||||
pushPoint,
|
||||
updateDraftPoint,
|
||||
insertDraftPoint,
|
||||
removeDraftPoint,
|
||||
clearDraft,
|
||||
save,
|
||||
removeFeature,
|
||||
|
|
@ -529,11 +491,6 @@ export function AuthoringToolbar({ authoring, mapId }: { authoring: MapAuthoring
|
|||
{kind === "zone" && authoring.draftPoints.length < 3 && " (need 3+)"}
|
||||
{kind === "node" && authoring.draftPoints.length === 0 && " (click to set)"}
|
||||
</span>
|
||||
{kind != null && (
|
||||
<span className="text-[10px] uppercase tracking-wide text-white/40">
|
||||
drag squares to move, right-click to delete, cyan squares insert
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
|
|
@ -664,32 +621,14 @@ export function AuthoringToolbar({ authoring, mapId }: { authoring: MapAuthoring
|
|||
className="w-full rounded-none border border-white/20 bg-black/40 px-2 py-1 text-xs text-white placeholder:text-white/30"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label className="text-[10px] uppercase text-white/60">Label</Label>
|
||||
<Select
|
||||
value={
|
||||
fields.roadLabelVisible == null
|
||||
? "auto"
|
||||
: fields.roadLabelVisible
|
||||
? "shown"
|
||||
: "hidden"
|
||||
}
|
||||
onValueChange={(value) =>
|
||||
fields.setRoadLabelVisible(
|
||||
value === "shown" ? true : value === "hidden" ? false : null,
|
||||
)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-20 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto">Auto</SelectItem>
|
||||
<SelectItem value="shown">Shown</SelectItem>
|
||||
<SelectItem value="hidden">Hidden</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<label className="flex items-center gap-1.5 pb-1 text-[10px] uppercase tracking-wide text-white/60">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={fields.labelVisible}
|
||||
onChange={(event) => fields.setLabelVisible(event.target.checked)}
|
||||
/>
|
||||
Label
|
||||
</label>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
|
@ -710,9 +649,7 @@ export function AuthoringToolbar({ authoring, mapId }: { authoring: MapAuthoring
|
|||
<Label className="text-[10px] uppercase text-white/60">Type</Label>
|
||||
<Select
|
||||
value={fields.zoneType}
|
||||
onValueChange={(value) =>
|
||||
fields.setZoneType(value as "water" | "land" | "territory" | "border")
|
||||
}
|
||||
onValueChange={(value) => fields.setZoneType(value as "water" | "land")}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-24 text-xs">
|
||||
<SelectValue />
|
||||
|
|
@ -720,8 +657,6 @@ export function AuthoringToolbar({ authoring, mapId }: { authoring: MapAuthoring
|
|||
<SelectContent>
|
||||
<SelectItem value="water">Water</SelectItem>
|
||||
<SelectItem value="land">Land</SelectItem>
|
||||
<SelectItem value="territory">Territory</SelectItem>
|
||||
<SelectItem value="border">Country Border</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
|
@ -771,8 +706,8 @@ export function AuthoringToolbar({ authoring, mapId }: { authoring: MapAuthoring
|
|||
<label className="flex items-center gap-1.5 pb-1 text-[10px] uppercase tracking-wide text-white/60">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={fields.zoneLabelVisible}
|
||||
onChange={(event) => fields.setZoneLabelVisible(event.target.checked)}
|
||||
checked={fields.labelVisible}
|
||||
onChange={(event) => fields.setLabelVisible(event.target.checked)}
|
||||
/>
|
||||
Label
|
||||
</label>
|
||||
|
|
|
|||
|
|
@ -30,20 +30,11 @@ import {
|
|||
} from "@/components/ui/select";
|
||||
import { useGameTick } from "@/hooks/useGameTick";
|
||||
import { distanceSquared, interpolateAlong, polylineLength, type MapPoint } from "@/lib/map/points";
|
||||
import {
|
||||
findSnap,
|
||||
segmentMidpoints,
|
||||
type SnapKind,
|
||||
type SnapMatch,
|
||||
type SnapTarget,
|
||||
} from "@/lib/map/snapping";
|
||||
import {
|
||||
dashArrayFor,
|
||||
labelScreenAngle,
|
||||
metersPerPx,
|
||||
polygonCentroid,
|
||||
roadLabelPoints,
|
||||
roadLabelShown,
|
||||
splinePolyline,
|
||||
} from "@/lib/map/style";
|
||||
import type { MapFeaturesResponse, ShipmentFeature, StructureFeature } from "@/lib/map/types";
|
||||
|
|
@ -75,11 +66,9 @@ const STRUCTURE_STATUS: Record<string, { color: string; label: string }> = {
|
|||
// Layer styling is hoisted to module constants: react-leaflet re-applies
|
||||
// pathOptions by reference, and a fresh object literal every render forces a
|
||||
// canvas redraw that glitches mid-zoom-animation.
|
||||
const ZONE_PATH_OPTIONS: Record<"water" | "land" | "territory" | "border", L.PathOptions> = {
|
||||
const ZONE_PATH_OPTIONS: Record<"water" | "land", L.PathOptions> = {
|
||||
water: { color: "#38bdf8", fillColor: "#38bdf8", fillOpacity: 0.25, weight: 1 },
|
||||
land: { color: "#4ade80", fillColor: "#4ade80", fillOpacity: 0.08, weight: 1 },
|
||||
territory: { color: "#fbbf24", fillColor: "#fbbf24", fillOpacity: 0.1, weight: 1 },
|
||||
border: { color: "#a78bfa", fillColor: "#a78bfa", fillOpacity: 0.06, weight: 1 },
|
||||
};
|
||||
|
||||
const ROAD_PATH_OPTIONS: Record<string, L.PathOptions> = {
|
||||
|
|
@ -122,22 +111,11 @@ const BASEMAP_FALLBACK_PATH_OPTIONS: L.PathOptions = {
|
|||
weight: 1,
|
||||
};
|
||||
|
||||
type StructurePinTier = "sm" | "md" | "lg";
|
||||
|
||||
const STRUCTURE_PIN_SIZES: Record<StructurePinTier, number> = { sm: 26, md: 32, lg: 38 };
|
||||
const STRUCTURE_PIN_SVG_SIZES: Record<StructurePinTier, number> = { sm: 13, md: 16, lg: 20 };
|
||||
|
||||
const ROAD_LABEL_SPACING_PX = 400;
|
||||
const ROAD_LABEL_MIN_SCREEN_PX = 120;
|
||||
const ROAD_LABEL_CURVE_MIN_PX = 600;
|
||||
const ROAD_LABELS_MAX = 12;
|
||||
const ROAD_LABEL_CHAR_PX = 9;
|
||||
const STRUCTURE_LABEL_CLUSTER_PX = 56;
|
||||
const STRUCTURE_PIN_MID_M_PER_PX = 8;
|
||||
const STRUCTURE_RIBBON_M_PER_PX = 4;
|
||||
const RIBBON_HEIGHT_PX = 32;
|
||||
const RIBBON_GAP_PX = 6;
|
||||
const RIBBON_PIN_HALF_PX = STRUCTURE_PIN_SIZES.lg / 2;
|
||||
|
||||
function escapeHtml(text: string): string {
|
||||
return text
|
||||
|
|
@ -169,24 +147,6 @@ function polylinePathD(points: MapPoint[]): string {
|
|||
.join(" ");
|
||||
}
|
||||
|
||||
const straightLabelIconCache = new Map<string, L.DivIcon>();
|
||||
|
||||
function straightLabelIcon(name: string, angle: number): L.DivIcon {
|
||||
const bucket = Math.round(angle / 5) * 5;
|
||||
const cacheKey = `${name}:${bucket}`;
|
||||
let icon = straightLabelIconCache.get(cacheKey);
|
||||
if (!icon) {
|
||||
icon = L.divIcon({
|
||||
className: "map-road-label-icon",
|
||||
html: `<div class="map-road-label" style="transform: translate(-50%, -50%) rotate(${bucket}deg)">${escapeHtml(name.toUpperCase())}</div>`,
|
||||
iconSize: [0, 0],
|
||||
iconAnchor: [0, 0],
|
||||
});
|
||||
straightLabelIconCache.set(cacheKey, icon);
|
||||
}
|
||||
return icon;
|
||||
}
|
||||
|
||||
/**
|
||||
* Catmull-Rom spline through the points, emitted as cubic Beziers, so label
|
||||
* glyphs turn gently through bends instead of snapping at each kink. The
|
||||
|
|
@ -253,9 +213,9 @@ const STRUCTURE_SVG_PATHS: Record<string, string> = {
|
|||
'<path d="M16.5 9.4 7.55 4.24"/><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.29 7 12 12 20.71 7"/><line x1="12" x2="12" y1="22" y2="12"/>',
|
||||
};
|
||||
|
||||
function dossierSvg(paths: string, color: string, bearingDeg?: number, size = 13): string {
|
||||
function dossierSvg(paths: string, color: string, bearingDeg?: number): string {
|
||||
const rotate = bearingDeg != null ? ` transform="rotate(${bearingDeg} 12 12)"` : "";
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="${color}" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"${rotate}>${paths}</svg>`;
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="${color}" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"${rotate}>${paths}</svg>`;
|
||||
}
|
||||
|
||||
const nodeIcon = L.divIcon({
|
||||
|
|
@ -288,41 +248,23 @@ function shipmentIcon(bearing: number): L.DivIcon {
|
|||
|
||||
const structureIconCache = new Map<string, L.DivIcon>();
|
||||
|
||||
interface StructureRibbon {
|
||||
tier: StructurePinTier;
|
||||
name: string;
|
||||
meta: string;
|
||||
}
|
||||
|
||||
function getStructureIcon(
|
||||
statusKey: string,
|
||||
progressPct: number | null,
|
||||
ribbon: StructureRibbon | null,
|
||||
): L.DivIcon {
|
||||
const tier = ribbon?.tier ?? "sm";
|
||||
const pinSize = STRUCTURE_PIN_SIZES[tier];
|
||||
const cacheKey = `${statusKey}:${progressPct ?? "full"}:${tier}:${ribbon ? `${ribbon.name}|${ribbon.meta}` : ""}`;
|
||||
function getStructureIcon(statusKey: string, progressPct: number | null): L.DivIcon {
|
||||
const cacheKey = `${statusKey}:${progressPct ?? "full"}`;
|
||||
let icon = structureIconCache.get(cacheKey);
|
||||
if (!icon) {
|
||||
if (structureIconCache.size > 400) structureIconCache.clear();
|
||||
const meta = STRUCTURE_STATUS[statusKey] ?? STRUCTURE_STATUS.complete;
|
||||
const bar =
|
||||
progressPct != null
|
||||
? `<div class="map-structure-progress"><div style="width:${progressPct}%"></div></div>`
|
||||
: "";
|
||||
const ribbonHtml = ribbon
|
||||
? `<div class="map-structure-ribbon" style="border-left-color:${meta.color}"><div class="ribbon-name">${escapeHtml(ribbon.name)}</div><div class="ribbon-meta">${escapeHtml(ribbon.meta)}</div></div>`
|
||||
: "";
|
||||
icon = L.divIcon({
|
||||
className: "map-structure-icon",
|
||||
html: `<div class="map-structure-pin ${tier === "sm" ? "" : `map-pin-${tier}`}" style="border-color:${meta.color}">${dossierSvg(
|
||||
html: `<div class="map-structure-pin" style="border-color:${meta.color}">${dossierSvg(
|
||||
STRUCTURE_SVG_PATHS[statusKey] ?? STRUCTURE_SVG_PATHS.complete,
|
||||
meta.color,
|
||||
undefined,
|
||||
STRUCTURE_PIN_SVG_SIZES[tier],
|
||||
)}${bar}${ribbonHtml}</div>`,
|
||||
iconSize: [pinSize, pinSize],
|
||||
iconAnchor: [pinSize / 2, pinSize / 2],
|
||||
)}${bar}</div>`,
|
||||
iconSize: [26, 26],
|
||||
iconAnchor: [13, 13],
|
||||
});
|
||||
structureIconCache.set(cacheKey, icon);
|
||||
}
|
||||
|
|
@ -415,159 +357,6 @@ function ClickHandler({ onPick }: { onPick: (point: MapPoint) => void }) {
|
|||
return null;
|
||||
}
|
||||
|
||||
/** Rewrites the bottom-right attribution prefix to credit the unit and the game. */
|
||||
function MapAttribution({ prefix }: { prefix: string }) {
|
||||
const map = useMap();
|
||||
useEffect(() => {
|
||||
map.attributionControl.setPrefix(prefix);
|
||||
}, [map, prefix]);
|
||||
return null;
|
||||
}
|
||||
|
||||
const SNAP_RADIUS_PX = 12;
|
||||
|
||||
const snapPreviewIconCache = new Map<string, L.DivIcon>();
|
||||
|
||||
function snapPreviewIcon(kind: SnapKind): L.DivIcon {
|
||||
let icon = snapPreviewIconCache.get(kind);
|
||||
if (!icon) {
|
||||
icon = L.divIcon({
|
||||
className: "map-snap-icon",
|
||||
html: `<div class="map-snap-preview map-snap-${kind}"></div>`,
|
||||
iconSize: [0, 0],
|
||||
iconAnchor: [0, 0],
|
||||
});
|
||||
snapPreviewIconCache.set(kind, icon);
|
||||
}
|
||||
return icon;
|
||||
}
|
||||
|
||||
/**
|
||||
* Authoring click layer: while a road/zone/node draft is active, every map
|
||||
* click snaps to existing geometry (feature vertices, zone/road edges,
|
||||
* structure/node positions, already-placed draft vertices) before landing,
|
||||
* and a moving cursor shows where the next click would land.
|
||||
*/
|
||||
function AuthoringSnapLayer({
|
||||
targets,
|
||||
worldSize,
|
||||
onPick,
|
||||
}: {
|
||||
targets: SnapTarget[];
|
||||
worldSize: number;
|
||||
onPick: (point: MapPoint) => void;
|
||||
}) {
|
||||
const map = useMap();
|
||||
const [preview, setPreview] = useState<SnapMatch | null>(null);
|
||||
|
||||
const snapAt = (event: L.LeafletMouseEvent): SnapMatch | null =>
|
||||
findSnap(
|
||||
[event.latlng.lng, -event.latlng.lat],
|
||||
targets,
|
||||
SNAP_RADIUS_PX * metersPerPx(map.getZoom(), worldSize),
|
||||
);
|
||||
|
||||
useMapEvents({
|
||||
mousemove: (event) => setPreview(snapAt(event)),
|
||||
click: (event) => {
|
||||
const match = snapAt(event);
|
||||
if (match) {
|
||||
onPick(match.point);
|
||||
} else {
|
||||
onPick([Math.round(event.latlng.lng), Math.round(-event.latlng.lat)]);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
if (!preview) return null;
|
||||
return (
|
||||
<Marker
|
||||
position={toLatLng(preview.point)}
|
||||
interactive={false}
|
||||
icon={snapPreviewIcon(preview.kind)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const vertexHandleIcon = L.divIcon({
|
||||
className: "map-vertex-handle-icon",
|
||||
html: '<div class="map-vertex-handle"></div>',
|
||||
iconSize: [12, 12],
|
||||
iconAnchor: [6, 6],
|
||||
});
|
||||
|
||||
const midpointHandleIcon = L.divIcon({
|
||||
className: "map-midpoint-handle-icon",
|
||||
html: '<div class="map-midpoint-handle"></div>',
|
||||
iconSize: [8, 8],
|
||||
iconAnchor: [4, 4],
|
||||
});
|
||||
|
||||
/**
|
||||
* Draggable handles for the current draft's vertices: drag a square to move it
|
||||
* (drag-end snaps to existing geometry), right-click deletes it, clicking the
|
||||
* small hollow square on a segment inserts a vertex there.
|
||||
*/
|
||||
function DraftHandles({
|
||||
points,
|
||||
closed,
|
||||
baseTargets,
|
||||
worldSize,
|
||||
onUpdate,
|
||||
onInsert,
|
||||
onRemove,
|
||||
}: {
|
||||
points: MapPoint[];
|
||||
closed: boolean;
|
||||
baseTargets: SnapTarget[];
|
||||
worldSize: number;
|
||||
onUpdate: (index: number, point: MapPoint) => void;
|
||||
onInsert: (index: number, point: MapPoint) => void;
|
||||
onRemove: (index: number) => void;
|
||||
}) {
|
||||
const map = useMap();
|
||||
const midpoints = useMemo(() => segmentMidpoints(points, closed), [points, closed]);
|
||||
|
||||
const dragTargets = (index: number): SnapTarget[] => [
|
||||
...baseTargets,
|
||||
// Own vertices, minus the dragged one so a small drag cannot snap back.
|
||||
{ key: "draft", vertices: points.filter((_, i) => i !== index), closed: false, edges: false },
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
{points.map((point, index) => (
|
||||
<Marker
|
||||
key={`draft-vertex-${index}`}
|
||||
position={toLatLng(point)}
|
||||
draggable
|
||||
icon={vertexHandleIcon}
|
||||
zIndexOffset={1000}
|
||||
eventHandlers={{
|
||||
dragend: (event) => {
|
||||
const latlng = (event.target as L.Marker).getLatLng();
|
||||
const raw: MapPoint = [latlng.lng, -latlng.lat];
|
||||
const radius = SNAP_RADIUS_PX * metersPerPx(map.getZoom(), worldSize);
|
||||
const match = findSnap(raw, dragTargets(index), radius);
|
||||
onUpdate(index, match ? match.point : [Math.round(raw[0]), Math.round(raw[1])]);
|
||||
},
|
||||
contextmenu: () => onRemove(index),
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{midpoints.map((midpoint, index) => (
|
||||
<Marker
|
||||
key={`draft-midpoint-${index}-${midpoint.index}`}
|
||||
position={toLatLng(midpoint.point)}
|
||||
icon={midpointHandleIcon}
|
||||
zIndexOffset={900}
|
||||
eventHandlers={{ click: () => onInsert(midpoint.index, midpoint.point) }}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
const total = Math.max(0, Math.ceil(ms / 1000));
|
||||
const hours = Math.floor(total / 3600);
|
||||
|
|
@ -576,38 +365,6 @@ function formatDuration(ms: number): string {
|
|||
return `${hours}h ${String(minutes).padStart(2, "0")}m ${String(seconds).padStart(2, "0")}s`;
|
||||
}
|
||||
|
||||
/** Footer-cell countdown: fixed-width "04H 07M 12S". */
|
||||
function paddedCountdown(ms: number): string {
|
||||
const total = Math.max(0, Math.ceil(ms / 1000));
|
||||
const hours = Math.floor(total / 3600);
|
||||
const minutes = Math.floor((total % 3600) / 60);
|
||||
const seconds = total % 60;
|
||||
return `${String(hours).padStart(2, "0")}H ${String(minutes).padStart(2, "0")}M ${String(seconds).padStart(2, "0")}S`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Friendly completion target: today (or already due) shows the time only,
|
||||
* within a week the weekday, beyond that the calendar date.
|
||||
*/
|
||||
function friendlyCompletion(completesMs: number, nowMs: number): string {
|
||||
const target = new Date(completesMs);
|
||||
const time = target.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||
const startOfDay = (date: Date) =>
|
||||
new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime();
|
||||
const dayDiff = Math.round(
|
||||
(startOfDay(target) - startOfDay(new Date(nowMs))) / (24 * 60 * 60 * 1000),
|
||||
);
|
||||
if (dayDiff <= 0) return time;
|
||||
if (dayDiff < 7) return `${target.toLocaleDateString([], { weekday: "long" })}, ${time}`;
|
||||
return `${target.toLocaleDateString([], { month: "short", day: "numeric" })}, ${time}`;
|
||||
}
|
||||
|
||||
/** Ribbon ETA in whole minutes ("1H 05M") so the cached icon changes at most once per minute. */
|
||||
function bucketedEta(ms: number): string {
|
||||
const totalMinutes = Math.max(0, Math.floor(ms / 60000));
|
||||
return `${Math.floor(totalMinutes / 60)}H ${totalMinutes % 60}M`;
|
||||
}
|
||||
|
||||
function DossierStrip({ label, refLabel }: { label: string; refLabel: string }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-6 border-b border-white/15 bg-black/50 px-3 py-1">
|
||||
|
|
@ -676,9 +433,7 @@ function StructurePopup({
|
|||
>
|
||||
{status.label}
|
||||
{pct != null ? ` // ${pct}%` : ""}
|
||||
{building && Number.isFinite(completesMs)
|
||||
? ` // Est. completion by ${friendlyCompletion(completesMs, nowMs)}`
|
||||
: ""}
|
||||
{Number.isFinite(remaining) ? ` // T-${formatDuration(remaining)}` : ""}
|
||||
</div>
|
||||
{structure.constructionStatus === "awaiting_materials" &&
|
||||
structure.materials.length > 0 && (
|
||||
|
|
@ -695,8 +450,13 @@ function StructurePopup({
|
|||
{ label: "Grid", value: `${gridX}E / ${gridY}S` },
|
||||
building
|
||||
? {
|
||||
label: "ETA",
|
||||
value: Number.isFinite(remaining) ? paddedCountdown(remaining) : "pending",
|
||||
label: "Completes",
|
||||
value: Number.isFinite(completesMs)
|
||||
? new Date(completesMs).toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})
|
||||
: "pending",
|
||||
}
|
||||
: {
|
||||
label: "Storage",
|
||||
|
|
@ -809,99 +569,18 @@ const StructureLayer = memo(function StructureLayer({
|
|||
return winners;
|
||||
}, [dots, zoom, worldSize]);
|
||||
|
||||
// Ribbon allocation at close zoom: same priority order as labels, greedy
|
||||
// screen-space rectangles (pin + estimated ribbon width) so ribbons never
|
||||
// cover another structure's pin or ribbon. Structures that lose the
|
||||
// collision fall back to their hover tooltip.
|
||||
const ribbonIds = useMemo(() => {
|
||||
if (metersPerPx(zoom, worldSize) > STRUCTURE_RIBBON_M_PER_PX) return new Set<number>();
|
||||
const placed: { left: number; right: number; top: number; bottom: number }[] = [];
|
||||
const winners = new Set<number>();
|
||||
const ordered = [...dots].sort(
|
||||
(a, b) =>
|
||||
(b.structure.labelPriority ?? 0) - (a.structure.labelPriority ?? 0) ||
|
||||
b.structure.storageUsedKg - a.structure.storageUsedKg ||
|
||||
a.structure.id - b.structure.id,
|
||||
);
|
||||
for (const dot of ordered) {
|
||||
const point = map.latLngToLayerPoint(dot.position);
|
||||
const width = Math.min(220, Math.max(110, Math.max(dot.structure.name.length, 24) * 6.2));
|
||||
const left = point.x + RIBBON_PIN_HALF_PX + RIBBON_GAP_PX;
|
||||
const rect = {
|
||||
left,
|
||||
right: left + width,
|
||||
top: point.y - RIBBON_HEIGHT_PX / 2,
|
||||
bottom: point.y + RIBBON_HEIGHT_PX / 2,
|
||||
};
|
||||
const collides = placed.some(
|
||||
(other) =>
|
||||
rect.left < other.right &&
|
||||
rect.right > other.left &&
|
||||
rect.top < other.bottom &&
|
||||
rect.bottom > other.top,
|
||||
);
|
||||
if (collides) continue;
|
||||
placed.push(rect);
|
||||
winners.add(dot.structure.id);
|
||||
}
|
||||
return winners;
|
||||
}, [dots, zoom, worldSize, map]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{dots.map(({ structure, statusKey, position }) => {
|
||||
const pct = statusKey === "building" ? constructionPct(structure, nowMs) : null;
|
||||
const showLabel = labeledIds.has(structure.id);
|
||||
const mPerPx = metersPerPx(zoom, worldSize);
|
||||
const tier: StructurePinTier =
|
||||
mPerPx <= STRUCTURE_RIBBON_M_PER_PX
|
||||
? "lg"
|
||||
: mPerPx <= STRUCTURE_PIN_MID_M_PER_PX
|
||||
? "md"
|
||||
: "sm";
|
||||
let ribbonMeta: string;
|
||||
if (statusKey === "building") {
|
||||
const end = structure.constructionCompletesAt
|
||||
? new Date(structure.constructionCompletesAt).getTime()
|
||||
: NaN;
|
||||
ribbonMeta = Number.isFinite(end)
|
||||
? `ETA ${bucketedEta(Math.max(0, end - nowMs))}`
|
||||
: "Building";
|
||||
} else if (statusKey === "awaiting_materials") {
|
||||
const first = structure.materials[0];
|
||||
ribbonMeta = first
|
||||
? `Needs ${first.resourceName} x${first.amount ?? 0}${structure.materials.length > 1 ? ` +${structure.materials.length - 1}` : ""}`
|
||||
: "Awaiting materials";
|
||||
} else {
|
||||
ribbonMeta = structure.blueprintName ?? "Structure";
|
||||
}
|
||||
const ribbon: StructureRibbon | null =
|
||||
tier === "lg" && ribbonIds.has(structure.id)
|
||||
? {
|
||||
tier,
|
||||
name: structure.name ?? `Structure ${structure.id}`,
|
||||
meta: ribbonMeta,
|
||||
}
|
||||
: null;
|
||||
return (
|
||||
<Marker
|
||||
key={`structure-${structure.id}`}
|
||||
position={position}
|
||||
icon={getStructureIcon(statusKey, pct, ribbon)}
|
||||
icon={getStructureIcon(statusKey, pct)}
|
||||
>
|
||||
{tier === "lg" ? (
|
||||
ribbon ? null : (
|
||||
<Tooltip
|
||||
direction="top"
|
||||
offset={[0, -16]}
|
||||
opacity={1}
|
||||
className="map-tooltip-dossier"
|
||||
>
|
||||
<div className="map-tooltip-name">{structure.name}</div>
|
||||
<div className="map-tooltip-meta">{ribbonMeta}</div>
|
||||
</Tooltip>
|
||||
)
|
||||
) : showLabel ? (
|
||||
{showLabel ? (
|
||||
<Tooltip
|
||||
permanent
|
||||
direction="right"
|
||||
|
|
@ -918,8 +597,7 @@ const StructureLayer = memo(function StructureLayer({
|
|||
opacity={1}
|
||||
className="map-tooltip-dossier"
|
||||
>
|
||||
<div className="map-tooltip-name">{structure.name}</div>
|
||||
<div className="map-tooltip-meta">{ribbonMeta}</div>
|
||||
{structure.name}
|
||||
</Tooltip>
|
||||
)}
|
||||
<Popup className="map-popup-dossier">
|
||||
|
|
@ -934,16 +612,8 @@ const StructureLayer = memo(function StructureLayer({
|
|||
|
||||
const RoadLabelsLayer = memo(function RoadLabelsLayer({
|
||||
roads,
|
||||
worldSize,
|
||||
}: {
|
||||
roads: {
|
||||
id: number;
|
||||
name: string;
|
||||
labelVisible: boolean | null;
|
||||
surface: "paved" | "dirt" | "trail";
|
||||
points: MapPoint[];
|
||||
}[];
|
||||
worldSize: number;
|
||||
roads: { id: number; name: string; labelVisible: boolean | null; points: MapPoint[] }[];
|
||||
}) {
|
||||
const map = useMap();
|
||||
const [zoom, setZoom] = useState(() => map.getZoom());
|
||||
|
|
@ -953,9 +623,8 @@ const RoadLabelsLayer = memo(function RoadLabelsLayer({
|
|||
});
|
||||
|
||||
const labeledRoads = useMemo(() => {
|
||||
const scale = metersPerPx(zoom, worldSize);
|
||||
return roads
|
||||
.filter((road) => road.name && roadLabelShown(road, scale))
|
||||
.filter((road) => road.name && road.labelVisible !== false)
|
||||
.flatMap((road) => {
|
||||
const anchor = map.latLngToLayerPoint(toLatLng(road.points[0]));
|
||||
const pxPoints = road.points.map((point) => {
|
||||
|
|
@ -965,25 +634,6 @@ const RoadLabelsLayer = memo(function RoadLabelsLayer({
|
|||
const screenLength = polylineLength(pxPoints);
|
||||
if (screenLength < ROAD_LABEL_MIN_SCREEN_PX) return [];
|
||||
|
||||
// Distant roads: a single straight label at the midpoint (following a
|
||||
// tiny wiggly path folds the letters together). Close up: text follows
|
||||
// the bends, character-aligned.
|
||||
if (screenLength < ROAD_LABEL_CURVE_MIN_PX) {
|
||||
const mid = interpolateAlong(road.points, 0.5);
|
||||
if (!mid) return [];
|
||||
return [
|
||||
{
|
||||
id: road.id,
|
||||
name: road.name,
|
||||
position: toLatLng(mid.point),
|
||||
icon: straightLabelIcon(
|
||||
road.name,
|
||||
labelScreenAngle(road.points[0], road.points[road.points.length - 1]),
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const halfText = (road.name.length * ROAD_LABEL_CHAR_PX) / 2;
|
||||
// On very long roads a fixed interval would exhaust the label cap part
|
||||
// way along the path; spread the capped labels over the whole road
|
||||
|
|
@ -1009,7 +659,7 @@ const RoadLabelsLayer = memo(function RoadLabelsLayer({
|
|||
},
|
||||
];
|
||||
});
|
||||
}, [map, roads, worldSize, zoom]);
|
||||
}, [map, roads, zoom]);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -1195,11 +845,11 @@ export function MapClient({ maps, canEdit, canPlace }: MapClientProps) {
|
|||
const zonePolys = useMemo(
|
||||
() =>
|
||||
(features?.zones ?? []).map((zone) => {
|
||||
const defaults = ZONE_PATH_OPTIONS[zone.type];
|
||||
const fallbackColor = zone.type === "water" ? "#38bdf8" : "#4ade80";
|
||||
const options: L.PathOptions = {
|
||||
color: zone.strokeColor || defaults.color,
|
||||
fillColor: zone.fillColor || defaults.fillColor,
|
||||
fillOpacity: zone.fillOpacity ?? defaults.fillOpacity,
|
||||
color: zone.strokeColor || fallbackColor,
|
||||
fillColor: zone.fillColor || fallbackColor,
|
||||
fillOpacity: zone.fillOpacity ?? (zone.type === "water" ? 0.25 : 0.08),
|
||||
weight: zone.strokeWeight ?? 1,
|
||||
};
|
||||
return { zone, positions: zone.points.map(toLatLng), options };
|
||||
|
|
@ -1253,37 +903,6 @@ export function MapClient({ maps, canEdit, canPlace }: MapClientProps) {
|
|||
[worldW, worldH],
|
||||
);
|
||||
|
||||
const maxWorld = Math.max(worldW, worldH);
|
||||
const snapTargets = useMemo<SnapTarget[]>(() => {
|
||||
if (mode !== "edit" || authoring.kind == null) return [];
|
||||
return [
|
||||
...(features?.roads ?? []).map((road) => ({
|
||||
key: `road-${road.id}`,
|
||||
vertices: road.points,
|
||||
closed: false,
|
||||
edges: true,
|
||||
})),
|
||||
...(features?.zones ?? []).map((zone) => ({
|
||||
key: `zone-${zone.id}`,
|
||||
vertices: zone.points,
|
||||
closed: true,
|
||||
edges: true,
|
||||
})),
|
||||
...(features?.nodes ?? []).map((node) => ({
|
||||
key: `node-${node.id}`,
|
||||
vertices: [node.point],
|
||||
closed: false,
|
||||
edges: false,
|
||||
})),
|
||||
...(features?.structures ?? []).map((structure) => ({
|
||||
key: `structure-${structure.id}`,
|
||||
vertices: [structure.point],
|
||||
closed: false,
|
||||
edges: false,
|
||||
})),
|
||||
];
|
||||
}, [mode, authoring.kind, features]);
|
||||
|
||||
if (maps.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center p-6">
|
||||
|
|
@ -1470,12 +1089,10 @@ export function MapClient({ maps, canEdit, canPlace }: MapClientProps) {
|
|||
|
||||
{features && toggles.roads && (
|
||||
<RoadLabelsLayer
|
||||
worldSize={Math.max(worldW, worldH)}
|
||||
roads={features.roads.map((road) => ({
|
||||
id: road.id,
|
||||
name: road.name ?? "",
|
||||
labelVisible: road.labelVisible,
|
||||
surface: road.surface,
|
||||
points: road.points,
|
||||
}))}
|
||||
/>
|
||||
|
|
@ -1585,31 +1202,12 @@ export function MapClient({ maps, canEdit, canPlace }: MapClientProps) {
|
|||
)}
|
||||
|
||||
<MousePosition />
|
||||
<MapAttribution prefix="Polaris Task Force // Arma 3 // Leaflet" />
|
||||
<FitToViewport worldW={worldW} worldH={worldH} minZoomFloor={mapConfig?.minZoom ?? 0} />
|
||||
{mapConfig && (
|
||||
<DefaultView key={mapConfig.id} center={center} zoom={mapConfig.defaultZoom ?? 2} />
|
||||
)}
|
||||
{mode === "place" && <ClickHandler onPick={setPendingPoint} />}
|
||||
{mode === "edit" && authoring.kind != null && (
|
||||
<AuthoringSnapLayer
|
||||
targets={snapTargets}
|
||||
worldSize={maxWorld}
|
||||
onPick={authoring.pushPoint}
|
||||
/>
|
||||
)}
|
||||
|
||||
{mode === "edit" && authoring.kind != null && authoring.draftPoints.length > 0 && (
|
||||
<DraftHandles
|
||||
points={authoring.draftPoints}
|
||||
closed={authoring.kind === "zone"}
|
||||
baseTargets={snapTargets}
|
||||
worldSize={maxWorld}
|
||||
onUpdate={authoring.updateDraftPoint}
|
||||
onInsert={authoring.insertDraftPoint}
|
||||
onRemove={authoring.removeDraftPoint}
|
||||
/>
|
||||
)}
|
||||
{mode === "edit" && authoring.kind != null && <ClickHandler onPick={authoring.pushPoint} />}
|
||||
|
||||
{mode === "edit" && authoring.kind === "road" && authoring.draftPoints.length > 0 && (
|
||||
<Polyline
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@
|
|||
import { useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Combobox } from "@/components/ui/combobox";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
|
|
@ -14,13 +13,15 @@ import {
|
|||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { placeStructure } from "@/app/(frontend)/logistics/map/actions";
|
||||
import {
|
||||
validatePlacement,
|
||||
type NodeInput,
|
||||
type StructureInput,
|
||||
type ZoneInput,
|
||||
} from "@/lib/map/placement";
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { placeStructure } from "@/app/(frontend)/logistics/map/actions";
|
||||
import { validatePlacement, type NodeInput, type StructureInput, type ZoneInput } from "@/lib/map/placement";
|
||||
import type { MapPoint } from "@/lib/map/points";
|
||||
import type { MapConfig, NodeFeature, StructureFeature, ZoneFeature } from "@/lib/map/types";
|
||||
|
||||
|
|
@ -85,18 +86,9 @@ export function PlacementDialog({
|
|||
terrainType?: string | null;
|
||||
constructionDurationMinutes?: number | null;
|
||||
requiredTech?: { name?: string }[] | number[];
|
||||
materials?: {
|
||||
resource?: { id: number; name?: string } | number;
|
||||
amount?: number | null;
|
||||
}[];
|
||||
resourcesInRange?: {
|
||||
resource?: { id: number } | number | null;
|
||||
range?: number | null;
|
||||
}[];
|
||||
structuresInRange?: {
|
||||
structure?: { id: number } | number | null;
|
||||
range?: number | null;
|
||||
}[];
|
||||
materials?: { resource?: { id: number; name?: string } | number; amount?: number | null }[];
|
||||
resourcesInRange?: { resource?: { id: number } | number | null; range?: number | null }[];
|
||||
structuresInRange?: { structure?: { id: number } | number | null; range?: number | null }[];
|
||||
}[];
|
||||
};
|
||||
setBlueprints(
|
||||
|
|
@ -106,9 +98,7 @@ export function PlacementDialog({
|
|||
terrainType: (doc.terrainType as "land" | "water" | null | undefined) ?? "land",
|
||||
constructionDurationMinutes: doc.constructionDurationMinutes ?? null,
|
||||
requiredTech: (doc.requiredTech ?? [])
|
||||
.filter(
|
||||
(tech): tech is { name?: string } => typeof tech === "object" && tech !== null,
|
||||
)
|
||||
.filter((tech): tech is { name?: string } => typeof tech === "object" && tech !== null)
|
||||
.map((tech) => tech.name ?? "Unknown"),
|
||||
materials: (doc.materials ?? []).map((material) => ({
|
||||
resource:
|
||||
|
|
@ -245,18 +235,18 @@ export function PlacementDialog({
|
|||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="place-blueprint">Blueprint</Label>
|
||||
<Combobox
|
||||
id="place-blueprint"
|
||||
value={blueprintId}
|
||||
onValueChange={setBlueprintId}
|
||||
placeholder="Select blueprint"
|
||||
searchPlaceholder="Search blueprints..."
|
||||
emptyMessage="No blueprints available."
|
||||
options={blueprints.map((option) => ({
|
||||
value: String(option.id),
|
||||
label: option.name,
|
||||
}))}
|
||||
/>
|
||||
<Select value={blueprintId} onValueChange={setBlueprintId}>
|
||||
<SelectTrigger id="place-blueprint" className="w-full">
|
||||
<SelectValue placeholder="Select blueprint" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{blueprints.map((option) => (
|
||||
<SelectItem key={option.id} value={String(option.id)}>
|
||||
{option.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
|
|
@ -271,20 +261,19 @@ export function PlacementDialog({
|
|||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="place-faction">Faction (optional)</Label>
|
||||
<Combobox
|
||||
id="place-faction"
|
||||
value={factionId}
|
||||
onValueChange={setFactionId}
|
||||
searchPlaceholder="Search factions..."
|
||||
emptyMessage="No factions available."
|
||||
options={[
|
||||
{ value: "none", label: "No faction" },
|
||||
...factions.map((faction) => ({
|
||||
value: String(faction.id),
|
||||
label: faction.name,
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
<Select value={factionId} onValueChange={setFactionId}>
|
||||
<SelectTrigger id="place-faction" className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">No faction</SelectItem>
|
||||
{factions.map((faction) => (
|
||||
<SelectItem key={faction.id} value={String(faction.id)}>
|
||||
{faction.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{blueprint && (
|
||||
|
|
|
|||
|
|
@ -5,11 +5,17 @@ import { useRouter } from "next/navigation";
|
|||
import type { Asset, LockerStorage } from "@/payload-types";
|
||||
import { PlusIcon } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Combobox } from "@/components/ui/combobox";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
|
|
@ -190,21 +196,28 @@ export function CreateListingDialog({
|
|||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>Item</Label>
|
||||
<Combobox
|
||||
<Select
|
||||
value={assetId}
|
||||
onValueChange={(v) => {
|
||||
setAssetId(v);
|
||||
setQuantity("1");
|
||||
}}
|
||||
placeholder="Select an item from your locker"
|
||||
searchPlaceholder="Search items..."
|
||||
emptyMessage="Nothing is approved for trading yet."
|
||||
options={sellable.map((s) => ({
|
||||
value: String(s.assetId),
|
||||
label: `${s.name} (${s.quantity.toLocaleString()}×)`,
|
||||
disabled: !s.tradeable || !s.isLive,
|
||||
}))}
|
||||
/>
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select an item from your locker" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{sellable.map((s) => (
|
||||
<SelectItem
|
||||
key={s.assetId}
|
||||
value={String(s.assetId)}
|
||||
disabled={!s.tradeable || !s.isLive}
|
||||
>
|
||||
{s.name} ({s.quantity.toLocaleString()}×)
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{sellable.length === 0 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{hasLockerItems
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ import {
|
|||
setPokerHoleQueue,
|
||||
} from "@/app/(frontend)/minigames/poker/actions";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Combobox } from "@/components/ui/combobox";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
|
|
@ -66,7 +65,11 @@ function cardLabel(card: Card): string {
|
|||
* what lets a single pick land; the previous null-propagating version made
|
||||
* first picks no-ops and hole-card queuing impossible.
|
||||
*/
|
||||
export function applyCardPick(value: Card | null, part: "rank" | "suit", next: string): Card {
|
||||
export function applyCardPick(
|
||||
value: Card | null,
|
||||
part: "rank" | "suit",
|
||||
next: string,
|
||||
): Card {
|
||||
if (part === "rank") {
|
||||
return { rank: Number(next) as Rank, suit: value?.suit ?? "s" };
|
||||
}
|
||||
|
|
@ -88,17 +91,18 @@ function CardPicker({
|
|||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="w-10 text-xs text-muted-foreground">{label}</span>
|
||||
<Combobox
|
||||
className="h-8 w-20 text-xs"
|
||||
value={value ? String(value.rank) : ""}
|
||||
onValueChange={setRank}
|
||||
placeholder="Rank"
|
||||
searchPlaceholder="Rank..."
|
||||
options={RANK_OPTIONS.map((option) => ({
|
||||
value: String(option.value),
|
||||
label: option.label,
|
||||
}))}
|
||||
/>
|
||||
<Select value={value ? String(value.rank) : ""} onValueChange={setRank}>
|
||||
<SelectTrigger className="h-8 w-20 text-xs">
|
||||
<SelectValue placeholder="Rank" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{RANK_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={String(option.value)}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={value?.suit ?? ""} onValueChange={setSuit}>
|
||||
<SelectTrigger className="h-8 w-24 text-xs">
|
||||
<SelectValue placeholder="Suit" />
|
||||
|
|
@ -254,19 +258,20 @@ export function PokerDevPanel({
|
|||
|
||||
<section className="flex flex-col gap-2 border border-border p-3">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider">Next hand hole cards</h3>
|
||||
<Combobox
|
||||
className="h-8 text-xs"
|
||||
value={seatId}
|
||||
onValueChange={setSeatId}
|
||||
placeholder="Player"
|
||||
searchPlaceholder="Search players..."
|
||||
options={view.players
|
||||
.filter((player) => !player.eliminated)
|
||||
.map((player) => ({
|
||||
value: String(player.seatId),
|
||||
label: player.name,
|
||||
}))}
|
||||
/>
|
||||
<Select value={seatId} onValueChange={setSeatId}>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="Player" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{view.players
|
||||
.filter((player) => !player.eliminated)
|
||||
.map((player) => (
|
||||
<SelectItem key={player.seatId} value={String(player.seatId)}>
|
||||
{player.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<CardPicker label="Card 1" value={holeA} onChange={setHoleA} />
|
||||
<CardPicker label="Card 2" value={holeB} onChange={setHoleB} />
|
||||
|
|
@ -284,19 +289,20 @@ export function PokerDevPanel({
|
|||
|
||||
<section className="flex flex-col gap-2 border border-border p-3">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider">Stack adjustment</h3>
|
||||
<Combobox
|
||||
className="h-8 text-xs"
|
||||
value={adjustSeat}
|
||||
onValueChange={setAdjustSeat}
|
||||
placeholder="Player"
|
||||
searchPlaceholder="Search players..."
|
||||
options={view.players
|
||||
.filter((player) => !player.eliminated)
|
||||
.map((player) => ({
|
||||
value: String(player.seatId),
|
||||
label: `${player.name} (${player.stack.toLocaleString()})`,
|
||||
}))}
|
||||
/>
|
||||
<Select value={adjustSeat} onValueChange={setAdjustSeat}>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="Player" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{view.players
|
||||
.filter((player) => !player.eliminated)
|
||||
.map((player) => (
|
||||
<SelectItem key={player.seatId} value={String(player.seatId)}>
|
||||
{player.name} ({player.stack.toLocaleString()})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ import {
|
|||
} from "@/lib/evaluations/levels";
|
||||
import { submitEvaluation } from "@/app/(frontend)/profile/[username]/actions";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Combobox } from "@/components/ui/combobox";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
|
|
@ -135,20 +134,20 @@ export function LeadershipEvaluationDialog({
|
|||
<form className="flex flex-col gap-4" onSubmit={handleSubmit} aria-busy={pending}>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="evaluation-mission">Mission</Label>
|
||||
<Combobox
|
||||
id="evaluation-mission"
|
||||
aria-invalid={error !== null}
|
||||
value={missionId}
|
||||
onValueChange={setMissionId}
|
||||
disabled={pending}
|
||||
placeholder="Select a completed mission"
|
||||
searchPlaceholder="Search missions..."
|
||||
emptyMessage="No completed missions to evaluate."
|
||||
options={missions.map((mission) => ({
|
||||
value: String(mission.id),
|
||||
label: `${mission.name} · ${formatMissionDate(mission.startDateTime)}`,
|
||||
}))}
|
||||
/>
|
||||
<Select value={missionId} onValueChange={setMissionId} disabled={pending}>
|
||||
<SelectTrigger id="evaluation-mission" aria-invalid={error !== null}>
|
||||
<SelectValue placeholder="Select a completed mission" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{missions.map((mission) => (
|
||||
<SelectItem key={mission.id} value={String(mission.id)}>
|
||||
{mission.name} · {formatMissionDate(mission.startDateTime)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@
|
|||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Combobox } from "@/components/ui/combobox";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
|
|
@ -12,6 +11,13 @@ import {
|
|||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { promoteMember } from "@/app/(frontend)/profile/[username]/actions";
|
||||
import { ArrowUpIcon, Loader2Icon, MedalIcon } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
|
@ -91,16 +97,21 @@ export function PromoteButton({ username, currentRankName, targetRanks }: Promot
|
|||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Combobox
|
||||
<Select
|
||||
value={selectedRankId != null ? String(selectedRankId) : undefined}
|
||||
onValueChange={(v) => setRankId(Number(v))}
|
||||
placeholder="Select a rank"
|
||||
searchPlaceholder="Search ranks..."
|
||||
options={targetRanks.map((r) => ({
|
||||
value: String(r.id),
|
||||
label: r.name,
|
||||
}))}
|
||||
/>
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a rank" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{targetRanks.map((r) => (
|
||||
<SelectItem key={r.id} value={String(r.id)}>
|
||||
{r.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
|
|
@ -116,4 +127,4 @@ export function PromoteButton({ username, currentRankName, targetRanks }: Promot
|
|||
</Dialog>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,11 +1,8 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { TagsIcon } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Combobox } from "@/components/ui/combobox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
|
|
@ -14,6 +11,15 @@ import {
|
|||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import type { KanbanFiltersProps, SprintFilter } from "./KanbanBoard";
|
||||
|
||||
function sprintFilterValue(value: string): SprintFilter {
|
||||
|
|
@ -23,9 +29,7 @@ function sprintFilterValue(value: string): SprintFilter {
|
|||
}
|
||||
|
||||
function activeSprints(sprints: KanbanFiltersProps["sprints"]) {
|
||||
const active = sprints.filter((sprint) =>
|
||||
["planned", "active", "in_progress"].includes(sprint.status),
|
||||
);
|
||||
const active = sprints.filter((sprint) => ["planned", "active", "in_progress"].includes(sprint.status));
|
||||
return active.length > 0 ? active : sprints;
|
||||
}
|
||||
|
||||
|
|
@ -39,12 +43,6 @@ export function KanbanFilters({
|
|||
}: KanbanFiltersProps) {
|
||||
const selectedLabelCount = selectedLabelIds.length;
|
||||
const sprintOptions = activeSprints(sprints);
|
||||
const [labelQuery, setLabelQuery] = useState("");
|
||||
const [labelsOpen, setLabelsOpen] = useState(false);
|
||||
const labelNeedle = labelQuery.trim().toLowerCase();
|
||||
const visibleLabels = labelNeedle
|
||||
? labels.filter((label) => label.name.toLowerCase().includes(labelNeedle))
|
||||
: labels;
|
||||
const sprintLabel =
|
||||
sprintFilter === "all"
|
||||
? "All sprints"
|
||||
|
|
@ -52,23 +50,27 @@ export function KanbanFilters({
|
|||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Combobox
|
||||
<Select
|
||||
value={String(sprintFilter)}
|
||||
onValueChange={(value) => onSprintFilterChange(sprintFilterValue(value))}
|
||||
className="w-[190px]"
|
||||
aria-label="Filter by sprint"
|
||||
placeholder={sprintLabel ?? "Select sprint"}
|
||||
searchPlaceholder="Search sprints..."
|
||||
options={[
|
||||
{ value: "all", label: "All sprints" },
|
||||
...sprintOptions.map((sprint) => ({
|
||||
value: String(sprint.id),
|
||||
label: sprint.name,
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
>
|
||||
<SelectTrigger className="w-[190px]" aria-label="Filter by sprint">
|
||||
<SelectValue placeholder={sprintLabel ?? "Select sprint"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectLabel>Sprint</SelectLabel>
|
||||
<SelectItem value="all">All sprints</SelectItem>
|
||||
{sprintOptions.map((sprint) => (
|
||||
<SelectItem key={sprint.id} value={String(sprint.id)}>
|
||||
{sprint.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<DropdownMenu open={labelsOpen} onOpenChange={setLabelsOpen}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" aria-label="Filter by label">
|
||||
<TagsIcon data-icon="inline-start" />
|
||||
|
|
@ -79,22 +81,10 @@ export function KanbanFilters({
|
|||
<DropdownMenuContent align="start" className="w-56">
|
||||
<DropdownMenuLabel>Filter by label</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<div className="px-2 pb-1.5">
|
||||
<Input
|
||||
value={labelQuery}
|
||||
onChange={(event) => setLabelQuery(event.target.value)}
|
||||
placeholder="Search labels..."
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
{labels.length === 0 ? (
|
||||
<span className="block px-2 py-1.5 text-sm text-muted-foreground">No labels yet</span>
|
||||
) : visibleLabels.length === 0 ? (
|
||||
<span className="block px-2 py-1.5 text-sm text-muted-foreground">
|
||||
No matching labels
|
||||
</span>
|
||||
) : (
|
||||
visibleLabels.map((label) => (
|
||||
labels.map((label) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={label.id}
|
||||
checked={selectedLabelIds.includes(label.id)}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ import {
|
|||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
interface LogEntry {
|
||||
id: number;
|
||||
|
|
@ -38,8 +37,6 @@ export function EventLedger({
|
|||
const [loading, setLoading] = useState(true);
|
||||
const [selectedTypes, setSelectedTypes] = useState<Set<string>>(new Set());
|
||||
const [currentUserId, setCurrentUserId] = useState<number | null>(null);
|
||||
const [typeQuery, setTypeQuery] = useState("");
|
||||
const [filterMenuOpen, setFilterMenuOpen] = useState(false);
|
||||
const initialDone = useRef(false);
|
||||
|
||||
const eventTypes = useMemo(() => {
|
||||
|
|
@ -47,11 +44,6 @@ export function EventLedger({
|
|||
return Array.from(set).sort();
|
||||
}, [entries]);
|
||||
|
||||
const typeNeedle = typeQuery.trim().toLowerCase();
|
||||
const visibleTypes = typeNeedle
|
||||
? eventTypes.filter((t) => formatType(t).toLowerCase().includes(typeNeedle))
|
||||
: eventTypes;
|
||||
|
||||
const filtered = useMemo(
|
||||
() => (selectedTypes.size === 0 ? entries : entries.filter((e) => selectedTypes.has(e.type))),
|
||||
[entries, selectedTypes],
|
||||
|
|
@ -146,13 +138,7 @@ export function EventLedger({
|
|||
</ItemHeader>
|
||||
<ItemContent>
|
||||
{eventTypes.length > 1 && (
|
||||
<DropdownMenu
|
||||
open={filterMenuOpen}
|
||||
onOpenChange={(next) => {
|
||||
setFilterMenuOpen(next);
|
||||
if (!next) setTypeQuery("");
|
||||
}}
|
||||
>
|
||||
<DropdownMenu>
|
||||
<span className="text-xs text-muted-foreground">Filter event logs...</span>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
|
|
@ -162,39 +148,22 @@ export function EventLedger({
|
|||
{dropdownLabel}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-60">
|
||||
<div className="p-1.5">
|
||||
<Input
|
||||
value={typeQuery}
|
||||
onChange={(event) => setTypeQuery(event.target.value)}
|
||||
placeholder="Search event types..."
|
||||
className="h-7 rounded-none text-xs"
|
||||
/>
|
||||
</div>
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={selectedTypes.size === 0}
|
||||
onCheckedChange={() => setSelectedTypes(new Set())}
|
||||
onSelect={(event) => event.preventDefault()}
|
||||
>
|
||||
All
|
||||
</DropdownMenuCheckboxItem>
|
||||
<div className="max-h-64 overflow-y-auto">
|
||||
{visibleTypes.map((t) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={t}
|
||||
checked={selectedTypes.has(t)}
|
||||
onCheckedChange={() => toggleType(t)}
|
||||
onSelect={(event) => event.preventDefault()}
|
||||
>
|
||||
{formatType(t)}
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
{visibleTypes.length === 0 && (
|
||||
<div className="px-2 py-3 text-center text-xs text-muted-foreground">
|
||||
No matches
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{eventTypes.map((t) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={t}
|
||||
checked={selectedTypes.has(t)}
|
||||
onCheckedChange={() => toggleType(t)}
|
||||
>
|
||||
{formatType(t)}
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ import {
|
|||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Combobox } from "@/components/ui/combobox";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
|
|
@ -509,20 +508,21 @@ export function ManageStorageDialog({
|
|||
<div className="flex flex-col gap-2">
|
||||
<Label>Transport Vehicle</Label>
|
||||
{availableVehicles.length > 0 ? (
|
||||
<Combobox
|
||||
value={shipmentVehicleId}
|
||||
onValueChange={setShipmentVehicleId}
|
||||
placeholder="Select a vehicle..."
|
||||
searchPlaceholder="Search vehicles..."
|
||||
emptyMessage="No vehicles with range."
|
||||
options={availableVehicles.map((v) => {
|
||||
const vType = typeof v.type === "object" ? v.type : null;
|
||||
return {
|
||||
value: String(v.id),
|
||||
label: `${v.name} (${vType?.name ?? "Unknown"}), ${v.currentFuel?.toFixed(0)} fuel`,
|
||||
};
|
||||
})}
|
||||
/>
|
||||
<Select value={shipmentVehicleId} onValueChange={setShipmentVehicleId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a vehicle..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableVehicles.map((v) => {
|
||||
const vType = typeof v.type === "object" ? v.type : null;
|
||||
return (
|
||||
<SelectItem key={v.id} value={String(v.id)}>
|
||||
{v.name} ({vType?.name ?? "Unknown"}), {v.currentFuel?.toFixed(0)} fuel
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 p-3 rounded-md border border-amber-500/30 bg-amber-500/5 text-sm text-amber-400">
|
||||
<TruckIcon className="size-4 shrink-0" />
|
||||
|
|
@ -684,7 +684,7 @@ export function ManageStorageDialog({
|
|||
<TabsContent value="deposit" className="flex flex-col gap-4 pt-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Resource</Label>
|
||||
<Combobox
|
||||
<Select
|
||||
value={depositResourceId}
|
||||
onValueChange={(val) => {
|
||||
setDepositResourceId(val);
|
||||
|
|
@ -692,20 +692,29 @@ export function ManageStorageDialog({
|
|||
clampAmount(depositAmount, getDepositLimitInfoFor(val)?.max),
|
||||
);
|
||||
}}
|
||||
placeholder="Select a resource..."
|
||||
searchPlaceholder="Search resources..."
|
||||
emptyMessage="Nothing may be deposited here."
|
||||
options={allResources
|
||||
.filter((r) => isResourceAllowed(r.id))
|
||||
.map((resource) => {
|
||||
const cap = getAllowedDepositCap(resource.id);
|
||||
return {
|
||||
value: String(resource.id),
|
||||
label: resource.name,
|
||||
hint: cap !== undefined ? `${cap.toLocaleString()} max` : undefined,
|
||||
};
|
||||
})}
|
||||
/>
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a resource..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{allResources
|
||||
.filter((r) => isResourceAllowed(r.id))
|
||||
.map((resource) => {
|
||||
const cap = getAllowedDepositCap(resource.id);
|
||||
return (
|
||||
<SelectItem key={resource.id} value={String(resource.id)}>
|
||||
{resource.name}
|
||||
{cap !== undefined && (
|
||||
<span className="text-muted-foreground">
|
||||
{" "}
|
||||
({cap.toLocaleString()} max)
|
||||
</span>
|
||||
)}
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Amount</Label>
|
||||
|
|
@ -745,27 +754,30 @@ export function ManageStorageDialog({
|
|||
<TabsContent value="withdraw" className="flex flex-col gap-4 pt-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Resource</Label>
|
||||
<Combobox
|
||||
<Select
|
||||
value={withdrawResourceId}
|
||||
onValueChange={(val) => {
|
||||
setWithdrawResourceId(val);
|
||||
const stored = findStoredResource(val);
|
||||
setWithdrawAmount(clampAmount(withdrawAmount, stored?.amount));
|
||||
}}
|
||||
placeholder="Select a resource..."
|
||||
searchPlaceholder="Search resources..."
|
||||
emptyMessage="Nothing stored yet."
|
||||
options={storedResources
|
||||
.map((entry) => {
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a resource..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{storedResources.map((entry) => {
|
||||
const resource = entry.resource as Resource;
|
||||
if (!resource || typeof resource === "number") return null;
|
||||
return {
|
||||
value: String(resource.id),
|
||||
label: `${resource.name} (${entry.amount.toLocaleString()} ${pluralize(entry.amount, resource.unitOfMeasure)})`,
|
||||
};
|
||||
})
|
||||
.filter((option) => option !== null)}
|
||||
/>
|
||||
return (
|
||||
<SelectItem key={entry.id ?? resource.id} value={String(resource.id)}>
|
||||
{resource.name} ({entry.amount.toLocaleString()}{" "}
|
||||
{pluralize(entry.amount, resource.unitOfMeasure)})
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Amount</Label>
|
||||
|
|
@ -805,43 +817,51 @@ export function ManageStorageDialog({
|
|||
<TabsContent value="transfer" className="flex flex-col gap-4 pt-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Destination</Label>
|
||||
<Combobox
|
||||
<Select
|
||||
value={transferToId}
|
||||
onValueChange={(val) => {
|
||||
setTransferToId(val);
|
||||
setTransferAmount(clampAmount(transferAmount, getTransferMaxFor(val)));
|
||||
}}
|
||||
placeholder="Select a structure..."
|
||||
searchPlaceholder="Search structures..."
|
||||
options={allStructures.map((s) => ({
|
||||
value: String(s.id),
|
||||
label: s.name,
|
||||
}))}
|
||||
/>
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a structure..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{allStructures.map((s) => (
|
||||
<SelectItem key={s.id} value={String(s.id)}>
|
||||
{s.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Resource</Label>
|
||||
<Combobox
|
||||
<Select
|
||||
value={transferResourceId}
|
||||
onValueChange={(val) => {
|
||||
setTransferResourceId(val);
|
||||
const stored = findStoredResource(val);
|
||||
setTransferAmount(clampAmount(transferAmount, stored?.amount));
|
||||
}}
|
||||
placeholder="Select a resource..."
|
||||
searchPlaceholder="Search resources..."
|
||||
emptyMessage="Nothing stored yet."
|
||||
options={storedResources
|
||||
.map((entry) => {
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a resource..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{storedResources.map((entry) => {
|
||||
const resource = entry.resource as Resource;
|
||||
if (!resource || typeof resource === "number") return null;
|
||||
return {
|
||||
value: String(resource.id),
|
||||
label: `${resource.name} (${entry.amount.toLocaleString()} ${pluralize(entry.amount, resource.unitOfMeasure)})`,
|
||||
};
|
||||
})
|
||||
.filter((option) => option !== null)}
|
||||
/>
|
||||
return (
|
||||
<SelectItem key={entry.id ?? resource.id} value={String(resource.id)}>
|
||||
{resource.name} ({entry.amount.toLocaleString()}{" "}
|
||||
{pluralize(entry.amount, resource.unitOfMeasure)})
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Amount</Label>
|
||||
|
|
|
|||
|
|
@ -1,122 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Check, ChevronDown } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
|
||||
export interface ComboboxOption {
|
||||
value: string;
|
||||
label: string;
|
||||
/** Optional secondary line rendered under the label. */
|
||||
hint?: string;
|
||||
/** Disabled options render muted and cannot be selected. */
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface ComboboxProps extends React.ComponentPropsWithoutRef<"button"> {
|
||||
value?: string;
|
||||
onValueChange: (value: string) => void;
|
||||
options: ComboboxOption[];
|
||||
placeholder?: string;
|
||||
searchPlaceholder?: string;
|
||||
emptyMessage?: string;
|
||||
/** Trigger sizing classes (the dropdown keeps the trigger's width). */
|
||||
className?: string;
|
||||
/** Popover content classes. */
|
||||
contentClassName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Searchable single-value dropdown (Popover + cmdk), a drop-in replacement for
|
||||
* ui/select wherever the item list is data-driven and can grow long. The
|
||||
* popover stays as wide as the trigger and the list scrolls at 16rem.
|
||||
*/
|
||||
function Combobox({
|
||||
value,
|
||||
onValueChange,
|
||||
options,
|
||||
placeholder = "Select...",
|
||||
searchPlaceholder = "Search...",
|
||||
emptyMessage = "No matches.",
|
||||
disabled = false,
|
||||
className,
|
||||
contentClassName,
|
||||
...rest
|
||||
}: ComboboxProps) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const selected = options.find((option) => option.value === value);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger
|
||||
disabled={disabled}
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className={cn(
|
||||
"flex h-9 w-full items-center justify-between gap-2 rounded-md border border-input bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs outline-none",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"aria-invalid:border-destructive aria-invalid:ring-destructive/20",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||
!selected && "text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
<span className="truncate">{selected ? selected.label : placeholder}</span>
|
||||
<ChevronDown className="size-4 shrink-0 opacity-50" aria-hidden />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className={cn("w-[var(--radix-popover-trigger-width)] p-0", contentClassName)}
|
||||
align="start"
|
||||
>
|
||||
<Command>
|
||||
<CommandInput placeholder={searchPlaceholder} />
|
||||
<CommandList className="max-h-64">
|
||||
<CommandEmpty>{emptyMessage}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{options.map((option) => (
|
||||
<CommandItem
|
||||
key={option.value}
|
||||
value={`${option.label} ${option.value}`}
|
||||
disabled={option.disabled}
|
||||
onSelect={() => {
|
||||
onValueChange(option.value);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-1 size-4",
|
||||
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 text-xs text-muted-foreground">
|
||||
{option.hint}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
export { Combobox };
|
||||
|
|
@ -16,7 +16,7 @@ export type BlueprintTerrain = "land" | "water";
|
|||
export interface ZoneInput {
|
||||
id: number;
|
||||
name?: string | null;
|
||||
type: "water" | "land" | "territory" | "border";
|
||||
type: "water" | "land";
|
||||
/** Resource ids contained in this zone; a site inside satisfies their requirements. */
|
||||
resources?: number[];
|
||||
points: MapPoint[];
|
||||
|
|
@ -112,7 +112,8 @@ export function checkResourcesInRange(
|
|||
node.resources.includes(resourceId) && distanceSquared(node.position, point) <= maxSq,
|
||||
);
|
||||
const zoneMatch = zones.some(
|
||||
(zone) => (zone.resources ?? []).includes(resourceId) && pointInPolygon(point, zone.points),
|
||||
(zone) =>
|
||||
(zone.resources ?? []).includes(resourceId) && pointInPolygon(point, zone.points),
|
||||
);
|
||||
if (!nodeMatch && !zoneMatch) {
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -1,95 +0,0 @@
|
|||
import { pointDistance, type MapPoint } from "./points";
|
||||
|
||||
export type SnapKind = "vertex" | "edge";
|
||||
|
||||
export interface SnapTarget {
|
||||
key: string;
|
||||
vertices: MapPoint[];
|
||||
/** Polygon target: the closing segment (last -> first vertex) participates. */
|
||||
closed: boolean;
|
||||
/** Offer edge snapping (nearest point on each segment), not just vertices. */
|
||||
edges: boolean;
|
||||
}
|
||||
|
||||
export interface SnapMatch {
|
||||
point: MapPoint;
|
||||
kind: SnapKind;
|
||||
targetKey: string;
|
||||
/** Distance in meters from the cursor to the matched geometry. */
|
||||
distance: number;
|
||||
}
|
||||
|
||||
/** Nearest point to p on segment ab (projection clamped to the endpoints). */
|
||||
export function closestPointOnSegment(p: MapPoint, a: MapPoint, b: MapPoint): MapPoint {
|
||||
const abx = b[0] - a[0];
|
||||
const aby = b[1] - a[1];
|
||||
const lengthSq = abx * abx + aby * aby;
|
||||
if (lengthSq === 0) return [a[0], a[1]];
|
||||
const t = Math.max(0, Math.min(1, ((p[0] - a[0]) * abx + (p[1] - a[1]) * aby) / lengthSq));
|
||||
return [a[0] + abx * t, a[1] + aby * t];
|
||||
}
|
||||
|
||||
/**
|
||||
* Nearest snap within radiusMeters. Vertices own their neighborhood: an edge
|
||||
* only wins when it is at least twice as close as the nearest vertex (clicks
|
||||
* near a corner mean the corner, not one of the edges meeting there). Returns null when
|
||||
* nothing is in range.
|
||||
*/
|
||||
export function findSnap(
|
||||
point: MapPoint,
|
||||
targets: readonly SnapTarget[],
|
||||
radiusMeters: number,
|
||||
): SnapMatch | null {
|
||||
if (radiusMeters <= 0) return null;
|
||||
let bestVertex: SnapMatch | null = null;
|
||||
let bestEdge: SnapMatch | null = null;
|
||||
|
||||
for (const target of targets) {
|
||||
for (const vertex of target.vertices) {
|
||||
const distance = pointDistance(point, vertex);
|
||||
if (distance <= radiusMeters && (!bestVertex || distance < bestVertex.distance)) {
|
||||
bestVertex = {
|
||||
point: [vertex[0], vertex[1]],
|
||||
kind: "vertex",
|
||||
targetKey: target.key,
|
||||
distance,
|
||||
};
|
||||
}
|
||||
}
|
||||
if (!target.edges) continue;
|
||||
const last = target.closed ? target.vertices.length : target.vertices.length - 1;
|
||||
for (let i = 0; i < last; i++) {
|
||||
const a = target.vertices[i];
|
||||
const b = target.vertices[(i + 1) % target.vertices.length];
|
||||
const projected = closestPointOnSegment(point, a, b);
|
||||
const distance = pointDistance(point, projected);
|
||||
if (distance <= radiusMeters && (!bestEdge || distance < bestEdge.distance)) {
|
||||
bestEdge = { point: projected, kind: "edge", targetKey: target.key, distance };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (bestVertex && (!bestEdge || bestEdge.distance >= bestVertex.distance * 0.5)) {
|
||||
return bestVertex;
|
||||
}
|
||||
return bestEdge ?? bestVertex;
|
||||
}
|
||||
|
||||
export interface SegmentMidpoint {
|
||||
/** Insert position in the point array: i + 1 (appends for the closing segment). */
|
||||
index: number;
|
||||
point: MapPoint;
|
||||
}
|
||||
|
||||
/** Midpoint of every segment, for click-to-insert vertex handles. */
|
||||
export function segmentMidpoints(points: MapPoint[], closed: boolean): SegmentMidpoint[] {
|
||||
const midpoints: SegmentMidpoint[] = [];
|
||||
if (points.length < 2) return midpoints;
|
||||
const segments = closed ? points.length : points.length - 1;
|
||||
for (let i = 0; i < segments; i++) {
|
||||
const a = points[i];
|
||||
const b = points[(i + 1) % points.length];
|
||||
midpoints.push({ index: i + 1, point: [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2] });
|
||||
}
|
||||
return midpoints;
|
||||
}
|
||||
|
|
@ -147,37 +147,3 @@ function segmentAngleDeg(from: MapPoint, to: MapPoint): number {
|
|||
if (dx === 0 && dy === 0) return 0;
|
||||
return (Math.atan2(dy, dx) * 180) / Math.PI;
|
||||
}
|
||||
|
||||
/**
|
||||
* CSS rotation for a straight label running along the from->to direction:
|
||||
* normalized into [-90, 90] (never upside down) with vertical text reading
|
||||
* bottom-to-top.
|
||||
*/
|
||||
export function labelScreenAngle(from: MapPoint, to: MapPoint): number {
|
||||
const angle = segmentAngleDeg(from, to);
|
||||
if (angle > 90) return angle - 180;
|
||||
if (angle < -90) return angle + 180;
|
||||
if (angle === 90) return -90;
|
||||
return angle;
|
||||
}
|
||||
|
||||
/**
|
||||
* Minor roads (dirt/trail) only carry a name label this close to the ground,
|
||||
* in meters per pixel (zoom >= 3 on an 8192 m world).
|
||||
*/
|
||||
export const MINOR_ROAD_LABEL_MAX_M_PER_PX = 4;
|
||||
|
||||
/**
|
||||
* Tri-state road label rule: an explicit checkbox value wins; unset means
|
||||
* auto, where paved roads always label and dirt/trail roads label only at
|
||||
* close zoom (metersPerPx at or below MINOR_ROAD_LABEL_MAX_M_PER_PX; pass
|
||||
* null/undefined to evaluate the zoom-independent part).
|
||||
*/
|
||||
export function roadLabelShown(
|
||||
road: { labelVisible?: boolean | null; surface: string },
|
||||
metersPerPx?: number | null,
|
||||
): boolean {
|
||||
if (road.labelVisible != null) return road.labelVisible;
|
||||
if (road.surface === "paved") return true;
|
||||
return metersPerPx != null && metersPerPx <= MINOR_ROAD_LABEL_MAX_M_PER_PX;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@ export interface ZoneFeature {
|
|||
id: number;
|
||||
name: string | null;
|
||||
description: string | null;
|
||||
type: "water" | "land" | "territory" | "border";
|
||||
type: "water" | "land";
|
||||
resources: { id: number; name: string }[];
|
||||
fillColor: string | null;
|
||||
strokeColor: string | null;
|
||||
|
|
|
|||
|
|
@ -1,29 +0,0 @@
|
|||
import { MigrateUpArgs, MigrateDownArgs, sql } from "@payloadcms/db-postgres";
|
||||
|
||||
export async function up({ db }: MigrateUpArgs): Promise<void> {
|
||||
// Political-boundary zone types (Postgres 12+ allows ADD VALUE inside the
|
||||
// migration's transaction as long as the new values are not used in it).
|
||||
await db.execute(sql`
|
||||
ALTER TYPE "enum_map_zones_type" ADD VALUE IF NOT EXISTS 'territory';
|
||||
`);
|
||||
await db.execute(sql`
|
||||
ALTER TYPE "enum_map_zones_type" ADD VALUE IF NOT EXISTS 'border';
|
||||
`);
|
||||
|
||||
// Road labels become tri-state: NULL = auto (paved only). Existing rows all
|
||||
// carry the old column default (true), which was never a deliberate choice,
|
||||
// so clear it on non-paved roads to make the paved-only default real.
|
||||
await db.execute(sql`
|
||||
ALTER TABLE "map_roads" ALTER COLUMN "label_visible" DROP DEFAULT;
|
||||
`);
|
||||
await db.execute(sql`
|
||||
UPDATE "map_roads" SET "label_visible" = NULL WHERE "surface" IS DISTINCT FROM 'paved';
|
||||
`);
|
||||
}
|
||||
|
||||
export async function down({ db }: MigrateDownArgs): Promise<void> {
|
||||
// Enum values cannot be removed in Postgres; restore the old road default.
|
||||
await db.execute(sql`
|
||||
ALTER TABLE "map_roads" ALTER COLUMN "label_visible" SET DEFAULT true;
|
||||
`);
|
||||
}
|
||||
|
|
@ -1,405 +1,399 @@
|
|||
import * as migration_20260820_004157 from "./20260820_004157";
|
||||
import * as migration_20260825_220000 from "./20260825_220000";
|
||||
import * as migration_20260826_050000_repair_shims_auxiliary_tables from "./20260826_050000_repair_shims_auxiliary_tables";
|
||||
import * as migration_20260826_060000_add_game_servers_event_target from "./20260826_060000_add_game_servers_event_target";
|
||||
import * as migration_20260827_005757 from "./20260827_005757";
|
||||
import * as migration_20260827_022334_add_eod_minesweeper_best_flags from "./20260827_022334_add_eod_minesweeper_best_flags";
|
||||
import * as migration_20260827_213857_add_evaluations_collection from "./20260827_213857_add_evaluations_collection";
|
||||
import * as migration_20260827_233224_add_mission_completed_at from "./20260827_233224_add_mission_completed_at";
|
||||
import * as migration_20260828_011500_add_background_texture_preference from "./20260828_011500_add_background_texture_preference";
|
||||
import * as migration_20260828_041615_add_campaign_mode from "./20260828_041615_add_campaign_mode";
|
||||
import * as migration_20260828_044506 from "./20260828_044506";
|
||||
import * as migration_20260831_142503 from "./20260831_142503";
|
||||
import * as migration_20260831_180233_assignment_transfers from "./20260831_180233_assignment_transfers";
|
||||
import * as migration_20260901_014006_add_assignment_transfer_event_target from "./20260901_014006_add_assignment_transfer_event_target";
|
||||
import * as migration_20260901_213108_add_ticket_votes_and_edit_history from "./20260901_213108_add_ticket_votes_and_edit_history";
|
||||
import * as migration_20260901_222200_add_admin_page_permissions from "./20260901_222200_add_admin_page_permissions";
|
||||
import * as migration_20260902_195336_add_medal_backgrounds from "./20260902_195336_add_medal_backgrounds";
|
||||
import * as migration_20260903_035544 from "./20260903_035544";
|
||||
import * as migration_20260903_044047 from "./20260903_044047";
|
||||
import * as migration_20260903_053419 from "./20260903_053419";
|
||||
import * as migration_20260903_060000_add_radio_mode_stats from "./20260903_060000_add_radio_mode_stats";
|
||||
import * as migration_20260903_061000_add_radio_incorrect_counts from "./20260903_061000_add_radio_incorrect_counts";
|
||||
import * as migration_20260903_062000_add_aim_trainer_stats from "./20260903_062000_add_aim_trainer_stats";
|
||||
import * as migration_20260904_230838_add_wiki_collections from "./20260904_230838_add_wiki_collections";
|
||||
import * as migration_20260905_183940_base_management from "./20260905_183940_base_management";
|
||||
import * as migration_20260905_195456_labor_headcount from "./20260905_195456_labor_headcount";
|
||||
import * as migration_20260905_210310_labor_classifications from "./20260905_210310_labor_classifications";
|
||||
import * as migration_20260905_213824 from "./20260905_213824";
|
||||
import * as migration_20260905_215658 from "./20260905_215658";
|
||||
import * as migration_20260906_045502 from "./20260906_045502";
|
||||
import * as migration_20260906_050900_add_evaluation_reminders_field from "./20260906_050900_add_evaluation_reminders_field";
|
||||
import * as migration_20260906_061451_add_wiki_template_css from "./20260906_061451_add_wiki_template_css";
|
||||
import * as migration_20260906_063753_add_personal_excerpt_richtext from "./20260906_063753_add_personal_excerpt_richtext";
|
||||
import * as migration_20260906_065405_add_wiki_template_html from "./20260906_065405_add_wiki_template_html";
|
||||
import * as migration_20260906_100908_add_market_economy_state from "./20260906_100908_add_market_economy_state";
|
||||
import * as migration_20260906_165837_add_market_state_event_target from "./20260906_165837_add_market_state_event_target";
|
||||
import * as migration_20260906_202925_add_voice_submissions from "./20260906_202925_add_voice_submissions";
|
||||
import * as migration_20260907_012326_add_missions_created_by from "./20260907_012326_add_missions_created_by";
|
||||
import * as migration_20260907_013724_make_missions_campaign_optional from "./20260907_013724_make_missions_campaign_optional";
|
||||
import * as migration_20260907_041051_add_voice_script_spoiler from "./20260907_041051_add_voice_script_spoiler";
|
||||
import * as migration_20260907_043339_add_voice_script_description from "./20260907_043339_add_voice_script_description";
|
||||
import * as migration_20260907_044934_add_missions_discord_modlist_hash from "./20260907_044934_add_missions_discord_modlist_hash";
|
||||
import * as migration_20260907_083010_add_voice_submission_description from "./20260907_083010_add_voice_submission_description";
|
||||
import * as migration_20260907_090245_add_mission_ready_reminder_fields from "./20260907_090245_add_mission_ready_reminder_fields";
|
||||
import * as migration_20260907_120754_add_story_beats from "./20260907_120754_add_story_beats";
|
||||
import * as migration_20260907_124024_convert_story_beats_body_to_lexical from "./20260907_124024_convert_story_beats_body_to_lexical";
|
||||
import * as migration_20260908_152021_add_opord_recall_minigame_stats from "./20260908_152021_add_opord_recall_minigame_stats";
|
||||
import * as migration_20260908_172241_add_custom_minigame_collections from "./20260908_172241_add_custom_minigame_collections";
|
||||
import * as migration_20260908_193107_add_creator_xp_bank_field from "./20260908_193107_add_creator_xp_bank_field";
|
||||
import * as migration_20260908_194435_add_expose_edge_mines_field from "./20260908_194435_add_expose_edge_mines_field";
|
||||
import * as migration_20260909_153427_add_opord_recall_best_times from "./20260909_153427_add_opord_recall_best_times";
|
||||
import * as migration_20260909_223955_add_voice_script_draft_status from "./20260909_223955_add_voice_script_draft_status";
|
||||
import * as migration_20260910_221018_make_missions_codename_optional from "./20260910_221018_make_missions_codename_optional";
|
||||
import * as migration_20260910_222518_add_dashboard_layout_preference from "./20260910_222518_add_dashboard_layout_preference";
|
||||
import * as migration_20260911_044754_add_poker_minigame_stats from "./20260911_044754_add_poker_minigame_stats";
|
||||
import * as migration_20260911_052147_add_ribbon_submissions from "./20260911_052147_add_ribbon_submissions";
|
||||
import * as migration_20260911_201559_add_award_advancement_series from "./20260911_201559_add_award_advancement_series";
|
||||
import * as migration_20260912_051752_add_rank_promotion_ceiling from "./20260912_051752_add_rank_promotion_ceiling";
|
||||
import * as migration_20260912_071740_add_blackjack_minigame_stats from "./20260912_071740_add_blackjack_minigame_stats";
|
||||
import * as migration_20260912_231219_add_discord_cancelled_at from "./20260912_231219_add_discord_cancelled_at";
|
||||
import * as migration_20260914_153046_add_chip_bank from "./20260914_153046_add_chip_bank";
|
||||
import * as migration_20260914_171412_add_respawn_tickets from "./20260914_171412_add_respawn_tickets";
|
||||
import * as migration_20260916_083158_add_game_map_system from "./20260916_083158_add_game_map_system";
|
||||
import * as migration_20260916_185544_construction_timestamp_model from "./20260916_185544_construction_timestamp_model";
|
||||
import * as migration_20260916_220224_add_map_labels_and_styling from "./20260916_220224_add_map_labels_and_styling";
|
||||
import * as migration_20260917_023028_add_node_and_zone_resources from "./20260917_023028_add_node_and_zone_resources";
|
||||
import * as migration_20260917_112258_add_zone_political_types from "./20260917_112258_add_zone_political_types";
|
||||
import * as migration_20260820_004157 from './20260820_004157';
|
||||
import * as migration_20260825_220000 from './20260825_220000';
|
||||
import * as migration_20260826_050000_repair_shims_auxiliary_tables from './20260826_050000_repair_shims_auxiliary_tables';
|
||||
import * as migration_20260826_060000_add_game_servers_event_target from './20260826_060000_add_game_servers_event_target';
|
||||
import * as migration_20260827_005757 from './20260827_005757';
|
||||
import * as migration_20260827_022334_add_eod_minesweeper_best_flags from './20260827_022334_add_eod_minesweeper_best_flags';
|
||||
import * as migration_20260827_213857_add_evaluations_collection from './20260827_213857_add_evaluations_collection';
|
||||
import * as migration_20260827_233224_add_mission_completed_at from './20260827_233224_add_mission_completed_at';
|
||||
import * as migration_20260828_011500_add_background_texture_preference from './20260828_011500_add_background_texture_preference';
|
||||
import * as migration_20260828_041615_add_campaign_mode from './20260828_041615_add_campaign_mode';
|
||||
import * as migration_20260828_044506 from './20260828_044506';
|
||||
import * as migration_20260831_142503 from './20260831_142503';
|
||||
import * as migration_20260831_180233_assignment_transfers from './20260831_180233_assignment_transfers';
|
||||
import * as migration_20260901_014006_add_assignment_transfer_event_target from './20260901_014006_add_assignment_transfer_event_target';
|
||||
import * as migration_20260901_213108_add_ticket_votes_and_edit_history from './20260901_213108_add_ticket_votes_and_edit_history';
|
||||
import * as migration_20260901_222200_add_admin_page_permissions from './20260901_222200_add_admin_page_permissions';
|
||||
import * as migration_20260902_195336_add_medal_backgrounds from './20260902_195336_add_medal_backgrounds';
|
||||
import * as migration_20260903_035544 from './20260903_035544';
|
||||
import * as migration_20260903_044047 from './20260903_044047';
|
||||
import * as migration_20260903_053419 from './20260903_053419';
|
||||
import * as migration_20260903_060000_add_radio_mode_stats from './20260903_060000_add_radio_mode_stats';
|
||||
import * as migration_20260903_061000_add_radio_incorrect_counts from './20260903_061000_add_radio_incorrect_counts';
|
||||
import * as migration_20260903_062000_add_aim_trainer_stats from './20260903_062000_add_aim_trainer_stats';
|
||||
import * as migration_20260904_230838_add_wiki_collections from './20260904_230838_add_wiki_collections';
|
||||
import * as migration_20260905_183940_base_management from './20260905_183940_base_management';
|
||||
import * as migration_20260905_195456_labor_headcount from './20260905_195456_labor_headcount';
|
||||
import * as migration_20260905_210310_labor_classifications from './20260905_210310_labor_classifications';
|
||||
import * as migration_20260905_213824 from './20260905_213824';
|
||||
import * as migration_20260905_215658 from './20260905_215658';
|
||||
import * as migration_20260906_045502 from './20260906_045502';
|
||||
import * as migration_20260906_050900_add_evaluation_reminders_field from './20260906_050900_add_evaluation_reminders_field';
|
||||
import * as migration_20260906_061451_add_wiki_template_css from './20260906_061451_add_wiki_template_css';
|
||||
import * as migration_20260906_063753_add_personal_excerpt_richtext from './20260906_063753_add_personal_excerpt_richtext';
|
||||
import * as migration_20260906_065405_add_wiki_template_html from './20260906_065405_add_wiki_template_html';
|
||||
import * as migration_20260906_100908_add_market_economy_state from './20260906_100908_add_market_economy_state';
|
||||
import * as migration_20260906_165837_add_market_state_event_target from './20260906_165837_add_market_state_event_target';
|
||||
import * as migration_20260906_202925_add_voice_submissions from './20260906_202925_add_voice_submissions';
|
||||
import * as migration_20260907_012326_add_missions_created_by from './20260907_012326_add_missions_created_by';
|
||||
import * as migration_20260907_013724_make_missions_campaign_optional from './20260907_013724_make_missions_campaign_optional';
|
||||
import * as migration_20260907_041051_add_voice_script_spoiler from './20260907_041051_add_voice_script_spoiler';
|
||||
import * as migration_20260907_043339_add_voice_script_description from './20260907_043339_add_voice_script_description';
|
||||
import * as migration_20260907_044934_add_missions_discord_modlist_hash from './20260907_044934_add_missions_discord_modlist_hash';
|
||||
import * as migration_20260907_083010_add_voice_submission_description from './20260907_083010_add_voice_submission_description';
|
||||
import * as migration_20260907_090245_add_mission_ready_reminder_fields from './20260907_090245_add_mission_ready_reminder_fields';
|
||||
import * as migration_20260907_120754_add_story_beats from './20260907_120754_add_story_beats';
|
||||
import * as migration_20260907_124024_convert_story_beats_body_to_lexical from './20260907_124024_convert_story_beats_body_to_lexical';
|
||||
import * as migration_20260908_152021_add_opord_recall_minigame_stats from './20260908_152021_add_opord_recall_minigame_stats';
|
||||
import * as migration_20260908_172241_add_custom_minigame_collections from './20260908_172241_add_custom_minigame_collections';
|
||||
import * as migration_20260908_193107_add_creator_xp_bank_field from './20260908_193107_add_creator_xp_bank_field';
|
||||
import * as migration_20260908_194435_add_expose_edge_mines_field from './20260908_194435_add_expose_edge_mines_field';
|
||||
import * as migration_20260909_153427_add_opord_recall_best_times from './20260909_153427_add_opord_recall_best_times';
|
||||
import * as migration_20260909_223955_add_voice_script_draft_status from './20260909_223955_add_voice_script_draft_status';
|
||||
import * as migration_20260910_221018_make_missions_codename_optional from './20260910_221018_make_missions_codename_optional';
|
||||
import * as migration_20260910_222518_add_dashboard_layout_preference from './20260910_222518_add_dashboard_layout_preference';
|
||||
import * as migration_20260911_044754_add_poker_minigame_stats from './20260911_044754_add_poker_minigame_stats';
|
||||
import * as migration_20260911_052147_add_ribbon_submissions from './20260911_052147_add_ribbon_submissions';
|
||||
import * as migration_20260911_201559_add_award_advancement_series from './20260911_201559_add_award_advancement_series';
|
||||
import * as migration_20260912_051752_add_rank_promotion_ceiling from './20260912_051752_add_rank_promotion_ceiling';
|
||||
import * as migration_20260912_071740_add_blackjack_minigame_stats from './20260912_071740_add_blackjack_minigame_stats';
|
||||
import * as migration_20260912_231219_add_discord_cancelled_at from './20260912_231219_add_discord_cancelled_at';
|
||||
import * as migration_20260914_153046_add_chip_bank from './20260914_153046_add_chip_bank';
|
||||
import * as migration_20260914_171412_add_respawn_tickets from './20260914_171412_add_respawn_tickets';
|
||||
import * as migration_20260916_083158_add_game_map_system from './20260916_083158_add_game_map_system';
|
||||
import * as migration_20260916_185544_construction_timestamp_model from './20260916_185544_construction_timestamp_model';
|
||||
import * as migration_20260916_220224_add_map_labels_and_styling from './20260916_220224_add_map_labels_and_styling';
|
||||
import * as migration_20260917_023028_add_node_and_zone_resources from './20260917_023028_add_node_and_zone_resources';
|
||||
|
||||
export const migrations = [
|
||||
{
|
||||
up: migration_20260820_004157.up,
|
||||
down: migration_20260820_004157.down,
|
||||
name: "20260820_004157",
|
||||
name: '20260820_004157',
|
||||
},
|
||||
{
|
||||
up: migration_20260825_220000.up,
|
||||
down: migration_20260825_220000.down,
|
||||
name: "20260825_220000",
|
||||
name: '20260825_220000',
|
||||
},
|
||||
{
|
||||
up: migration_20260826_050000_repair_shims_auxiliary_tables.up,
|
||||
down: migration_20260826_050000_repair_shims_auxiliary_tables.down,
|
||||
name: "20260826_050000_repair_shims_auxiliary_tables",
|
||||
name: '20260826_050000_repair_shims_auxiliary_tables',
|
||||
},
|
||||
{
|
||||
up: migration_20260826_060000_add_game_servers_event_target.up,
|
||||
down: migration_20260826_060000_add_game_servers_event_target.down,
|
||||
name: "20260826_060000_add_game_servers_event_target",
|
||||
name: '20260826_060000_add_game_servers_event_target',
|
||||
},
|
||||
{
|
||||
up: migration_20260827_005757.up,
|
||||
down: migration_20260827_005757.down,
|
||||
name: "20260827_005757",
|
||||
name: '20260827_005757',
|
||||
},
|
||||
{
|
||||
up: migration_20260827_022334_add_eod_minesweeper_best_flags.up,
|
||||
down: migration_20260827_022334_add_eod_minesweeper_best_flags.down,
|
||||
name: "20260827_022334_add_eod_minesweeper_best_flags",
|
||||
name: '20260827_022334_add_eod_minesweeper_best_flags',
|
||||
},
|
||||
{
|
||||
up: migration_20260827_213857_add_evaluations_collection.up,
|
||||
down: migration_20260827_213857_add_evaluations_collection.down,
|
||||
name: "20260827_213857_add_evaluations_collection",
|
||||
name: '20260827_213857_add_evaluations_collection',
|
||||
},
|
||||
{
|
||||
up: migration_20260827_233224_add_mission_completed_at.up,
|
||||
down: migration_20260827_233224_add_mission_completed_at.down,
|
||||
name: "20260827_233224_add_mission_completed_at",
|
||||
name: '20260827_233224_add_mission_completed_at',
|
||||
},
|
||||
{
|
||||
up: migration_20260828_011500_add_background_texture_preference.up,
|
||||
down: migration_20260828_011500_add_background_texture_preference.down,
|
||||
name: "20260828_011500_add_background_texture_preference",
|
||||
name: '20260828_011500_add_background_texture_preference',
|
||||
},
|
||||
{
|
||||
up: migration_20260828_041615_add_campaign_mode.up,
|
||||
down: migration_20260828_041615_add_campaign_mode.down,
|
||||
name: "20260828_041615_add_campaign_mode",
|
||||
name: '20260828_041615_add_campaign_mode',
|
||||
},
|
||||
{
|
||||
up: migration_20260828_044506.up,
|
||||
down: migration_20260828_044506.down,
|
||||
name: "20260828_044506",
|
||||
name: '20260828_044506',
|
||||
},
|
||||
{
|
||||
up: migration_20260831_142503.up,
|
||||
down: migration_20260831_142503.down,
|
||||
name: "20260831_142503",
|
||||
name: '20260831_142503',
|
||||
},
|
||||
{
|
||||
up: migration_20260831_180233_assignment_transfers.up,
|
||||
down: migration_20260831_180233_assignment_transfers.down,
|
||||
name: "20260831_180233_assignment_transfers",
|
||||
name: '20260831_180233_assignment_transfers',
|
||||
},
|
||||
{
|
||||
up: migration_20260901_014006_add_assignment_transfer_event_target.up,
|
||||
down: migration_20260901_014006_add_assignment_transfer_event_target.down,
|
||||
name: "20260901_014006_add_assignment_transfer_event_target",
|
||||
name: '20260901_014006_add_assignment_transfer_event_target',
|
||||
},
|
||||
{
|
||||
up: migration_20260901_213108_add_ticket_votes_and_edit_history.up,
|
||||
down: migration_20260901_213108_add_ticket_votes_and_edit_history.down,
|
||||
name: "20260901_213108_add_ticket_votes_and_edit_history",
|
||||
name: '20260901_213108_add_ticket_votes_and_edit_history',
|
||||
},
|
||||
{
|
||||
up: migration_20260901_222200_add_admin_page_permissions.up,
|
||||
down: migration_20260901_222200_add_admin_page_permissions.down,
|
||||
name: "20260901_222200_add_admin_page_permissions",
|
||||
name: '20260901_222200_add_admin_page_permissions',
|
||||
},
|
||||
{
|
||||
up: migration_20260902_195336_add_medal_backgrounds.up,
|
||||
down: migration_20260902_195336_add_medal_backgrounds.down,
|
||||
name: "20260902_195336_add_medal_backgrounds",
|
||||
name: '20260902_195336_add_medal_backgrounds',
|
||||
},
|
||||
{
|
||||
up: migration_20260903_035544.up,
|
||||
down: migration_20260903_035544.down,
|
||||
name: "20260903_035544",
|
||||
name: '20260903_035544',
|
||||
},
|
||||
{
|
||||
up: migration_20260903_044047.up,
|
||||
down: migration_20260903_044047.down,
|
||||
name: "20260903_044047",
|
||||
name: '20260903_044047',
|
||||
},
|
||||
{
|
||||
up: migration_20260903_053419.up,
|
||||
down: migration_20260903_053419.down,
|
||||
name: "20260903_053419",
|
||||
name: '20260903_053419',
|
||||
},
|
||||
{
|
||||
up: migration_20260903_060000_add_radio_mode_stats.up,
|
||||
down: migration_20260903_060000_add_radio_mode_stats.down,
|
||||
name: "20260903_060000_add_radio_mode_stats",
|
||||
name: '20260903_060000_add_radio_mode_stats',
|
||||
},
|
||||
{
|
||||
up: migration_20260903_061000_add_radio_incorrect_counts.up,
|
||||
down: migration_20260903_061000_add_radio_incorrect_counts.down,
|
||||
name: "20260903_061000_add_radio_incorrect_counts",
|
||||
name: '20260903_061000_add_radio_incorrect_counts',
|
||||
},
|
||||
{
|
||||
up: migration_20260903_062000_add_aim_trainer_stats.up,
|
||||
down: migration_20260903_062000_add_aim_trainer_stats.down,
|
||||
name: "20260903_062000_add_aim_trainer_stats",
|
||||
name: '20260903_062000_add_aim_trainer_stats',
|
||||
},
|
||||
{
|
||||
up: migration_20260904_230838_add_wiki_collections.up,
|
||||
down: migration_20260904_230838_add_wiki_collections.down,
|
||||
name: "20260904_230838_add_wiki_collections",
|
||||
name: '20260904_230838_add_wiki_collections',
|
||||
},
|
||||
{
|
||||
up: migration_20260905_183940_base_management.up,
|
||||
down: migration_20260905_183940_base_management.down,
|
||||
name: "20260905_183940_base_management",
|
||||
name: '20260905_183940_base_management',
|
||||
},
|
||||
{
|
||||
up: migration_20260905_195456_labor_headcount.up,
|
||||
down: migration_20260905_195456_labor_headcount.down,
|
||||
name: "20260905_195456_labor_headcount",
|
||||
name: '20260905_195456_labor_headcount',
|
||||
},
|
||||
{
|
||||
up: migration_20260905_210310_labor_classifications.up,
|
||||
down: migration_20260905_210310_labor_classifications.down,
|
||||
name: "20260905_210310_labor_classifications",
|
||||
name: '20260905_210310_labor_classifications',
|
||||
},
|
||||
{
|
||||
up: migration_20260905_213824.up,
|
||||
down: migration_20260905_213824.down,
|
||||
name: "20260905_213824",
|
||||
name: '20260905_213824',
|
||||
},
|
||||
{
|
||||
up: migration_20260905_215658.up,
|
||||
down: migration_20260905_215658.down,
|
||||
name: "20260905_215658",
|
||||
name: '20260905_215658',
|
||||
},
|
||||
{
|
||||
up: migration_20260906_045502.up,
|
||||
down: migration_20260906_045502.down,
|
||||
name: "20260906_045502",
|
||||
name: '20260906_045502',
|
||||
},
|
||||
{
|
||||
up: migration_20260906_050900_add_evaluation_reminders_field.up,
|
||||
down: migration_20260906_050900_add_evaluation_reminders_field.down,
|
||||
name: "20260906_050900_add_evaluation_reminders_field",
|
||||
name: '20260906_050900_add_evaluation_reminders_field',
|
||||
},
|
||||
{
|
||||
up: migration_20260906_061451_add_wiki_template_css.up,
|
||||
down: migration_20260906_061451_add_wiki_template_css.down,
|
||||
name: "20260906_061451_add_wiki_template_css",
|
||||
name: '20260906_061451_add_wiki_template_css',
|
||||
},
|
||||
{
|
||||
up: migration_20260906_063753_add_personal_excerpt_richtext.up,
|
||||
down: migration_20260906_063753_add_personal_excerpt_richtext.down,
|
||||
name: "20260906_063753_add_personal_excerpt_richtext",
|
||||
name: '20260906_063753_add_personal_excerpt_richtext',
|
||||
},
|
||||
{
|
||||
up: migration_20260906_065405_add_wiki_template_html.up,
|
||||
down: migration_20260906_065405_add_wiki_template_html.down,
|
||||
name: "20260906_065405_add_wiki_template_html",
|
||||
name: '20260906_065405_add_wiki_template_html',
|
||||
},
|
||||
{
|
||||
up: migration_20260906_100908_add_market_economy_state.up,
|
||||
down: migration_20260906_100908_add_market_economy_state.down,
|
||||
name: "20260906_100908_add_market_economy_state",
|
||||
name: '20260906_100908_add_market_economy_state',
|
||||
},
|
||||
{
|
||||
up: migration_20260906_165837_add_market_state_event_target.up,
|
||||
down: migration_20260906_165837_add_market_state_event_target.down,
|
||||
name: "20260906_165837_add_market_state_event_target",
|
||||
name: '20260906_165837_add_market_state_event_target',
|
||||
},
|
||||
{
|
||||
up: migration_20260906_202925_add_voice_submissions.up,
|
||||
down: migration_20260906_202925_add_voice_submissions.down,
|
||||
name: "20260906_202925_add_voice_submissions",
|
||||
name: '20260906_202925_add_voice_submissions',
|
||||
},
|
||||
{
|
||||
up: migration_20260907_012326_add_missions_created_by.up,
|
||||
down: migration_20260907_012326_add_missions_created_by.down,
|
||||
name: "20260907_012326_add_missions_created_by",
|
||||
name: '20260907_012326_add_missions_created_by',
|
||||
},
|
||||
{
|
||||
up: migration_20260907_013724_make_missions_campaign_optional.up,
|
||||
down: migration_20260907_013724_make_missions_campaign_optional.down,
|
||||
name: "20260907_013724_make_missions_campaign_optional",
|
||||
name: '20260907_013724_make_missions_campaign_optional',
|
||||
},
|
||||
{
|
||||
up: migration_20260907_041051_add_voice_script_spoiler.up,
|
||||
down: migration_20260907_041051_add_voice_script_spoiler.down,
|
||||
name: "20260907_041051_add_voice_script_spoiler",
|
||||
name: '20260907_041051_add_voice_script_spoiler',
|
||||
},
|
||||
{
|
||||
up: migration_20260907_043339_add_voice_script_description.up,
|
||||
down: migration_20260907_043339_add_voice_script_description.down,
|
||||
name: "20260907_043339_add_voice_script_description",
|
||||
name: '20260907_043339_add_voice_script_description',
|
||||
},
|
||||
{
|
||||
up: migration_20260907_044934_add_missions_discord_modlist_hash.up,
|
||||
down: migration_20260907_044934_add_missions_discord_modlist_hash.down,
|
||||
name: "20260907_044934_add_missions_discord_modlist_hash",
|
||||
name: '20260907_044934_add_missions_discord_modlist_hash',
|
||||
},
|
||||
{
|
||||
up: migration_20260907_083010_add_voice_submission_description.up,
|
||||
down: migration_20260907_083010_add_voice_submission_description.down,
|
||||
name: "20260907_083010_add_voice_submission_description",
|
||||
name: '20260907_083010_add_voice_submission_description',
|
||||
},
|
||||
{
|
||||
up: migration_20260907_090245_add_mission_ready_reminder_fields.up,
|
||||
down: migration_20260907_090245_add_mission_ready_reminder_fields.down,
|
||||
name: "20260907_090245_add_mission_ready_reminder_fields",
|
||||
name: '20260907_090245_add_mission_ready_reminder_fields',
|
||||
},
|
||||
{
|
||||
up: migration_20260907_120754_add_story_beats.up,
|
||||
down: migration_20260907_120754_add_story_beats.down,
|
||||
name: "20260907_120754_add_story_beats",
|
||||
name: '20260907_120754_add_story_beats',
|
||||
},
|
||||
{
|
||||
up: migration_20260907_124024_convert_story_beats_body_to_lexical.up,
|
||||
down: migration_20260907_124024_convert_story_beats_body_to_lexical.down,
|
||||
name: "20260907_124024_convert_story_beats_body_to_lexical",
|
||||
name: '20260907_124024_convert_story_beats_body_to_lexical',
|
||||
},
|
||||
{
|
||||
up: migration_20260908_152021_add_opord_recall_minigame_stats.up,
|
||||
down: migration_20260908_152021_add_opord_recall_minigame_stats.down,
|
||||
name: "20260908_152021_add_opord_recall_minigame_stats",
|
||||
name: '20260908_152021_add_opord_recall_minigame_stats',
|
||||
},
|
||||
{
|
||||
up: migration_20260908_172241_add_custom_minigame_collections.up,
|
||||
down: migration_20260908_172241_add_custom_minigame_collections.down,
|
||||
name: "20260908_172241_add_custom_minigame_collections",
|
||||
name: '20260908_172241_add_custom_minigame_collections',
|
||||
},
|
||||
{
|
||||
up: migration_20260908_193107_add_creator_xp_bank_field.up,
|
||||
down: migration_20260908_193107_add_creator_xp_bank_field.down,
|
||||
name: "20260908_193107_add_creator_xp_bank_field",
|
||||
name: '20260908_193107_add_creator_xp_bank_field',
|
||||
},
|
||||
{
|
||||
up: migration_20260908_194435_add_expose_edge_mines_field.up,
|
||||
down: migration_20260908_194435_add_expose_edge_mines_field.down,
|
||||
name: "20260908_194435_add_expose_edge_mines_field",
|
||||
name: '20260908_194435_add_expose_edge_mines_field',
|
||||
},
|
||||
{
|
||||
up: migration_20260909_153427_add_opord_recall_best_times.up,
|
||||
down: migration_20260909_153427_add_opord_recall_best_times.down,
|
||||
name: "20260909_153427_add_opord_recall_best_times",
|
||||
name: '20260909_153427_add_opord_recall_best_times',
|
||||
},
|
||||
{
|
||||
up: migration_20260909_223955_add_voice_script_draft_status.up,
|
||||
down: migration_20260909_223955_add_voice_script_draft_status.down,
|
||||
name: "20260909_223955_add_voice_script_draft_status",
|
||||
name: '20260909_223955_add_voice_script_draft_status',
|
||||
},
|
||||
{
|
||||
up: migration_20260910_221018_make_missions_codename_optional.up,
|
||||
down: migration_20260910_221018_make_missions_codename_optional.down,
|
||||
name: "20260910_221018_make_missions_codename_optional",
|
||||
name: '20260910_221018_make_missions_codename_optional',
|
||||
},
|
||||
{
|
||||
up: migration_20260910_222518_add_dashboard_layout_preference.up,
|
||||
down: migration_20260910_222518_add_dashboard_layout_preference.down,
|
||||
name: "20260910_222518_add_dashboard_layout_preference",
|
||||
name: '20260910_222518_add_dashboard_layout_preference',
|
||||
},
|
||||
{
|
||||
up: migration_20260911_044754_add_poker_minigame_stats.up,
|
||||
down: migration_20260911_044754_add_poker_minigame_stats.down,
|
||||
name: "20260911_044754_add_poker_minigame_stats",
|
||||
name: '20260911_044754_add_poker_minigame_stats',
|
||||
},
|
||||
{
|
||||
up: migration_20260911_052147_add_ribbon_submissions.up,
|
||||
down: migration_20260911_052147_add_ribbon_submissions.down,
|
||||
name: "20260911_052147_add_ribbon_submissions",
|
||||
name: '20260911_052147_add_ribbon_submissions',
|
||||
},
|
||||
{
|
||||
up: migration_20260911_201559_add_award_advancement_series.up,
|
||||
down: migration_20260911_201559_add_award_advancement_series.down,
|
||||
name: "20260911_201559_add_award_advancement_series",
|
||||
name: '20260911_201559_add_award_advancement_series',
|
||||
},
|
||||
{
|
||||
up: migration_20260912_051752_add_rank_promotion_ceiling.up,
|
||||
down: migration_20260912_051752_add_rank_promotion_ceiling.down,
|
||||
name: "20260912_051752_add_rank_promotion_ceiling",
|
||||
name: '20260912_051752_add_rank_promotion_ceiling',
|
||||
},
|
||||
{
|
||||
up: migration_20260912_071740_add_blackjack_minigame_stats.up,
|
||||
down: migration_20260912_071740_add_blackjack_minigame_stats.down,
|
||||
name: "20260912_071740_add_blackjack_minigame_stats",
|
||||
name: '20260912_071740_add_blackjack_minigame_stats',
|
||||
},
|
||||
{
|
||||
up: migration_20260912_231219_add_discord_cancelled_at.up,
|
||||
down: migration_20260912_231219_add_discord_cancelled_at.down,
|
||||
name: "20260912_231219_add_discord_cancelled_at",
|
||||
name: '20260912_231219_add_discord_cancelled_at',
|
||||
},
|
||||
{
|
||||
up: migration_20260914_153046_add_chip_bank.up,
|
||||
down: migration_20260914_153046_add_chip_bank.down,
|
||||
name: "20260914_153046_add_chip_bank",
|
||||
name: '20260914_153046_add_chip_bank',
|
||||
},
|
||||
{
|
||||
up: migration_20260914_171412_add_respawn_tickets.up,
|
||||
down: migration_20260914_171412_add_respawn_tickets.down,
|
||||
name: "20260914_171412_add_respawn_tickets",
|
||||
name: '20260914_171412_add_respawn_tickets',
|
||||
},
|
||||
{
|
||||
up: migration_20260916_083158_add_game_map_system.up,
|
||||
down: migration_20260916_083158_add_game_map_system.down,
|
||||
name: "20260916_083158_add_game_map_system",
|
||||
name: '20260916_083158_add_game_map_system',
|
||||
},
|
||||
{
|
||||
up: migration_20260916_185544_construction_timestamp_model.up,
|
||||
down: migration_20260916_185544_construction_timestamp_model.down,
|
||||
name: "20260916_185544_construction_timestamp_model",
|
||||
name: '20260916_185544_construction_timestamp_model',
|
||||
},
|
||||
{
|
||||
up: migration_20260916_220224_add_map_labels_and_styling.up,
|
||||
down: migration_20260916_220224_add_map_labels_and_styling.down,
|
||||
name: "20260916_220224_add_map_labels_and_styling",
|
||||
name: '20260916_220224_add_map_labels_and_styling',
|
||||
},
|
||||
{
|
||||
up: migration_20260917_023028_add_node_and_zone_resources.up,
|
||||
down: migration_20260917_023028_add_node_and_zone_resources.down,
|
||||
name: "20260917_023028_add_node_and_zone_resources",
|
||||
},
|
||||
{
|
||||
up: migration_20260917_112258_add_zone_political_types.up,
|
||||
down: migration_20260917_112258_add_zone_political_types.down,
|
||||
name: "20260917_112258_add_zone_political_types",
|
||||
name: '20260917_023028_add_node_and_zone_resources'
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -828,17 +828,7 @@ export const enum_releases_status = pgEnum("enum_releases_status", [
|
|||
]);
|
||||
export const enum_maps_basemap_mode = pgEnum("enum_maps_basemap_mode", ["image", "tiles"]);
|
||||
export const enum_map_roads_surface = pgEnum("enum_map_roads_surface", ["paved", "dirt", "trail"]);
|
||||
export const enum_map_roads_stroke_style = pgEnum("enum_map_roads_stroke_style", [
|
||||
"solid",
|
||||
"dashed",
|
||||
"dotted",
|
||||
]);
|
||||
export const enum_map_zones_type = pgEnum("enum_map_zones_type", [
|
||||
"water",
|
||||
"land",
|
||||
"territory",
|
||||
"border",
|
||||
]);
|
||||
export const enum_map_zones_type = pgEnum("enum_map_zones_type", ["water", "land"]);
|
||||
export const enum_narrative_events_triggers_type = pgEnum("enum_narrative_events_triggers_type", [
|
||||
"manual",
|
||||
"mission_complete",
|
||||
|
|
@ -1076,8 +1066,6 @@ export const game_structures = pgTable(
|
|||
}),
|
||||
maintenanceCost: numeric("maintenance_cost", { mode: "number" }).default(0),
|
||||
coordinates: geometryColumn("coordinates"),
|
||||
labelVisible: boolean("label_visible").default(true),
|
||||
labelPriority: numeric("label_priority", { mode: "number" }).default(0),
|
||||
updatedAt: timestamp("updated_at", { mode: "string", withTimezone: true, precision: 3 })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
|
|
@ -4799,7 +4787,6 @@ export const map_roads = pgTable(
|
|||
{
|
||||
id: serial("id").primaryKey(),
|
||||
name: varchar("name"),
|
||||
description: varchar("description"),
|
||||
map: integer("map_id")
|
||||
.notNull()
|
||||
.references(() => maps.id, {
|
||||
|
|
@ -4807,10 +4794,6 @@ export const map_roads = pgTable(
|
|||
}),
|
||||
surface: enum_map_roads_surface("surface").notNull().default("paved"),
|
||||
speedMultiplier: numeric("speed_multiplier", { mode: "number" }).notNull().default(1),
|
||||
strokeColor: varchar("stroke_color"),
|
||||
strokeWeight: numeric("stroke_weight", { mode: "number" }).default(2),
|
||||
strokeStyle: enum_map_roads_stroke_style("stroke_style").default("solid"),
|
||||
labelVisible: boolean("label_visible"),
|
||||
points: jsonb("points").notNull(),
|
||||
updatedAt: timestamp("updated_at", { mode: "string", withTimezone: true, precision: 3 })
|
||||
.defaultNow()
|
||||
|
|
@ -4831,18 +4814,12 @@ export const map_zones = pgTable(
|
|||
{
|
||||
id: serial("id").primaryKey(),
|
||||
name: varchar("name"),
|
||||
description: varchar("description"),
|
||||
map: integer("map_id")
|
||||
.notNull()
|
||||
.references(() => maps.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
type: enum_map_zones_type("type").notNull(),
|
||||
fillColor: varchar("fill_color"),
|
||||
strokeColor: varchar("stroke_color"),
|
||||
fillOpacity: numeric("fill_opacity", { mode: "number" }),
|
||||
strokeWeight: numeric("stroke_weight", { mode: "number" }).default(1),
|
||||
labelVisible: boolean("label_visible").default(true),
|
||||
points: jsonb("points").notNull(),
|
||||
updatedAt: timestamp("updated_at", { mode: "string", withTimezone: true, precision: 3 })
|
||||
.defaultNow()
|
||||
|
|
@ -4858,44 +4835,21 @@ export const map_zones = pgTable(
|
|||
],
|
||||
);
|
||||
|
||||
export const map_zones_rels = pgTable(
|
||||
"map_zones_rels",
|
||||
{
|
||||
id: serial("id").primaryKey(),
|
||||
order: integer("order"),
|
||||
parent: integer("parent_id").notNull(),
|
||||
path: varchar("path").notNull(),
|
||||
resourcesID: integer("resources_id"),
|
||||
},
|
||||
(columns) => [
|
||||
index("map_zones_rels_order_idx").on(columns.order),
|
||||
index("map_zones_rels_parent_idx").on(columns.parent),
|
||||
index("map_zones_rels_path_idx").on(columns.path),
|
||||
index("map_zones_rels_resources_id_idx").on(columns.resourcesID),
|
||||
foreignKey({
|
||||
columns: [columns["parent"]],
|
||||
foreignColumns: [map_zones.id],
|
||||
name: "map_zones_rels_parent_fk",
|
||||
}).onDelete("cascade"),
|
||||
foreignKey({
|
||||
columns: [columns["resourcesID"]],
|
||||
foreignColumns: [resources.id],
|
||||
name: "map_zones_rels_resources_fk",
|
||||
}).onDelete("cascade"),
|
||||
],
|
||||
);
|
||||
|
||||
export const resource_nodes = pgTable(
|
||||
"resource_nodes",
|
||||
{
|
||||
id: serial("id").primaryKey(),
|
||||
name: varchar("name").notNull(),
|
||||
description: varchar("description"),
|
||||
map: integer("map_id")
|
||||
.notNull()
|
||||
.references(() => maps.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
resource: integer("resource_id")
|
||||
.notNull()
|
||||
.references(() => resources.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
position: jsonb("position").notNull(),
|
||||
richness: numeric("richness", { mode: "number" }).default(100),
|
||||
updatedAt: timestamp("updated_at", { mode: "string", withTimezone: true, precision: 3 })
|
||||
|
|
@ -4907,38 +4861,12 @@ export const resource_nodes = pgTable(
|
|||
},
|
||||
(columns) => [
|
||||
index("resource_nodes_map_idx").on(columns.map),
|
||||
index("resource_nodes_resource_idx").on(columns.resource),
|
||||
index("resource_nodes_updated_at_idx").on(columns.updatedAt),
|
||||
index("resource_nodes_created_at_idx").on(columns.createdAt),
|
||||
],
|
||||
);
|
||||
|
||||
export const resource_nodes_rels = pgTable(
|
||||
"resource_nodes_rels",
|
||||
{
|
||||
id: serial("id").primaryKey(),
|
||||
order: integer("order"),
|
||||
parent: integer("parent_id").notNull(),
|
||||
path: varchar("path").notNull(),
|
||||
resourcesID: integer("resources_id"),
|
||||
},
|
||||
(columns) => [
|
||||
index("resource_nodes_rels_order_idx").on(columns.order),
|
||||
index("resource_nodes_rels_parent_idx").on(columns.parent),
|
||||
index("resource_nodes_rels_path_idx").on(columns.path),
|
||||
index("resource_nodes_rels_resources_id_idx").on(columns.resourcesID),
|
||||
foreignKey({
|
||||
columns: [columns["parent"]],
|
||||
foreignColumns: [resource_nodes.id],
|
||||
name: "resource_nodes_rels_parent_fk",
|
||||
}).onDelete("cascade"),
|
||||
foreignKey({
|
||||
columns: [columns["resourcesID"]],
|
||||
foreignColumns: [resources.id],
|
||||
name: "resource_nodes_rels_resources_fk",
|
||||
}).onDelete("cascade"),
|
||||
],
|
||||
);
|
||||
|
||||
export const narrative_events = pgTable(
|
||||
"narrative_events",
|
||||
{
|
||||
|
|
@ -8144,48 +8072,23 @@ export const relations_map_roads = relations(map_roads, ({ one }) => ({
|
|||
relationName: "map",
|
||||
}),
|
||||
}));
|
||||
export const relations_map_zones_rels = relations(map_zones_rels, ({ one }) => ({
|
||||
parent: one(map_zones, {
|
||||
fields: [map_zones_rels.parent],
|
||||
references: [map_zones.id],
|
||||
relationName: "_rels",
|
||||
}),
|
||||
resourcesID: one(resources, {
|
||||
fields: [map_zones_rels.resourcesID],
|
||||
references: [resources.id],
|
||||
relationName: "resources",
|
||||
}),
|
||||
}));
|
||||
export const relations_map_zones = relations(map_zones, ({ one, many }) => ({
|
||||
export const relations_map_zones = relations(map_zones, ({ one }) => ({
|
||||
map: one(maps, {
|
||||
fields: [map_zones.map],
|
||||
references: [maps.id],
|
||||
relationName: "map",
|
||||
}),
|
||||
_rels: many(map_zones_rels, {
|
||||
relationName: "_rels",
|
||||
}),
|
||||
}));
|
||||
export const relations_resource_nodes_rels = relations(resource_nodes_rels, ({ one }) => ({
|
||||
parent: one(resource_nodes, {
|
||||
fields: [resource_nodes_rels.parent],
|
||||
references: [resource_nodes.id],
|
||||
relationName: "_rels",
|
||||
}),
|
||||
resourcesID: one(resources, {
|
||||
fields: [resource_nodes_rels.resourcesID],
|
||||
references: [resources.id],
|
||||
relationName: "resources",
|
||||
}),
|
||||
}));
|
||||
export const relations_resource_nodes = relations(resource_nodes, ({ one, many }) => ({
|
||||
export const relations_resource_nodes = relations(resource_nodes, ({ one }) => ({
|
||||
map: one(maps, {
|
||||
fields: [resource_nodes.map],
|
||||
references: [maps.id],
|
||||
relationName: "map",
|
||||
}),
|
||||
_rels: many(resource_nodes_rels, {
|
||||
relationName: "_rels",
|
||||
resource: one(resources, {
|
||||
fields: [resource_nodes.resource],
|
||||
references: [resources.id],
|
||||
relationName: "resource",
|
||||
}),
|
||||
}));
|
||||
export const relations_narrative_events = relations(narrative_events, () => ({}));
|
||||
|
|
@ -8854,7 +8757,6 @@ type DatabaseSchema = {
|
|||
enum_releases_status: typeof enum_releases_status;
|
||||
enum_maps_basemap_mode: typeof enum_maps_basemap_mode;
|
||||
enum_map_roads_surface: typeof enum_map_roads_surface;
|
||||
enum_map_roads_stroke_style: typeof enum_map_roads_stroke_style;
|
||||
enum_map_zones_type: typeof enum_map_zones_type;
|
||||
enum_narrative_events_triggers_type: typeof enum_narrative_events_triggers_type;
|
||||
enum_story_beats_status: typeof enum_story_beats_status;
|
||||
|
|
@ -8985,9 +8887,7 @@ type DatabaseSchema = {
|
|||
maps: typeof maps;
|
||||
map_roads: typeof map_roads;
|
||||
map_zones: typeof map_zones;
|
||||
map_zones_rels: typeof map_zones_rels;
|
||||
resource_nodes: typeof resource_nodes;
|
||||
resource_nodes_rels: typeof resource_nodes_rels;
|
||||
narrative_events: typeof narrative_events;
|
||||
story_beats: typeof story_beats;
|
||||
story_beat_states: typeof story_beat_states;
|
||||
|
|
@ -9132,9 +9032,7 @@ type DatabaseSchema = {
|
|||
relations_labels: typeof relations_labels;
|
||||
relations_maps: typeof relations_maps;
|
||||
relations_map_roads: typeof relations_map_roads;
|
||||
relations_map_zones_rels: typeof relations_map_zones_rels;
|
||||
relations_map_zones: typeof relations_map_zones;
|
||||
relations_resource_nodes_rels: typeof relations_resource_nodes_rels;
|
||||
relations_resource_nodes: typeof relations_resource_nodes;
|
||||
relations_narrative_events: typeof relations_narrative_events;
|
||||
relations_story_beats: typeof relations_story_beats;
|
||||
|
|
|
|||
|
|
@ -3791,7 +3791,7 @@ export interface MapRoad {
|
|||
strokeWeight?: number | null;
|
||||
strokeStyle?: ('solid' | 'dashed' | 'dotted') | null;
|
||||
/**
|
||||
* Auto (unset): only paved roads show labels. Check to force on, uncheck to force off. The map authoring toolbar's Label select writes this.
|
||||
* Renders the road's name along the line at zoom-dependent intervals.
|
||||
*/
|
||||
labelVisible?: boolean | null;
|
||||
/**
|
||||
|
|
@ -3825,9 +3825,9 @@ export interface MapZone {
|
|||
description?: string | null;
|
||||
map: number | Map;
|
||||
/**
|
||||
* Water zones block land-blueprint placement. Land zones are informational; default terrain is buildable land. Territory and Country Border are political boundaries (informational).
|
||||
* Water zones block land-blueprint placement. Land zones are informational; default terrain is buildable land.
|
||||
*/
|
||||
type: 'water' | 'land' | 'territory' | 'border';
|
||||
type: 'water' | 'land';
|
||||
/**
|
||||
* Polygon fill as hex (e.g. #38bdf8). Empty = default color for the type.
|
||||
*/
|
||||
|
|
@ -3837,7 +3837,7 @@ export interface MapZone {
|
|||
*/
|
||||
strokeColor?: string | null;
|
||||
/**
|
||||
* Fill opacity 0-1. Empty = default (0.25 water, 0.08 land, 0.1 territory, 0.06 border).
|
||||
* Fill opacity 0-1. Empty = default (0.25 water, 0.08 land).
|
||||
*/
|
||||
fillOpacity?: number | null;
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -14,20 +14,12 @@ import type {
|
|||
} from "@/payload-types";
|
||||
import { buildRoadGraph, findRoute, type RoadInput } from "@/lib/map/routing";
|
||||
import {
|
||||
labelScreenAngle,
|
||||
roadLabelShown,
|
||||
metersPerPx,
|
||||
polygonCentroid,
|
||||
roadLabelPoints,
|
||||
splinePolyline,
|
||||
validateHexColor,
|
||||
} from "@/lib/map/style";
|
||||
import {
|
||||
closestPointOnSegment,
|
||||
findSnap,
|
||||
segmentMidpoints,
|
||||
type SnapTarget,
|
||||
} from "@/lib/map/snapping";
|
||||
import {
|
||||
checkResourcesInRange,
|
||||
checkStructuresInRange,
|
||||
|
|
@ -513,28 +505,6 @@ describe("Game Map System", () => {
|
|||
expect(short).toEqual(raw.slice(0, 2));
|
||||
});
|
||||
|
||||
it("derives upright straight-label angles with vertical text reading up", () => {
|
||||
expect(labelScreenAngle([0, 0], [1000, 0])).toBe(0);
|
||||
expect(labelScreenAngle([0, 0], [-1000, 0])).toBe(0);
|
||||
expect(labelScreenAngle([0, 0], [0, 1000])).toBe(-90);
|
||||
expect(labelScreenAngle([0, 0], [0, -1000])).toBe(-90);
|
||||
expect(labelScreenAngle([0, 0], [1000, 1000])).toBe(45);
|
||||
expect(labelScreenAngle([0, 0], [-1000, 1000])).toBe(-45);
|
||||
});
|
||||
|
||||
it("defaults road labels to paved roads, minor roads at close zoom, with explicit override", () => {
|
||||
expect(roadLabelShown({ labelVisible: null, surface: "paved" }, 32)).toBe(true);
|
||||
expect(roadLabelShown({ labelVisible: null, surface: "dirt" }, 32)).toBe(false);
|
||||
expect(roadLabelShown({ labelVisible: null, surface: "trail" }, 32)).toBe(false);
|
||||
expect(roadLabelShown({ labelVisible: null, surface: "dirt" }, 4)).toBe(true);
|
||||
expect(roadLabelShown({ labelVisible: null, surface: "dirt" }, 3.9)).toBe(true);
|
||||
expect(roadLabelShown({ labelVisible: null, surface: "trail" }, 4)).toBe(true);
|
||||
expect(roadLabelShown({ labelVisible: true, surface: "dirt" }, 32)).toBe(true);
|
||||
expect(roadLabelShown({ labelVisible: false, surface: "paved" }, 4)).toBe(false);
|
||||
expect(roadLabelShown({ labelVisible: null, surface: "paved" })).toBe(true);
|
||||
expect(roadLabelShown({ labelVisible: null, surface: "dirt" })).toBe(false);
|
||||
});
|
||||
|
||||
it("computes polygon centroids inside the ring", () => {
|
||||
const centroid = polygonCentroid([
|
||||
[0, 0],
|
||||
|
|
@ -553,82 +523,6 @@ describe("Game Map System", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("authoring snapping (pure)", () => {
|
||||
it("projects to segments and clamps to endpoints", () => {
|
||||
expect(closestPointOnSegment([500, 500], [0, 0], [1000, 0])).toEqual([500, 0]);
|
||||
expect(closestPointOnSegment([-50, -50], [0, 0], [1000, 0])).toEqual([0, 0]);
|
||||
expect(closestPointOnSegment([2000, 50], [0, 0], [1000, 0])).toEqual([1000, 0]);
|
||||
expect(closestPointOnSegment([10, 10], [10, 10], [10, 10])).toEqual([10, 10]);
|
||||
});
|
||||
|
||||
it("snaps to the nearest vertex, preferring vertices over edges on ties", () => {
|
||||
const zone: SnapTarget = {
|
||||
key: "zone-1",
|
||||
vertices: [
|
||||
[0, 0],
|
||||
[1000, 0],
|
||||
[1000, 1000],
|
||||
[0, 1000],
|
||||
],
|
||||
closed: true,
|
||||
edges: true,
|
||||
};
|
||||
const corner = findSnap([30, 20], [zone], 100);
|
||||
expect(corner?.kind).toBe("vertex");
|
||||
expect(corner?.point).toEqual([0, 0]);
|
||||
|
||||
// On the middle of an edge (>= 10% closer than any vertex) the edge wins.
|
||||
const edge = findSnap([500, 30], [zone], 100);
|
||||
expect(edge?.kind).toBe("edge");
|
||||
expect(edge?.point).toEqual([500, 0]);
|
||||
});
|
||||
|
||||
it("includes the closing segment of closed targets and respects the radius", () => {
|
||||
const zone: SnapTarget = {
|
||||
key: "zone-1",
|
||||
vertices: [
|
||||
[0, 0],
|
||||
[1000, 0],
|
||||
[1000, 1000],
|
||||
[0, 1000],
|
||||
],
|
||||
closed: true,
|
||||
edges: true,
|
||||
};
|
||||
const closing = findSnap([30, 500], [zone], 100);
|
||||
expect(closing?.kind).toBe("edge");
|
||||
expect(closing?.point).toEqual([0, 500]);
|
||||
|
||||
expect(findSnap([30, 500], [zone], 10)).toBeNull();
|
||||
});
|
||||
|
||||
it("builds segment midpoints with insert indices for open and closed drafts", () => {
|
||||
const open = segmentMidpoints(
|
||||
[
|
||||
[0, 0],
|
||||
[100, 0],
|
||||
[100, 100],
|
||||
],
|
||||
false,
|
||||
);
|
||||
expect(open).toEqual([
|
||||
{ index: 1, point: [50, 0] },
|
||||
{ index: 2, point: [100, 50] },
|
||||
]);
|
||||
|
||||
const closed = segmentMidpoints(
|
||||
[
|
||||
[0, 0],
|
||||
[100, 0],
|
||||
[100, 100],
|
||||
],
|
||||
true,
|
||||
);
|
||||
expect(closed).toHaveLength(3);
|
||||
expect(closed[2]).toEqual({ index: 3, point: [50, 50] });
|
||||
});
|
||||
});
|
||||
|
||||
describe("road routing (pure)", () => {
|
||||
it("connects roads that share a vertex", () => {
|
||||
const roads: RoadInput[] = [
|
||||
|
|
@ -1189,98 +1083,6 @@ describe("Game Map System", () => {
|
|||
expect(outside.success).toBe(false);
|
||||
expect(outside.error).toContain("resource node");
|
||||
});
|
||||
|
||||
it("accepts political-boundary zone types and leaves placement unaffected", async () => {
|
||||
const territory = await payload.create({
|
||||
collection: "map-zones",
|
||||
data: {
|
||||
map: mapA.id,
|
||||
name: `${RUN} Territory`,
|
||||
type: "territory",
|
||||
points: [
|
||||
[0, 0],
|
||||
[1000, 0],
|
||||
[1000, 1000],
|
||||
[0, 1000],
|
||||
],
|
||||
},
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
});
|
||||
createdIds.zones.push(territory.id);
|
||||
expect(territory.type).toBe("territory");
|
||||
|
||||
const border = await payload.create({
|
||||
collection: "map-zones",
|
||||
data: {
|
||||
map: mapA.id,
|
||||
name: `${RUN} Border`,
|
||||
type: "border",
|
||||
points: [
|
||||
[4000, 6000],
|
||||
[5000, 6000],
|
||||
[5000, 7000],
|
||||
[4000, 7000],
|
||||
],
|
||||
},
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
});
|
||||
createdIds.zones.push(border.id);
|
||||
expect(border.type).toBe("border");
|
||||
|
||||
// Political zones are informational: a land blueprint inside one places
|
||||
// normally (only water zones gate placement).
|
||||
authSpy.mockResolvedValue({ user: logisticsUser });
|
||||
const inside = await placeStructure({
|
||||
mapId: mapA.id,
|
||||
blueprintId: depotBlueprint.id,
|
||||
name: `${RUN} Territory Site`,
|
||||
x: 500,
|
||||
y: 500,
|
||||
});
|
||||
expect(inside.success).toBe(true);
|
||||
createdIds.gameStructures.push((inside.data as GameStructure).id);
|
||||
});
|
||||
|
||||
it("stores road labels as tri-state with paved-only auto default", async () => {
|
||||
const dirt = await payload.create({
|
||||
collection: "map-roads",
|
||||
data: {
|
||||
map: mapA.id,
|
||||
name: `${RUN} Dirt Track`,
|
||||
surface: "dirt",
|
||||
speedMultiplier: 1,
|
||||
points: [
|
||||
[0, 9000],
|
||||
[1000, 9000],
|
||||
],
|
||||
},
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
});
|
||||
createdIds.roads.push(dirt.id);
|
||||
expect(dirt.labelVisible).toBeNull();
|
||||
|
||||
const paved = await payload.create({
|
||||
collection: "map-roads",
|
||||
data: {
|
||||
map: mapA.id,
|
||||
name: `${RUN} Paved Way`,
|
||||
surface: "paved",
|
||||
speedMultiplier: 1,
|
||||
labelVisible: false,
|
||||
points: [
|
||||
[0, 9500],
|
||||
[1000, 9500],
|
||||
],
|
||||
},
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
});
|
||||
createdIds.roads.push(paved.id);
|
||||
expect(paved.labelVisible).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("construction timestamps", () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue