feat(ui): searchable combobox across data-driven dropdowns
- new ui/combobox primitive (popover plus cmdk): search input, scrolling list, trigger-width popover, per-option disabled and hint lines - converts every data-driven single-value select site-wide (blueprints, factions, accounts, assignments, NPCs, structures, locker items, seats, missions, ranks, resources, mod lists, event targets, sprints) - long multi-select checkbox menus (event log types, kanban labels) gain search inputs following the resource picker pattern - static short enum selects stay as plain selects
This commit is contained in:
parent
2bc61fe211
commit
947a5c1bbf
21 changed files with 651 additions and 597 deletions
|
|
@ -5,6 +5,7 @@ import { useRouter } from "next/navigation";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { ArrowLeftRightIcon, Loader2Icon } from "lucide-react";
|
import { ArrowLeftRightIcon, Loader2Icon } from "lucide-react";
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
|
import { Combobox } from "@/components/ui/combobox";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
|
|
@ -16,7 +17,14 @@ import {
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { Label } from "@/components/ui/label";
|
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 { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { requestTransfer } from "@/app/(frontend)/transfers/actions";
|
import { requestTransfer } from "@/app/(frontend)/transfers/actions";
|
||||||
|
|
@ -199,22 +207,20 @@ export function RequestTransferDialog({
|
||||||
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
|
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label htmlFor="transfer-source">Current assignment</Label>
|
<Label htmlFor="transfer-source">Current assignment</Label>
|
||||||
<Select value={sourceId} onValueChange={setSourceId} disabled={pending}>
|
<Combobox
|
||||||
<SelectTrigger id="transfer-source">
|
id="transfer-source"
|
||||||
<SelectValue placeholder="Select current assignment" />
|
value={sourceId}
|
||||||
</SelectTrigger>
|
onValueChange={setSourceId}
|
||||||
<SelectContent>
|
disabled={pending}
|
||||||
<SelectGroup>
|
placeholder="Select current assignment"
|
||||||
{requestableAssignments.map((assignment) => (
|
searchPlaceholder="Search assignments..."
|
||||||
<SelectItem key={assignment.id} value={String(assignment.id)}>
|
options={requestableAssignments.map((assignment) => ({
|
||||||
{assignment.callsign
|
value: String(assignment.id),
|
||||||
? `${assignment.name} · ${assignment.callsign}`
|
label: assignment.callsign
|
||||||
: assignment.name}
|
? `${assignment.name} · ${assignment.callsign}`
|
||||||
</SelectItem>
|
: assignment.name,
|
||||||
))}
|
}))}
|
||||||
</SelectGroup>
|
/>
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-1.5" aria-busy={loadingDestinations}>
|
<div className="flex flex-col gap-1.5" aria-busy={loadingDestinations}>
|
||||||
|
|
@ -222,26 +228,20 @@ export function RequestTransferDialog({
|
||||||
{loadingDestinations ? (
|
{loadingDestinations ? (
|
||||||
<Skeleton className="h-9 w-full" />
|
<Skeleton className="h-9 w-full" />
|
||||||
) : destinations.length > 0 ? (
|
) : destinations.length > 0 ? (
|
||||||
<Select
|
<Combobox
|
||||||
|
id="transfer-destination"
|
||||||
value={destinationId}
|
value={destinationId}
|
||||||
onValueChange={setDestinationId}
|
onValueChange={setDestinationId}
|
||||||
disabled={pending}
|
disabled={pending}
|
||||||
>
|
placeholder="Select destination assignment"
|
||||||
<SelectTrigger id="transfer-destination">
|
searchPlaceholder="Search assignments..."
|
||||||
<SelectValue placeholder="Select destination assignment" />
|
options={destinations.map((assignment) => ({
|
||||||
</SelectTrigger>
|
value: String(assignment.id),
|
||||||
<SelectContent>
|
label: assignment.callsign
|
||||||
<SelectGroup>
|
? `${assignment.name} · ${assignment.callsign}`
|
||||||
{destinations.map((assignment) => (
|
: assignment.name,
|
||||||
<SelectItem key={assignment.id} value={String(assignment.id)}>
|
}))}
|
||||||
{assignment.callsign
|
/>
|
||||||
? `${assignment.name} · ${assignment.callsign}`
|
|
||||||
: assignment.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectGroup>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
) : (
|
) : (
|
||||||
<Alert>
|
<Alert>
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
|
|
@ -271,10 +271,18 @@ export function RequestTransferDialog({
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button type="button" variant="outline" onClick={() => setInternalOpen(false)} disabled={pending}>
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setInternalOpen(false)}
|
||||||
|
disabled={pending}
|
||||||
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</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 && <Loader2Icon data-icon="inline-start" className="animate-spin" />}
|
||||||
{pending ? "Submitting..." : "Submit request"}
|
{pending ? "Submitting..." : "Submit request"}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { useRouter } from "next/navigation";
|
||||||
import { ArrowDownLeftIcon, ArrowRightLeftIcon, ArrowUpRightIcon } from "lucide-react";
|
import { ArrowDownLeftIcon, ArrowRightLeftIcon, ArrowUpRightIcon } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Combobox } from "@/components/ui/combobox";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import {
|
import {
|
||||||
|
|
@ -164,18 +165,17 @@ export function BankTransactionDialog({
|
||||||
{mode === "transfer" && (
|
{mode === "transfer" && (
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label>Destination account</Label>
|
<Label>Destination account</Label>
|
||||||
<Select value={toId} onValueChange={setToId}>
|
<Combobox
|
||||||
<SelectTrigger>
|
value={toId}
|
||||||
<SelectValue placeholder="Select account" />
|
onValueChange={setToId}
|
||||||
</SelectTrigger>
|
placeholder="Select account"
|
||||||
<SelectContent>
|
searchPlaceholder="Search accounts..."
|
||||||
{otherAccounts.map((a) => (
|
emptyMessage="No other accounts."
|
||||||
<SelectItem key={a.id} value={String(a.id)}>
|
options={otherAccounts.map((a) => ({
|
||||||
{a.name}
|
value: String(a.id),
|
||||||
</SelectItem>
|
label: a.name,
|
||||||
))}
|
}))}
|
||||||
</SelectContent>
|
/>
|
||||||
</Select>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { useRouter } from "next/navigation";
|
||||||
import { PlusIcon } from "lucide-react";
|
import { PlusIcon } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Combobox } from "@/components/ui/combobox";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
|
|
@ -117,18 +118,16 @@ export function CreateAccountDialog({
|
||||||
{accountType === "faction" && (
|
{accountType === "faction" && (
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label>Owner faction</Label>
|
<Label>Owner faction</Label>
|
||||||
<Select value={ownerFactionId} onValueChange={setOwnerFactionId}>
|
<Combobox
|
||||||
<SelectTrigger>
|
value={ownerFactionId}
|
||||||
<SelectValue placeholder="Select faction" />
|
onValueChange={setOwnerFactionId}
|
||||||
</SelectTrigger>
|
placeholder="Select faction"
|
||||||
<SelectContent>
|
searchPlaceholder="Search factions..."
|
||||||
{factions.map((f) => (
|
options={factions.map((f) => ({
|
||||||
<SelectItem key={f.id} value={String(f.id)}>
|
value: String(f.id),
|
||||||
{f.name}
|
label: f.name,
|
||||||
</SelectItem>
|
}))}
|
||||||
))}
|
/>
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{accountType === "personal" && !isManager && (
|
{accountType === "personal" && !isManager && (
|
||||||
|
|
|
||||||
|
|
@ -15,15 +15,8 @@ import {
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Combobox } from "@/components/ui/combobox";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectGroup,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import type { LaborClassificationOption, StructureStaffingOption } from "@/lib/base";
|
import type { LaborClassificationOption, StructureStaffingOption } from "@/lib/base";
|
||||||
import { currencyLabel, type CurrencyConfig } from "@/lib/banking/format";
|
import { currencyLabel, type CurrencyConfig } from "@/lib/banking/format";
|
||||||
|
|
@ -353,61 +346,51 @@ export function HireStaffDialog({
|
||||||
{fixedNpc?.name ?? `NPC #${fixedNpcId}`}
|
{fixedNpc?.name ?? `NPC #${fixedNpcId}`}
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<Select value={npcId} onValueChange={setNpcId}>
|
<Combobox
|
||||||
<SelectTrigger>
|
value={npcId}
|
||||||
<SelectValue placeholder="Select an NPC" />
|
onValueChange={setNpcId}
|
||||||
</SelectTrigger>
|
placeholder="Select an NPC"
|
||||||
<SelectContent>
|
searchPlaceholder="Search NPCs..."
|
||||||
<SelectGroup>
|
emptyMessage="No candidates available."
|
||||||
{npcs.map((npc) => (
|
options={npcs.map((npc) => ({
|
||||||
<SelectItem key={npc.id} value={String(npc.id)}>
|
value: String(npc.id),
|
||||||
{npc.name}
|
label: npc.name,
|
||||||
</SelectItem>
|
}))}
|
||||||
))}
|
/>
|
||||||
</SelectGroup>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label>Structure</Label>
|
<Label>Structure</Label>
|
||||||
<Select value={structureId} onValueChange={handleStructureChange}>
|
<Combobox
|
||||||
<SelectTrigger>
|
value={structureId}
|
||||||
<SelectValue placeholder="Select a structure" />
|
onValueChange={handleStructureChange}
|
||||||
</SelectTrigger>
|
placeholder="Select a structure"
|
||||||
<SelectContent>
|
searchPlaceholder="Search structures..."
|
||||||
<SelectGroup>
|
options={structures.map((structure) => ({
|
||||||
{structures.map((structure) => (
|
value: String(structure.id),
|
||||||
<SelectItem key={structure.id} value={String(structure.id)}>
|
label: structure.name,
|
||||||
{structure.name}
|
}))}
|
||||||
</SelectItem>
|
/>
|
||||||
))}
|
|
||||||
</SelectGroup>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label>Position type</Label>
|
<Label>Position type</Label>
|
||||||
<Select value={positionType} onValueChange={handlePositionTypeChange}>
|
<Combobox
|
||||||
<SelectTrigger>
|
value={positionType}
|
||||||
<SelectValue />
|
onValueChange={handlePositionTypeChange}
|
||||||
</SelectTrigger>
|
searchPlaceholder="Search positions..."
|
||||||
<SelectContent>
|
options={[
|
||||||
<SelectGroup>
|
...(fixedNpcId == null && approvedClassifications.length > 0
|
||||||
{fixedNpcId == null && approvedClassifications.length > 0 && (
|
? [{ value: "labor", label: "Labor" }]
|
||||||
<SelectItem value="labor">Labor</SelectItem>
|
: []),
|
||||||
)}
|
...approvedPositionTypes.map((position) => ({
|
||||||
{approvedPositionTypes.map((position) => (
|
value: position.value,
|
||||||
<SelectItem key={position.value} value={position.value}>
|
label: position.label,
|
||||||
{position.label}
|
})),
|
||||||
</SelectItem>
|
]}
|
||||||
))}
|
/>
|
||||||
</SelectGroup>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
{selectedStructure &&
|
{selectedStructure &&
|
||||||
approvedClassifications.length === 0 &&
|
approvedClassifications.length === 0 &&
|
||||||
approvedPositionTypes.length === 0 && (
|
approvedPositionTypes.length === 0 && (
|
||||||
|
|
@ -421,20 +404,15 @@ export function HireStaffDialog({
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label>Classification</Label>
|
<Label>Classification</Label>
|
||||||
<Select value={laborClassification} onValueChange={handleClassificationChange}>
|
<Combobox
|
||||||
<SelectTrigger>
|
value={laborClassification}
|
||||||
<SelectValue />
|
onValueChange={handleClassificationChange}
|
||||||
</SelectTrigger>
|
searchPlaceholder="Search classifications..."
|
||||||
<SelectContent>
|
options={approvedClassifications.map((classification) => ({
|
||||||
<SelectGroup>
|
value: String(classification.id),
|
||||||
{approvedClassifications.map((classification) => (
|
label: classification.name,
|
||||||
<SelectItem key={classification.id} value={String(classification.id)}>
|
}))}
|
||||||
{classification.name}
|
/>
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectGroup>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
{selectedStructure && approvedClassifications.length === 0 && (
|
{selectedStructure && approvedClassifications.length === 0 && (
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
This structure has no approved labor positions.
|
This structure has no approved labor positions.
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import * as React from "react";
|
||||||
import { ChevronLeft, ChevronRight, RotateCcw, Search } from "lucide-react";
|
import { ChevronLeft, ChevronRight, RotateCcw, Search } from "lucide-react";
|
||||||
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Combobox } from "@/components/ui/combobox";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import {
|
import {
|
||||||
|
|
@ -305,19 +306,20 @@ export default function EventLogTable() {
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|
||||||
<Select value={targetCollection} onValueChange={setTargetCollection}>
|
<Combobox
|
||||||
<SelectTrigger className="h-8 w-44">
|
className="h-8 w-44"
|
||||||
<SelectValue placeholder="Target" />
|
value={targetCollection}
|
||||||
</SelectTrigger>
|
onValueChange={setTargetCollection}
|
||||||
<SelectContent className="max-h-72">
|
placeholder="Target"
|
||||||
<SelectItem value="all">All targets</SelectItem>
|
searchPlaceholder="Search targets..."
|
||||||
{TARGET_COLLECTION_OPTIONS.map(([value, label]) => (
|
options={[
|
||||||
<SelectItem key={value} value={value}>
|
{ value: "all", label: "All targets" },
|
||||||
{label}
|
...TARGET_COLLECTION_OPTIONS.map(([value, label]) => ({
|
||||||
</SelectItem>
|
value,
|
||||||
))}
|
label,
|
||||||
</SelectContent>
|
})),
|
||||||
</Select>
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
<Select value={source} onValueChange={(v) => setSource(v as SourceFilter)}>
|
<Select value={source} onValueChange={(v) => setSource(v as SourceFilter)}>
|
||||||
<SelectTrigger className="h-8 w-36">
|
<SelectTrigger className="h-8 w-36">
|
||||||
|
|
@ -442,10 +444,7 @@ export default function EventLogTable() {
|
||||||
Showing {from.toLocaleString()}-{to.toLocaleString()} of {totalDocs.toLocaleString()}
|
Showing {from.toLocaleString()}-{to.toLocaleString()} of {totalDocs.toLocaleString()}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Select
|
<Select value={String(pageSize)} onValueChange={(v) => setPageSize(Number(v))}>
|
||||||
value={String(pageSize)}
|
|
||||||
onValueChange={(v) => setPageSize(Number(v))}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="h-8 w-28">
|
<SelectTrigger className="h-8 w-28">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { useRouter } from "next/navigation";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import type { Ticket, User } from "@/payload-types";
|
import type { Ticket, User } from "@/payload-types";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Combobox } from "@/components/ui/combobox";
|
||||||
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
|
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import {
|
import {
|
||||||
|
|
@ -264,23 +265,21 @@ export function TicketDetail({
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label htmlFor="ticket-assignee">Assignee</Label>
|
<Label htmlFor="ticket-assignee">Assignee</Label>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Select
|
<Combobox
|
||||||
|
id="ticket-assignee"
|
||||||
|
className="min-w-0 flex-1"
|
||||||
value={assignee}
|
value={assignee}
|
||||||
onValueChange={setAssignee}
|
onValueChange={setAssignee}
|
||||||
disabled={controlBusy !== null}
|
disabled={controlBusy !== null}
|
||||||
>
|
searchPlaceholder="Search members..."
|
||||||
<SelectTrigger id="ticket-assignee" className="min-w-0 flex-1">
|
options={[
|
||||||
<SelectValue />
|
{ value: "unassigned", label: "Unassigned" },
|
||||||
</SelectTrigger>
|
...assigneeOptions.map((option) => ({
|
||||||
<SelectContent>
|
value: String(option.id),
|
||||||
<SelectItem value="unassigned">Unassigned</SelectItem>
|
label: option.label,
|
||||||
{assigneeOptions.map((option) => (
|
})),
|
||||||
<SelectItem key={option.id} value={String(option.id)}>
|
]}
|
||||||
{option.label}
|
/>
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => void applyControl("assignee")}
|
onClick={() => void applyControl("assignee")}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import {
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
|
import { Combobox } from "@/components/ui/combobox";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
|
@ -21,11 +22,7 @@ import {
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import {
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||||
Collapsible,
|
|
||||||
CollapsibleContent,
|
|
||||||
CollapsibleTrigger,
|
|
||||||
} from "@/components/ui/collapsible";
|
|
||||||
import {
|
import {
|
||||||
createFridaySideOp,
|
createFridaySideOp,
|
||||||
updateFridaySideOp,
|
updateFridaySideOp,
|
||||||
|
|
@ -238,7 +235,9 @@ export function CreateSideOpDialog({
|
||||||
}
|
}
|
||||||
|
|
||||||
function modsInput() {
|
function modsInput() {
|
||||||
return requiredModsId === "none" ? { requiredModsId: null } : { requiredModsId: Number(requiredModsId) };
|
return requiredModsId === "none"
|
||||||
|
? { requiredModsId: null }
|
||||||
|
: { requiredModsId: Number(requiredModsId) };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleModlistUpload(file: File) {
|
async function handleModlistUpload(file: File) {
|
||||||
|
|
@ -344,7 +343,8 @@ export function CreateSideOpDialog({
|
||||||
estimatedDuration: Number(estimatedDuration) || 240,
|
estimatedDuration: Number(estimatedDuration) || 240,
|
||||||
maxPlayers: Number(maxPlayers) || 32,
|
maxPlayers: Number(maxPlayers) || 32,
|
||||||
...modsInput(),
|
...modsInput(),
|
||||||
campaignId: campaignChoice === "new" || campaignChoice === "none" ? null : Number(campaignChoice),
|
campaignId:
|
||||||
|
campaignChoice === "new" || campaignChoice === "none" ? null : Number(campaignChoice),
|
||||||
newCampaignName: campaignChoice === "new" ? newCampaignName || null : null,
|
newCampaignName: campaignChoice === "new" ? newCampaignName || null : null,
|
||||||
noCampaign: campaignChoice === "none",
|
noCampaign: campaignChoice === "none",
|
||||||
...narrativeInput(),
|
...narrativeInput(),
|
||||||
|
|
@ -430,25 +430,20 @@ export function CreateSideOpDialog({
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div className="grid gap-1.5">
|
<div className="grid gap-1.5">
|
||||||
<Label>Map</Label>
|
<Label>Map</Label>
|
||||||
<Select
|
<Combobox
|
||||||
|
className="w-full"
|
||||||
value={mapChoice}
|
value={mapChoice}
|
||||||
onValueChange={(v) => {
|
onValueChange={(v) => {
|
||||||
setMapChoice(v);
|
setMapChoice(v);
|
||||||
if (v !== "custom" && v !== "") setMapId(v);
|
if (v !== "custom" && v !== "") setMapId(v);
|
||||||
}}
|
}}
|
||||||
>
|
placeholder="Choose a map"
|
||||||
<SelectTrigger className="w-full">
|
searchPlaceholder="Search maps..."
|
||||||
<SelectValue placeholder="Choose a map" />
|
options={[
|
||||||
</SelectTrigger>
|
...maps.map((m) => ({ value: String(m.id), label: m.name })),
|
||||||
<SelectContent>
|
{ value: "custom", label: "+ Custom map…" },
|
||||||
{maps.map((m) => (
|
]}
|
||||||
<SelectItem key={m.id} value={String(m.id)}>
|
/>
|
||||||
{m.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
<SelectItem value="custom">+ Custom map…</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-1.5">
|
<div className="grid gap-1.5">
|
||||||
<Label>Mission type</Label>
|
<Label>Mission type</Label>
|
||||||
|
|
@ -490,20 +485,22 @@ export function CreateSideOpDialog({
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-1.5">
|
<div className="grid gap-1.5">
|
||||||
<Label>Campaign</Label>
|
<Label>Campaign</Label>
|
||||||
<Select value={campaignChoice} onValueChange={setCampaignChoice}>
|
<Combobox
|
||||||
<SelectTrigger className="w-full">
|
className="w-full"
|
||||||
<SelectValue placeholder="Choose a campaign" />
|
value={campaignChoice}
|
||||||
</SelectTrigger>
|
onValueChange={setCampaignChoice}
|
||||||
<SelectContent>
|
placeholder="Choose a campaign"
|
||||||
<SelectItem value="none">No campaign (one-off op)</SelectItem>
|
searchPlaceholder="Search campaigns..."
|
||||||
{campaigns.map((c) => (
|
options={[
|
||||||
<SelectItem key={c.id} value={String(c.id)} disabled={c.disabled}>
|
{ value: "none", label: "No campaign (one-off op)" },
|
||||||
{c.name}
|
...campaigns.map((c) => ({
|
||||||
</SelectItem>
|
value: String(c.id),
|
||||||
))}
|
label: c.name,
|
||||||
<SelectItem value="new">+ New campaign…</SelectItem>
|
disabled: c.disabled,
|
||||||
</SelectContent>
|
})),
|
||||||
</Select>
|
{ value: "new", label: "+ New campaign…" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{mapChoice === "custom" && (
|
{mapChoice === "custom" && (
|
||||||
|
|
@ -531,19 +528,17 @@ export function CreateSideOpDialog({
|
||||||
|
|
||||||
<div className="grid gap-1.5">
|
<div className="grid gap-1.5">
|
||||||
<Label>Mod list</Label>
|
<Label>Mod list</Label>
|
||||||
<Select value={requiredModsId} onValueChange={setRequiredModsId}>
|
<Combobox
|
||||||
<SelectTrigger className="w-full">
|
className="w-full"
|
||||||
<SelectValue placeholder="No mod list (vanilla)" />
|
value={requiredModsId}
|
||||||
</SelectTrigger>
|
onValueChange={setRequiredModsId}
|
||||||
<SelectContent>
|
placeholder="No mod list (vanilla)"
|
||||||
<SelectItem value="none">No mod list (vanilla)</SelectItem>
|
searchPlaceholder="Search mod lists..."
|
||||||
{modLists.map((l) => (
|
options={[
|
||||||
<SelectItem key={l.id} value={String(l.id)}>
|
{ value: "none", label: "No mod list (vanilla)" },
|
||||||
{l.name}
|
...modLists.map((l) => ({ value: String(l.id), label: l.name })),
|
||||||
</SelectItem>
|
]}
|
||||||
))}
|
/>
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<Input
|
<Input
|
||||||
type="file"
|
type="file"
|
||||||
accept=".html,.htm,.txt,text/html,text/plain"
|
accept=".html,.htm,.txt,text/html,text/plain"
|
||||||
|
|
@ -555,8 +550,8 @@ export function CreateSideOpDialog({
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Upload the mod list file exported from the Arma 3 launcher (.html) or a .txt mod
|
Upload the mod list file exported from the Arma 3 launcher (.html) or a .txt mod list,
|
||||||
list, or pick an existing one.
|
or pick an existing one.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,15 +5,9 @@ import { useRouter } from "next/navigation";
|
||||||
import { PlusIcon } from "lucide-react";
|
import { PlusIcon } from "lucide-react";
|
||||||
import type { Asset } from "@/payload-types";
|
import type { Asset } from "@/payload-types";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Combobox } from "@/components/ui/combobox";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
|
|
@ -86,18 +80,17 @@ export function AddLockerItemDialog({ assets }: AddLockerItemDialogProps) {
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label>Asset</Label>
|
<Label>Asset</Label>
|
||||||
<Select value={assetId} onValueChange={setAssetId}>
|
<Combobox
|
||||||
<SelectTrigger>
|
value={assetId}
|
||||||
<SelectValue placeholder="Select an asset" />
|
onValueChange={setAssetId}
|
||||||
</SelectTrigger>
|
placeholder="Select an asset"
|
||||||
<SelectContent>
|
searchPlaceholder="Search assets..."
|
||||||
{assets.map((a) => (
|
emptyMessage="No assets in your locker."
|
||||||
<SelectItem key={a.id} value={String(a.id)}>
|
options={assets.map((a) => ({
|
||||||
{a.name} · {getAssetTypeLabel(a.assetType)}
|
value: String(a.id),
|
||||||
</SelectItem>
|
label: `${a.name} · ${getAssetTypeLabel(a.assetType)}`,
|
||||||
))}
|
}))}
|
||||||
</SelectContent>
|
/>
|
||||||
</Select>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label>Quantity</Label>
|
<Label>Quantity</Label>
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { useEffect, useMemo, useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import type { Asset } from "@/payload-types";
|
import type { Asset } from "@/payload-types";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Combobox } from "@/components/ui/combobox";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import {
|
import {
|
||||||
|
|
@ -14,13 +15,6 @@ import {
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
import {
|
import {
|
||||||
getAssetTypeLabel,
|
getAssetTypeLabel,
|
||||||
getAttachmentOptionsForItem,
|
getAttachmentOptionsForItem,
|
||||||
|
|
@ -202,30 +196,24 @@ export function EquipmentEditorDialog({
|
||||||
className="grid grid-cols-[100px_1fr] items-center gap-2"
|
className="grid grid-cols-[100px_1fr] items-center gap-2"
|
||||||
>
|
>
|
||||||
<span className="text-xs text-muted-foreground">{category.label}</span>
|
<span className="text-xs text-muted-foreground">{category.label}</span>
|
||||||
<Select
|
<Combobox
|
||||||
value={installed ? String(installed.id) : "none"}
|
value={installed ? String(installed.id) : "none"}
|
||||||
onValueChange={(v) => handleCategoryPick(category, v)}
|
onValueChange={(v) => handleCategoryPick(category, v)}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
>
|
placeholder={
|
||||||
<SelectTrigger>
|
categoryOptions.length === 0
|
||||||
<SelectValue
|
? "No compatible items"
|
||||||
placeholder={
|
: "Choose an attachment"
|
||||||
categoryOptions.length === 0
|
}
|
||||||
? "No compatible items"
|
searchPlaceholder="Search attachments..."
|
||||||
: "Choose an attachment"
|
options={[
|
||||||
}
|
{ value: "none", label: "None" },
|
||||||
/>
|
...categoryOptions.map((o) => ({
|
||||||
</SelectTrigger>
|
value: String(o.assetId),
|
||||||
<SelectContent>
|
label: `${o.asset.name} · ${o.quantity > 0 ? `${o.quantity.toLocaleString()}×` : "installed"}`,
|
||||||
<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>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
@ -240,28 +228,22 @@ export function EquipmentEditorDialog({
|
||||||
<Label className="text-sm font-semibold">Skin</Label>
|
<Label className="text-sm font-semibold">Skin</Label>
|
||||||
<div className="grid grid-cols-[100px_1fr] items-center gap-2">
|
<div className="grid grid-cols-[100px_1fr] items-center gap-2">
|
||||||
<span className="text-xs text-muted-foreground">Applied</span>
|
<span className="text-xs text-muted-foreground">Applied</span>
|
||||||
<Select
|
<Combobox
|
||||||
value={installedSkin ? String(installedSkin.id) : "none"}
|
value={installedSkin ? String(installedSkin.id) : "none"}
|
||||||
onValueChange={handleSkinPick}
|
onValueChange={handleSkinPick}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
>
|
placeholder={
|
||||||
<SelectTrigger>
|
mergedSkinOptions.length === 0 ? "No compatible skins" : "Choose a skin"
|
||||||
<SelectValue
|
}
|
||||||
placeholder={
|
searchPlaceholder="Search skins..."
|
||||||
mergedSkinOptions.length === 0 ? "No compatible skins" : "Choose a skin"
|
options={[
|
||||||
}
|
{ value: "none", label: "None" },
|
||||||
/>
|
...mergedSkinOptions.map((o) => ({
|
||||||
</SelectTrigger>
|
value: String(o.assetId),
|
||||||
<SelectContent>
|
label: `${o.asset.name} · ${o.quantity > 0 ? `${o.quantity.toLocaleString()}×` : "applied"}`,
|
||||||
<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>
|
</div>
|
||||||
{installedSkin && (
|
{installedSkin && (
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { useEffect, useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import type { Loadout } from "@/payload-types";
|
import type { Loadout } from "@/payload-types";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Combobox } from "@/components/ui/combobox";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
|
@ -160,24 +161,21 @@ export function LoadoutEditor({
|
||||||
return (
|
return (
|
||||||
<div key={slot.name} className="flex flex-col gap-1.5">
|
<div key={slot.name} className="flex flex-col gap-1.5">
|
||||||
<Label>{slot.label}</Label>
|
<Label>{slot.label}</Label>
|
||||||
<Select
|
<Combobox
|
||||||
value={slots[slot.name] ?? ""}
|
value={slots[slot.name] ?? ""}
|
||||||
onValueChange={(v) =>
|
onValueChange={(v) =>
|
||||||
setSlots((prev) => ({ ...prev, [slot.name]: v === "none" ? "" : v }))
|
setSlots((prev) => ({ ...prev, [slot.name]: v === "none" ? "" : v }))
|
||||||
}
|
}
|
||||||
>
|
placeholder={slot.description}
|
||||||
<SelectTrigger>
|
searchPlaceholder="Search items..."
|
||||||
<SelectValue placeholder={slot.description} />
|
options={[
|
||||||
</SelectTrigger>
|
{ value: "none", label: "Empty" },
|
||||||
<SelectContent>
|
...options.map((item) => ({
|
||||||
<SelectItem value="none">Empty</SelectItem>
|
value: String(item.assetId),
|
||||||
{options.map((item) => (
|
label: item.asset.name,
|
||||||
<SelectItem key={item.assetId} value={String(item.assetId)}>
|
})),
|
||||||
{item.asset.name}
|
]}
|
||||||
</SelectItem>
|
/>
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
{options.length === 0 && (
|
{options.length === 0 && (
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
No matching assets in your locker.
|
No matching assets in your locker.
|
||||||
|
|
|
||||||
|
|
@ -4,13 +4,7 @@ import { useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import type { Loadout } from "@/payload-types";
|
import type { Loadout } from "@/payload-types";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import { Combobox } from "@/components/ui/combobox";
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
import { setLoadoutSlot, equipLoadout } from "@/app/(frontend)/locker/actions";
|
import { setLoadoutSlot, equipLoadout } from "@/app/(frontend)/locker/actions";
|
||||||
import {
|
import {
|
||||||
getLoadoutSlot,
|
getLoadoutSlot,
|
||||||
|
|
@ -77,22 +71,18 @@ export function LockerCharacterPanel({ loadouts, items }: LockerCharacterPanelPr
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-b border-border/50 p-2">
|
<div className="border-b border-border/50 p-2">
|
||||||
<Select
|
<Combobox
|
||||||
|
className="h-8 rounded-sm border-border/60 bg-background/40 text-xs uppercase tracking-wide"
|
||||||
value={equippedLoadout ? String(equippedLoadout.id) : ""}
|
value={equippedLoadout ? String(equippedLoadout.id) : ""}
|
||||||
onValueChange={changeLoadout}
|
onValueChange={changeLoadout}
|
||||||
disabled={busy || loadouts.length === 0}
|
disabled={busy || loadouts.length === 0}
|
||||||
>
|
placeholder="SELECT LOADOUT"
|
||||||
<SelectTrigger className="h-8 rounded-sm border-border/60 bg-background/40 text-xs uppercase tracking-wide">
|
searchPlaceholder="Search loadouts..."
|
||||||
<SelectValue placeholder="SELECT LOADOUT" />
|
options={loadouts.map((loadout) => ({
|
||||||
</SelectTrigger>
|
value: String(loadout.id),
|
||||||
<SelectContent>
|
label: loadout.name,
|
||||||
{loadouts.map((loadout) => (
|
}))}
|
||||||
<SelectItem key={loadout.id} value={String(loadout.id)}>
|
/>
|
||||||
{loadout.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-2">
|
<div className="p-2">
|
||||||
|
|
|
||||||
|
|
@ -4,14 +4,8 @@ import { useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import type { Asset, Loadout } from "@/payload-types";
|
import type { Asset, Loadout } from "@/payload-types";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Combobox } from "@/components/ui/combobox";
|
||||||
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
|
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
import { Wardrobe } from "./Wardrobe";
|
import { Wardrobe } from "./Wardrobe";
|
||||||
import { WardrobeSlotPicker } from "./WardrobeSlotPicker";
|
import { WardrobeSlotPicker } from "./WardrobeSlotPicker";
|
||||||
import {
|
import {
|
||||||
|
|
@ -115,26 +109,20 @@ export function WardrobePanel({ userName, loadouts, equippedLoadout, items }: Wa
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Select
|
<Combobox
|
||||||
|
className="flex-1"
|
||||||
value={equippedLoadout ? String(equippedLoadout.id) : ""}
|
value={equippedLoadout ? String(equippedLoadout.id) : ""}
|
||||||
onValueChange={(v) => handleEquip(Number(v))}
|
onValueChange={(v) => handleEquip(Number(v))}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
>
|
placeholder={
|
||||||
<SelectTrigger className="flex-1">
|
equippedLoadout ? equippedLoadout.name : "Select a loadout to equip"
|
||||||
<SelectValue
|
}
|
||||||
placeholder={
|
searchPlaceholder="Search loadouts..."
|
||||||
equippedLoadout ? equippedLoadout.name : "Select a loadout to equip"
|
options={loadouts.map((l) => ({
|
||||||
}
|
value: String(l.id),
|
||||||
/>
|
label: l.name,
|
||||||
</SelectTrigger>
|
}))}
|
||||||
<SelectContent>
|
/>
|
||||||
{loadouts.map((l) => (
|
|
||||||
<SelectItem key={l.id} value={String(l.id)}>
|
|
||||||
{l.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
{equippedLoadout && (
|
{equippedLoadout && (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Combobox } from "@/components/ui/combobox";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
|
|
@ -13,15 +14,13 @@ import {
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
import { placeStructure } from "@/app/(frontend)/logistics/map/actions";
|
import { placeStructure } from "@/app/(frontend)/logistics/map/actions";
|
||||||
import { validatePlacement, type NodeInput, type StructureInput, type ZoneInput } from "@/lib/map/placement";
|
import {
|
||||||
|
validatePlacement,
|
||||||
|
type NodeInput,
|
||||||
|
type StructureInput,
|
||||||
|
type ZoneInput,
|
||||||
|
} from "@/lib/map/placement";
|
||||||
import type { MapPoint } from "@/lib/map/points";
|
import type { MapPoint } from "@/lib/map/points";
|
||||||
import type { MapConfig, NodeFeature, StructureFeature, ZoneFeature } from "@/lib/map/types";
|
import type { MapConfig, NodeFeature, StructureFeature, ZoneFeature } from "@/lib/map/types";
|
||||||
|
|
||||||
|
|
@ -86,9 +85,18 @@ export function PlacementDialog({
|
||||||
terrainType?: string | null;
|
terrainType?: string | null;
|
||||||
constructionDurationMinutes?: number | null;
|
constructionDurationMinutes?: number | null;
|
||||||
requiredTech?: { name?: string }[] | number[];
|
requiredTech?: { name?: string }[] | number[];
|
||||||
materials?: { resource?: { id: number; name?: string } | number; amount?: number | null }[];
|
materials?: {
|
||||||
resourcesInRange?: { resource?: { id: number } | number | null; range?: number | null }[];
|
resource?: { id: number; name?: string } | number;
|
||||||
structuresInRange?: { structure?: { id: number } | number | null; range?: number | null }[];
|
amount?: number | null;
|
||||||
|
}[];
|
||||||
|
resourcesInRange?: {
|
||||||
|
resource?: { id: number } | number | null;
|
||||||
|
range?: number | null;
|
||||||
|
}[];
|
||||||
|
structuresInRange?: {
|
||||||
|
structure?: { id: number } | number | null;
|
||||||
|
range?: number | null;
|
||||||
|
}[];
|
||||||
}[];
|
}[];
|
||||||
};
|
};
|
||||||
setBlueprints(
|
setBlueprints(
|
||||||
|
|
@ -98,7 +106,9 @@ export function PlacementDialog({
|
||||||
terrainType: (doc.terrainType as "land" | "water" | null | undefined) ?? "land",
|
terrainType: (doc.terrainType as "land" | "water" | null | undefined) ?? "land",
|
||||||
constructionDurationMinutes: doc.constructionDurationMinutes ?? null,
|
constructionDurationMinutes: doc.constructionDurationMinutes ?? null,
|
||||||
requiredTech: (doc.requiredTech ?? [])
|
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"),
|
.map((tech) => tech.name ?? "Unknown"),
|
||||||
materials: (doc.materials ?? []).map((material) => ({
|
materials: (doc.materials ?? []).map((material) => ({
|
||||||
resource:
|
resource:
|
||||||
|
|
@ -235,18 +245,18 @@ export function PlacementDialog({
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label htmlFor="place-blueprint">Blueprint</Label>
|
<Label htmlFor="place-blueprint">Blueprint</Label>
|
||||||
<Select value={blueprintId} onValueChange={setBlueprintId}>
|
<Combobox
|
||||||
<SelectTrigger id="place-blueprint" className="w-full">
|
id="place-blueprint"
|
||||||
<SelectValue placeholder="Select blueprint" />
|
value={blueprintId}
|
||||||
</SelectTrigger>
|
onValueChange={setBlueprintId}
|
||||||
<SelectContent>
|
placeholder="Select blueprint"
|
||||||
{blueprints.map((option) => (
|
searchPlaceholder="Search blueprints..."
|
||||||
<SelectItem key={option.id} value={String(option.id)}>
|
emptyMessage="No blueprints available."
|
||||||
{option.name}
|
options={blueprints.map((option) => ({
|
||||||
</SelectItem>
|
value: String(option.id),
|
||||||
))}
|
label: option.name,
|
||||||
</SelectContent>
|
}))}
|
||||||
</Select>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
|
|
@ -261,19 +271,20 @@ export function PlacementDialog({
|
||||||
|
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label htmlFor="place-faction">Faction (optional)</Label>
|
<Label htmlFor="place-faction">Faction (optional)</Label>
|
||||||
<Select value={factionId} onValueChange={setFactionId}>
|
<Combobox
|
||||||
<SelectTrigger id="place-faction" className="w-full">
|
id="place-faction"
|
||||||
<SelectValue />
|
value={factionId}
|
||||||
</SelectTrigger>
|
onValueChange={setFactionId}
|
||||||
<SelectContent>
|
searchPlaceholder="Search factions..."
|
||||||
<SelectItem value="none">No faction</SelectItem>
|
emptyMessage="No factions available."
|
||||||
{factions.map((faction) => (
|
options={[
|
||||||
<SelectItem key={faction.id} value={String(faction.id)}>
|
{ value: "none", label: "No faction" },
|
||||||
{faction.name}
|
...factions.map((faction) => ({
|
||||||
</SelectItem>
|
value: String(faction.id),
|
||||||
))}
|
label: faction.name,
|
||||||
</SelectContent>
|
})),
|
||||||
</Select>
|
]}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{blueprint && (
|
{blueprint && (
|
||||||
|
|
|
||||||
|
|
@ -5,17 +5,11 @@ import { useRouter } from "next/navigation";
|
||||||
import type { Asset, LockerStorage } from "@/payload-types";
|
import type { Asset, LockerStorage } from "@/payload-types";
|
||||||
import { PlusIcon } from "lucide-react";
|
import { PlusIcon } from "lucide-react";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Combobox } from "@/components/ui/combobox";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
|
|
@ -196,28 +190,21 @@ export function CreateListingDialog({
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label>Item</Label>
|
<Label>Item</Label>
|
||||||
<Select
|
<Combobox
|
||||||
value={assetId}
|
value={assetId}
|
||||||
onValueChange={(v) => {
|
onValueChange={(v) => {
|
||||||
setAssetId(v);
|
setAssetId(v);
|
||||||
setQuantity("1");
|
setQuantity("1");
|
||||||
}}
|
}}
|
||||||
>
|
placeholder="Select an item from your locker"
|
||||||
<SelectTrigger>
|
searchPlaceholder="Search items..."
|
||||||
<SelectValue placeholder="Select an item from your locker" />
|
emptyMessage="Nothing is approved for trading yet."
|
||||||
</SelectTrigger>
|
options={sellable.map((s) => ({
|
||||||
<SelectContent>
|
value: String(s.assetId),
|
||||||
{sellable.map((s) => (
|
label: `${s.name} (${s.quantity.toLocaleString()}×)`,
|
||||||
<SelectItem
|
disabled: !s.tradeable || !s.isLive,
|
||||||
key={s.assetId}
|
}))}
|
||||||
value={String(s.assetId)}
|
/>
|
||||||
disabled={!s.tradeable || !s.isLive}
|
|
||||||
>
|
|
||||||
{s.name} ({s.quantity.toLocaleString()}×)
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
{sellable.length === 0 && (
|
{sellable.length === 0 && (
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{hasLockerItems
|
{hasLockerItems
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import {
|
||||||
setPokerHoleQueue,
|
setPokerHoleQueue,
|
||||||
} from "@/app/(frontend)/minigames/poker/actions";
|
} from "@/app/(frontend)/minigames/poker/actions";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Combobox } from "@/components/ui/combobox";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
|
|
@ -65,11 +66,7 @@ function cardLabel(card: Card): string {
|
||||||
* what lets a single pick land; the previous null-propagating version made
|
* what lets a single pick land; the previous null-propagating version made
|
||||||
* first picks no-ops and hole-card queuing impossible.
|
* first picks no-ops and hole-card queuing impossible.
|
||||||
*/
|
*/
|
||||||
export function applyCardPick(
|
export function applyCardPick(value: Card | null, part: "rank" | "suit", next: string): Card {
|
||||||
value: Card | null,
|
|
||||||
part: "rank" | "suit",
|
|
||||||
next: string,
|
|
||||||
): Card {
|
|
||||||
if (part === "rank") {
|
if (part === "rank") {
|
||||||
return { rank: Number(next) as Rank, suit: value?.suit ?? "s" };
|
return { rank: Number(next) as Rank, suit: value?.suit ?? "s" };
|
||||||
}
|
}
|
||||||
|
|
@ -91,18 +88,17 @@ function CardPicker({
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<span className="w-10 text-xs text-muted-foreground">{label}</span>
|
<span className="w-10 text-xs text-muted-foreground">{label}</span>
|
||||||
<Select value={value ? String(value.rank) : ""} onValueChange={setRank}>
|
<Combobox
|
||||||
<SelectTrigger className="h-8 w-20 text-xs">
|
className="h-8 w-20 text-xs"
|
||||||
<SelectValue placeholder="Rank" />
|
value={value ? String(value.rank) : ""}
|
||||||
</SelectTrigger>
|
onValueChange={setRank}
|
||||||
<SelectContent>
|
placeholder="Rank"
|
||||||
{RANK_OPTIONS.map((option) => (
|
searchPlaceholder="Rank..."
|
||||||
<SelectItem key={option.value} value={String(option.value)}>
|
options={RANK_OPTIONS.map((option) => ({
|
||||||
{option.label}
|
value: String(option.value),
|
||||||
</SelectItem>
|
label: option.label,
|
||||||
))}
|
}))}
|
||||||
</SelectContent>
|
/>
|
||||||
</Select>
|
|
||||||
<Select value={value?.suit ?? ""} onValueChange={setSuit}>
|
<Select value={value?.suit ?? ""} onValueChange={setSuit}>
|
||||||
<SelectTrigger className="h-8 w-24 text-xs">
|
<SelectTrigger className="h-8 w-24 text-xs">
|
||||||
<SelectValue placeholder="Suit" />
|
<SelectValue placeholder="Suit" />
|
||||||
|
|
@ -258,20 +254,19 @@ export function PokerDevPanel({
|
||||||
|
|
||||||
<section className="flex flex-col gap-2 border border-border p-3">
|
<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>
|
<h3 className="text-xs font-semibold uppercase tracking-wider">Next hand hole cards</h3>
|
||||||
<Select value={seatId} onValueChange={setSeatId}>
|
<Combobox
|
||||||
<SelectTrigger className="h-8 text-xs">
|
className="h-8 text-xs"
|
||||||
<SelectValue placeholder="Player" />
|
value={seatId}
|
||||||
</SelectTrigger>
|
onValueChange={setSeatId}
|
||||||
<SelectContent>
|
placeholder="Player"
|
||||||
{view.players
|
searchPlaceholder="Search players..."
|
||||||
.filter((player) => !player.eliminated)
|
options={view.players
|
||||||
.map((player) => (
|
.filter((player) => !player.eliminated)
|
||||||
<SelectItem key={player.seatId} value={String(player.seatId)}>
|
.map((player) => ({
|
||||||
{player.name}
|
value: String(player.seatId),
|
||||||
</SelectItem>
|
label: player.name,
|
||||||
))}
|
}))}
|
||||||
</SelectContent>
|
/>
|
||||||
</Select>
|
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<CardPicker label="Card 1" value={holeA} onChange={setHoleA} />
|
<CardPicker label="Card 1" value={holeA} onChange={setHoleA} />
|
||||||
<CardPicker label="Card 2" value={holeB} onChange={setHoleB} />
|
<CardPicker label="Card 2" value={holeB} onChange={setHoleB} />
|
||||||
|
|
@ -289,20 +284,19 @@ export function PokerDevPanel({
|
||||||
|
|
||||||
<section className="flex flex-col gap-2 border border-border p-3">
|
<section className="flex flex-col gap-2 border border-border p-3">
|
||||||
<h3 className="text-xs font-semibold uppercase tracking-wider">Stack adjustment</h3>
|
<h3 className="text-xs font-semibold uppercase tracking-wider">Stack adjustment</h3>
|
||||||
<Select value={adjustSeat} onValueChange={setAdjustSeat}>
|
<Combobox
|
||||||
<SelectTrigger className="h-8 text-xs">
|
className="h-8 text-xs"
|
||||||
<SelectValue placeholder="Player" />
|
value={adjustSeat}
|
||||||
</SelectTrigger>
|
onValueChange={setAdjustSeat}
|
||||||
<SelectContent>
|
placeholder="Player"
|
||||||
{view.players
|
searchPlaceholder="Search players..."
|
||||||
.filter((player) => !player.eliminated)
|
options={view.players
|
||||||
.map((player) => (
|
.filter((player) => !player.eliminated)
|
||||||
<SelectItem key={player.seatId} value={String(player.seatId)}>
|
.map((player) => ({
|
||||||
{player.name} ({player.stack.toLocaleString()})
|
value: String(player.seatId),
|
||||||
</SelectItem>
|
label: `${player.name} (${player.stack.toLocaleString()})`,
|
||||||
))}
|
}))}
|
||||||
</SelectContent>
|
/>
|
||||||
</Select>
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import {
|
||||||
} from "@/lib/evaluations/levels";
|
} from "@/lib/evaluations/levels";
|
||||||
import { submitEvaluation } from "@/app/(frontend)/profile/[username]/actions";
|
import { submitEvaluation } from "@/app/(frontend)/profile/[username]/actions";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Combobox } from "@/components/ui/combobox";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
|
|
@ -134,20 +135,20 @@ export function LeadershipEvaluationDialog({
|
||||||
<form className="flex flex-col gap-4" onSubmit={handleSubmit} aria-busy={pending}>
|
<form className="flex flex-col gap-4" onSubmit={handleSubmit} aria-busy={pending}>
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label htmlFor="evaluation-mission">Mission</Label>
|
<Label htmlFor="evaluation-mission">Mission</Label>
|
||||||
<Select value={missionId} onValueChange={setMissionId} disabled={pending}>
|
<Combobox
|
||||||
<SelectTrigger id="evaluation-mission" aria-invalid={error !== null}>
|
id="evaluation-mission"
|
||||||
<SelectValue placeholder="Select a completed mission" />
|
aria-invalid={error !== null}
|
||||||
</SelectTrigger>
|
value={missionId}
|
||||||
<SelectContent>
|
onValueChange={setMissionId}
|
||||||
<SelectGroup>
|
disabled={pending}
|
||||||
{missions.map((mission) => (
|
placeholder="Select a completed mission"
|
||||||
<SelectItem key={mission.id} value={String(mission.id)}>
|
searchPlaceholder="Search missions..."
|
||||||
{mission.name} · {formatMissionDate(mission.startDateTime)}
|
emptyMessage="No completed missions to evaluate."
|
||||||
</SelectItem>
|
options={missions.map((mission) => ({
|
||||||
))}
|
value: String(mission.id),
|
||||||
</SelectGroup>
|
label: `${mission.name} · ${formatMissionDate(mission.startDateTime)}`,
|
||||||
</SelectContent>
|
}))}
|
||||||
</Select>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Combobox } from "@/components/ui/combobox";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
|
|
@ -11,13 +12,6 @@ import {
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
import { promoteMember } from "@/app/(frontend)/profile/[username]/actions";
|
import { promoteMember } from "@/app/(frontend)/profile/[username]/actions";
|
||||||
import { ArrowUpIcon, Loader2Icon, MedalIcon } from "lucide-react";
|
import { ArrowUpIcon, Loader2Icon, MedalIcon } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
@ -97,21 +91,16 @@ export function PromoteButton({ username, currentRankName, targetRanks }: Promot
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Select
|
<Combobox
|
||||||
value={selectedRankId != null ? String(selectedRankId) : undefined}
|
value={selectedRankId != null ? String(selectedRankId) : undefined}
|
||||||
onValueChange={(v) => setRankId(Number(v))}
|
onValueChange={(v) => setRankId(Number(v))}
|
||||||
>
|
placeholder="Select a rank"
|
||||||
<SelectTrigger>
|
searchPlaceholder="Search ranks..."
|
||||||
<SelectValue placeholder="Select a rank" />
|
options={targetRanks.map((r) => ({
|
||||||
</SelectTrigger>
|
value: String(r.id),
|
||||||
<SelectContent>
|
label: r.name,
|
||||||
{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>}
|
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,11 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
import { TagsIcon } from "lucide-react";
|
import { TagsIcon } from "lucide-react";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Combobox } from "@/components/ui/combobox";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuCheckboxItem,
|
DropdownMenuCheckboxItem,
|
||||||
|
|
@ -11,15 +14,6 @@ import {
|
||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectGroup,
|
|
||||||
SelectItem,
|
|
||||||
SelectLabel,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
import type { KanbanFiltersProps, SprintFilter } from "./KanbanBoard";
|
import type { KanbanFiltersProps, SprintFilter } from "./KanbanBoard";
|
||||||
|
|
||||||
function sprintFilterValue(value: string): SprintFilter {
|
function sprintFilterValue(value: string): SprintFilter {
|
||||||
|
|
@ -29,7 +23,9 @@ function sprintFilterValue(value: string): SprintFilter {
|
||||||
}
|
}
|
||||||
|
|
||||||
function activeSprints(sprints: KanbanFiltersProps["sprints"]) {
|
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;
|
return active.length > 0 ? active : sprints;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -43,6 +39,12 @@ export function KanbanFilters({
|
||||||
}: KanbanFiltersProps) {
|
}: KanbanFiltersProps) {
|
||||||
const selectedLabelCount = selectedLabelIds.length;
|
const selectedLabelCount = selectedLabelIds.length;
|
||||||
const sprintOptions = activeSprints(sprints);
|
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 =
|
const sprintLabel =
|
||||||
sprintFilter === "all"
|
sprintFilter === "all"
|
||||||
? "All sprints"
|
? "All sprints"
|
||||||
|
|
@ -50,27 +52,23 @@ export function KanbanFilters({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<Select
|
<Combobox
|
||||||
value={String(sprintFilter)}
|
value={String(sprintFilter)}
|
||||||
onValueChange={(value) => onSprintFilterChange(sprintFilterValue(value))}
|
onValueChange={(value) => onSprintFilterChange(sprintFilterValue(value))}
|
||||||
>
|
className="w-[190px]"
|
||||||
<SelectTrigger className="w-[190px]" aria-label="Filter by sprint">
|
aria-label="Filter by sprint"
|
||||||
<SelectValue placeholder={sprintLabel ?? "Select sprint"} />
|
placeholder={sprintLabel ?? "Select sprint"}
|
||||||
</SelectTrigger>
|
searchPlaceholder="Search sprints..."
|
||||||
<SelectContent>
|
options={[
|
||||||
<SelectGroup>
|
{ value: "all", label: "All sprints" },
|
||||||
<SelectLabel>Sprint</SelectLabel>
|
...sprintOptions.map((sprint) => ({
|
||||||
<SelectItem value="all">All sprints</SelectItem>
|
value: String(sprint.id),
|
||||||
{sprintOptions.map((sprint) => (
|
label: sprint.name,
|
||||||
<SelectItem key={sprint.id} value={String(sprint.id)}>
|
})),
|
||||||
{sprint.name}
|
]}
|
||||||
</SelectItem>
|
/>
|
||||||
))}
|
|
||||||
</SelectGroup>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
<DropdownMenu>
|
<DropdownMenu open={labelsOpen} onOpenChange={setLabelsOpen}>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="outline" size="sm" aria-label="Filter by label">
|
<Button variant="outline" size="sm" aria-label="Filter by label">
|
||||||
<TagsIcon data-icon="inline-start" />
|
<TagsIcon data-icon="inline-start" />
|
||||||
|
|
@ -81,10 +79,22 @@ export function KanbanFilters({
|
||||||
<DropdownMenuContent align="start" className="w-56">
|
<DropdownMenuContent align="start" className="w-56">
|
||||||
<DropdownMenuLabel>Filter by label</DropdownMenuLabel>
|
<DropdownMenuLabel>Filter by label</DropdownMenuLabel>
|
||||||
<DropdownMenuSeparator />
|
<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 ? (
|
{labels.length === 0 ? (
|
||||||
<span className="block px-2 py-1.5 text-sm text-muted-foreground">No labels yet</span>
|
<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>
|
||||||
) : (
|
) : (
|
||||||
labels.map((label) => (
|
visibleLabels.map((label) => (
|
||||||
<DropdownMenuCheckboxItem
|
<DropdownMenuCheckboxItem
|
||||||
key={label.id}
|
key={label.id}
|
||||||
checked={selectedLabelIds.includes(label.id)}
|
checked={selectedLabelIds.includes(label.id)}
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import {
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
|
||||||
interface LogEntry {
|
interface LogEntry {
|
||||||
id: number;
|
id: number;
|
||||||
|
|
@ -37,6 +38,8 @@ export function EventLedger({
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [selectedTypes, setSelectedTypes] = useState<Set<string>>(new Set());
|
const [selectedTypes, setSelectedTypes] = useState<Set<string>>(new Set());
|
||||||
const [currentUserId, setCurrentUserId] = useState<number | null>(null);
|
const [currentUserId, setCurrentUserId] = useState<number | null>(null);
|
||||||
|
const [typeQuery, setTypeQuery] = useState("");
|
||||||
|
const [filterMenuOpen, setFilterMenuOpen] = useState(false);
|
||||||
const initialDone = useRef(false);
|
const initialDone = useRef(false);
|
||||||
|
|
||||||
const eventTypes = useMemo(() => {
|
const eventTypes = useMemo(() => {
|
||||||
|
|
@ -44,6 +47,11 @@ export function EventLedger({
|
||||||
return Array.from(set).sort();
|
return Array.from(set).sort();
|
||||||
}, [entries]);
|
}, [entries]);
|
||||||
|
|
||||||
|
const typeNeedle = typeQuery.trim().toLowerCase();
|
||||||
|
const visibleTypes = typeNeedle
|
||||||
|
? eventTypes.filter((t) => formatType(t).toLowerCase().includes(typeNeedle))
|
||||||
|
: eventTypes;
|
||||||
|
|
||||||
const filtered = useMemo(
|
const filtered = useMemo(
|
||||||
() => (selectedTypes.size === 0 ? entries : entries.filter((e) => selectedTypes.has(e.type))),
|
() => (selectedTypes.size === 0 ? entries : entries.filter((e) => selectedTypes.has(e.type))),
|
||||||
[entries, selectedTypes],
|
[entries, selectedTypes],
|
||||||
|
|
@ -138,7 +146,13 @@ export function EventLedger({
|
||||||
</ItemHeader>
|
</ItemHeader>
|
||||||
<ItemContent>
|
<ItemContent>
|
||||||
{eventTypes.length > 1 && (
|
{eventTypes.length > 1 && (
|
||||||
<DropdownMenu>
|
<DropdownMenu
|
||||||
|
open={filterMenuOpen}
|
||||||
|
onOpenChange={(next) => {
|
||||||
|
setFilterMenuOpen(next);
|
||||||
|
if (!next) setTypeQuery("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
<span className="text-xs text-muted-foreground">Filter event logs...</span>
|
<span className="text-xs text-muted-foreground">Filter event logs...</span>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<button
|
<button
|
||||||
|
|
@ -148,22 +162,39 @@ export function EventLedger({
|
||||||
{dropdownLabel}
|
{dropdownLabel}
|
||||||
</button>
|
</button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="start">
|
<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>
|
||||||
<DropdownMenuCheckboxItem
|
<DropdownMenuCheckboxItem
|
||||||
checked={selectedTypes.size === 0}
|
checked={selectedTypes.size === 0}
|
||||||
onCheckedChange={() => setSelectedTypes(new Set())}
|
onCheckedChange={() => setSelectedTypes(new Set())}
|
||||||
|
onSelect={(event) => event.preventDefault()}
|
||||||
>
|
>
|
||||||
All
|
All
|
||||||
</DropdownMenuCheckboxItem>
|
</DropdownMenuCheckboxItem>
|
||||||
{eventTypes.map((t) => (
|
<div className="max-h-64 overflow-y-auto">
|
||||||
<DropdownMenuCheckboxItem
|
{visibleTypes.map((t) => (
|
||||||
key={t}
|
<DropdownMenuCheckboxItem
|
||||||
checked={selectedTypes.has(t)}
|
key={t}
|
||||||
onCheckedChange={() => toggleType(t)}
|
checked={selectedTypes.has(t)}
|
||||||
>
|
onCheckedChange={() => toggleType(t)}
|
||||||
{formatType(t)}
|
onSelect={(event) => event.preventDefault()}
|
||||||
</DropdownMenuCheckboxItem>
|
>
|
||||||
))}
|
{formatType(t)}
|
||||||
|
</DropdownMenuCheckboxItem>
|
||||||
|
))}
|
||||||
|
{visibleTypes.length === 0 && (
|
||||||
|
<div className="px-2 py-3 text-center text-xs text-muted-foreground">
|
||||||
|
No matches
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import {
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
|
import { Combobox } from "@/components/ui/combobox";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
|
|
@ -508,21 +509,20 @@ export function ManageStorageDialog({
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label>Transport Vehicle</Label>
|
<Label>Transport Vehicle</Label>
|
||||||
{availableVehicles.length > 0 ? (
|
{availableVehicles.length > 0 ? (
|
||||||
<Select value={shipmentVehicleId} onValueChange={setShipmentVehicleId}>
|
<Combobox
|
||||||
<SelectTrigger>
|
value={shipmentVehicleId}
|
||||||
<SelectValue placeholder="Select a vehicle..." />
|
onValueChange={setShipmentVehicleId}
|
||||||
</SelectTrigger>
|
placeholder="Select a vehicle..."
|
||||||
<SelectContent>
|
searchPlaceholder="Search vehicles..."
|
||||||
{availableVehicles.map((v) => {
|
emptyMessage="No vehicles with range."
|
||||||
const vType = typeof v.type === "object" ? v.type : null;
|
options={availableVehicles.map((v) => {
|
||||||
return (
|
const vType = typeof v.type === "object" ? v.type : null;
|
||||||
<SelectItem key={v.id} value={String(v.id)}>
|
return {
|
||||||
{v.name} ({vType?.name ?? "Unknown"}), {v.currentFuel?.toFixed(0)} fuel
|
value: String(v.id),
|
||||||
</SelectItem>
|
label: `${v.name} (${vType?.name ?? "Unknown"}), ${v.currentFuel?.toFixed(0)} fuel`,
|
||||||
);
|
};
|
||||||
})}
|
})}
|
||||||
</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">
|
<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" />
|
<TruckIcon className="size-4 shrink-0" />
|
||||||
|
|
@ -684,7 +684,7 @@ export function ManageStorageDialog({
|
||||||
<TabsContent value="deposit" className="flex flex-col gap-4 pt-4">
|
<TabsContent value="deposit" className="flex flex-col gap-4 pt-4">
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label>Resource</Label>
|
<Label>Resource</Label>
|
||||||
<Select
|
<Combobox
|
||||||
value={depositResourceId}
|
value={depositResourceId}
|
||||||
onValueChange={(val) => {
|
onValueChange={(val) => {
|
||||||
setDepositResourceId(val);
|
setDepositResourceId(val);
|
||||||
|
|
@ -692,29 +692,20 @@ export function ManageStorageDialog({
|
||||||
clampAmount(depositAmount, getDepositLimitInfoFor(val)?.max),
|
clampAmount(depositAmount, getDepositLimitInfoFor(val)?.max),
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
>
|
placeholder="Select a resource..."
|
||||||
<SelectTrigger>
|
searchPlaceholder="Search resources..."
|
||||||
<SelectValue placeholder="Select a resource..." />
|
emptyMessage="Nothing may be deposited here."
|
||||||
</SelectTrigger>
|
options={allResources
|
||||||
<SelectContent>
|
.filter((r) => isResourceAllowed(r.id))
|
||||||
{allResources
|
.map((resource) => {
|
||||||
.filter((r) => isResourceAllowed(r.id))
|
const cap = getAllowedDepositCap(resource.id);
|
||||||
.map((resource) => {
|
return {
|
||||||
const cap = getAllowedDepositCap(resource.id);
|
value: String(resource.id),
|
||||||
return (
|
label: resource.name,
|
||||||
<SelectItem key={resource.id} value={String(resource.id)}>
|
hint: cap !== undefined ? `${cap.toLocaleString()} max` : undefined,
|
||||||
{resource.name}
|
};
|
||||||
{cap !== undefined && (
|
})}
|
||||||
<span className="text-muted-foreground">
|
/>
|
||||||
{" "}
|
|
||||||
({cap.toLocaleString()} max)
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</SelectItem>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label>Amount</Label>
|
<Label>Amount</Label>
|
||||||
|
|
@ -754,30 +745,27 @@ export function ManageStorageDialog({
|
||||||
<TabsContent value="withdraw" className="flex flex-col gap-4 pt-4">
|
<TabsContent value="withdraw" className="flex flex-col gap-4 pt-4">
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label>Resource</Label>
|
<Label>Resource</Label>
|
||||||
<Select
|
<Combobox
|
||||||
value={withdrawResourceId}
|
value={withdrawResourceId}
|
||||||
onValueChange={(val) => {
|
onValueChange={(val) => {
|
||||||
setWithdrawResourceId(val);
|
setWithdrawResourceId(val);
|
||||||
const stored = findStoredResource(val);
|
const stored = findStoredResource(val);
|
||||||
setWithdrawAmount(clampAmount(withdrawAmount, stored?.amount));
|
setWithdrawAmount(clampAmount(withdrawAmount, stored?.amount));
|
||||||
}}
|
}}
|
||||||
>
|
placeholder="Select a resource..."
|
||||||
<SelectTrigger>
|
searchPlaceholder="Search resources..."
|
||||||
<SelectValue placeholder="Select a resource..." />
|
emptyMessage="Nothing stored yet."
|
||||||
</SelectTrigger>
|
options={storedResources
|
||||||
<SelectContent>
|
.map((entry) => {
|
||||||
{storedResources.map((entry) => {
|
|
||||||
const resource = entry.resource as Resource;
|
const resource = entry.resource as Resource;
|
||||||
if (!resource || typeof resource === "number") return null;
|
if (!resource || typeof resource === "number") return null;
|
||||||
return (
|
return {
|
||||||
<SelectItem key={entry.id ?? resource.id} value={String(resource.id)}>
|
value: String(resource.id),
|
||||||
{resource.name} ({entry.amount.toLocaleString()}{" "}
|
label: `${resource.name} (${entry.amount.toLocaleString()} ${pluralize(entry.amount, resource.unitOfMeasure)})`,
|
||||||
{pluralize(entry.amount, resource.unitOfMeasure)})
|
};
|
||||||
</SelectItem>
|
})
|
||||||
);
|
.filter((option) => option !== null)}
|
||||||
})}
|
/>
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label>Amount</Label>
|
<Label>Amount</Label>
|
||||||
|
|
@ -817,51 +805,43 @@ export function ManageStorageDialog({
|
||||||
<TabsContent value="transfer" className="flex flex-col gap-4 pt-4">
|
<TabsContent value="transfer" className="flex flex-col gap-4 pt-4">
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label>Destination</Label>
|
<Label>Destination</Label>
|
||||||
<Select
|
<Combobox
|
||||||
value={transferToId}
|
value={transferToId}
|
||||||
onValueChange={(val) => {
|
onValueChange={(val) => {
|
||||||
setTransferToId(val);
|
setTransferToId(val);
|
||||||
setTransferAmount(clampAmount(transferAmount, getTransferMaxFor(val)));
|
setTransferAmount(clampAmount(transferAmount, getTransferMaxFor(val)));
|
||||||
}}
|
}}
|
||||||
>
|
placeholder="Select a structure..."
|
||||||
<SelectTrigger>
|
searchPlaceholder="Search structures..."
|
||||||
<SelectValue placeholder="Select a structure..." />
|
options={allStructures.map((s) => ({
|
||||||
</SelectTrigger>
|
value: String(s.id),
|
||||||
<SelectContent>
|
label: s.name,
|
||||||
{allStructures.map((s) => (
|
}))}
|
||||||
<SelectItem key={s.id} value={String(s.id)}>
|
/>
|
||||||
{s.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label>Resource</Label>
|
<Label>Resource</Label>
|
||||||
<Select
|
<Combobox
|
||||||
value={transferResourceId}
|
value={transferResourceId}
|
||||||
onValueChange={(val) => {
|
onValueChange={(val) => {
|
||||||
setTransferResourceId(val);
|
setTransferResourceId(val);
|
||||||
const stored = findStoredResource(val);
|
const stored = findStoredResource(val);
|
||||||
setTransferAmount(clampAmount(transferAmount, stored?.amount));
|
setTransferAmount(clampAmount(transferAmount, stored?.amount));
|
||||||
}}
|
}}
|
||||||
>
|
placeholder="Select a resource..."
|
||||||
<SelectTrigger>
|
searchPlaceholder="Search resources..."
|
||||||
<SelectValue placeholder="Select a resource..." />
|
emptyMessage="Nothing stored yet."
|
||||||
</SelectTrigger>
|
options={storedResources
|
||||||
<SelectContent>
|
.map((entry) => {
|
||||||
{storedResources.map((entry) => {
|
|
||||||
const resource = entry.resource as Resource;
|
const resource = entry.resource as Resource;
|
||||||
if (!resource || typeof resource === "number") return null;
|
if (!resource || typeof resource === "number") return null;
|
||||||
return (
|
return {
|
||||||
<SelectItem key={entry.id ?? resource.id} value={String(resource.id)}>
|
value: String(resource.id),
|
||||||
{resource.name} ({entry.amount.toLocaleString()}{" "}
|
label: `${resource.name} (${entry.amount.toLocaleString()} ${pluralize(entry.amount, resource.unitOfMeasure)})`,
|
||||||
{pluralize(entry.amount, resource.unitOfMeasure)})
|
};
|
||||||
</SelectItem>
|
})
|
||||||
);
|
.filter((option) => option !== null)}
|
||||||
})}
|
/>
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label>Amount</Label>
|
<Label>Amount</Label>
|
||||||
|
|
|
||||||
122
src/components/ui/combobox.tsx
Normal file
122
src/components/ui/combobox.tsx
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
"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 };
|
||||||
Loading…
Reference in a new issue