mirror of
https://github.com/sortedcord/omnia.git
synced 2026-07-22 03:52:48 +05:30
minor(builder): misc ui fixes
This commit is contained in:
@@ -4,6 +4,14 @@ import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxContent,
|
||||
ComboboxEmpty,
|
||||
ComboboxInput,
|
||||
ComboboxItem,
|
||||
ComboboxList,
|
||||
} from "@/components/ui/combobox";
|
||||
import { AttributeEditor } from "./AttributeEditor";
|
||||
import { Plus, Trash2, ChevronRight } from "lucide-react";
|
||||
import type { EntityData, MemoryData, LocationData } from "./types";
|
||||
@@ -104,9 +112,9 @@ export function EntitiesTab({
|
||||
|
||||
{/* Right panel: Edit selected entity details */}
|
||||
{selectedEnt ? (
|
||||
<div className="md:col-span-3 border border-border/20 bg-card p-6 shadow-[2px_2px_0_0_var(--border)] grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Column 1: ID, Location, Attributes */}
|
||||
<div className="space-y-4">
|
||||
<div className="md:col-span-3 space-y-6">
|
||||
{/* Entity Configuration Card */}
|
||||
<div className="border border-border/20 bg-card p-6 shadow-[2px_2px_0_0_var(--border)] space-y-4">
|
||||
<h2 className="text-body-lg text-primary font-bold border-b border-border/20 pb-2">
|
||||
Entity Configuration
|
||||
</h2>
|
||||
@@ -122,23 +130,27 @@ export function EntitiesTab({
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>Current Location</Label>
|
||||
<select
|
||||
className="bg-card border border-border/30 px-3 py-1.5 text-xs outline-none w-full rounded font-mono"
|
||||
<Combobox
|
||||
items={locationIds}
|
||||
value={selectedEnt.locationId || ""}
|
||||
onChange={(e) => {
|
||||
onValueChange={(value) => {
|
||||
const copy = [...entities];
|
||||
copy[selectedEntIndex].locationId =
|
||||
e.target.value || undefined;
|
||||
copy[selectedEntIndex].locationId = value || undefined;
|
||||
setEntities(copy);
|
||||
}}
|
||||
>
|
||||
<option value="">-- No Location (floating) --</option>
|
||||
{locationIds.map((id) => (
|
||||
<option key={id} value={id}>
|
||||
{getLocationDisplayNameById(id, locations)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ComboboxInput placeholder="Select a location..." showClear />
|
||||
<ComboboxContent>
|
||||
<ComboboxEmpty>No locations found.</ComboboxEmpty>
|
||||
<ComboboxList>
|
||||
{(id: string) => (
|
||||
<ComboboxItem key={id} value={id}>
|
||||
{getLocationDisplayNameById(id, locations)}
|
||||
</ComboboxItem>
|
||||
)}
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5 pt-1 pb-2">
|
||||
@@ -191,360 +203,354 @@ export function EntitiesTab({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Column 2: Aliases and Initial Memories */}
|
||||
<div className="space-y-6 border-t lg:border-t-0 lg:border-l border-border/20 lg:pl-6 pt-4 lg:pt-0">
|
||||
{/* Aliases Section */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-center border-b border-border/20 pb-2">
|
||||
<h3 className="text-body-md text-foreground font-bold">
|
||||
Aliases (Perceptions)
|
||||
</h3>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
const copy = [...entities];
|
||||
const target = entityIds.find(
|
||||
(id) => id !== selectedEnt.id && !selectedEnt.aliases[id],
|
||||
);
|
||||
if (target) {
|
||||
copy[selectedEntIndex].aliases = {
|
||||
...selectedEnt.aliases,
|
||||
[target]: "",
|
||||
};
|
||||
setEntities(copy);
|
||||
}
|
||||
}}
|
||||
disabled={entityIds.length <= 1}
|
||||
className="h-7 text-xs flex gap-1 cursor-pointer"
|
||||
>
|
||||
<Plus className="size-3" /> Add Alias
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{Object.keys(selectedEnt.aliases || {}).length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground italic">
|
||||
No descriptive aliases configured. Defaults to actual entity
|
||||
ID.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{Object.entries(selectedEnt.aliases).map(
|
||||
([targetId, aliasText]) => (
|
||||
<div
|
||||
key={targetId}
|
||||
className="flex gap-2 items-center bg-secondary/15 p-2 rounded"
|
||||
>
|
||||
<span
|
||||
className="text-[11px] font-mono text-muted-foreground w-1/3 truncate"
|
||||
title={targetId}
|
||||
>
|
||||
{getEntityDisplayNameById(targetId, entities)}
|
||||
</span>
|
||||
<ChevronRight className="size-3 text-muted-foreground shrink-0" />
|
||||
<Input
|
||||
placeholder="Descriptive name (e.g. the guard)"
|
||||
value={aliasText}
|
||||
onChange={(e) => {
|
||||
const copy = [...entities];
|
||||
copy[selectedEntIndex].aliases = {
|
||||
...selectedEnt.aliases,
|
||||
[targetId]: e.target.value,
|
||||
};
|
||||
setEntities(copy);
|
||||
}}
|
||||
className="h-7 text-xs flex-1"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const copy = [...entities];
|
||||
const updated = { ...selectedEnt.aliases };
|
||||
delete updated[targetId];
|
||||
copy[selectedEntIndex].aliases = updated;
|
||||
setEntities(copy);
|
||||
}}
|
||||
className="text-muted-foreground hover:text-destructive cursor-pointer"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* Aliases Card */}
|
||||
<div className="border border-border/20 bg-card p-6 shadow-[2px_2px_0_0_var(--border)] space-y-3">
|
||||
<div className="flex justify-between items-center border-b border-border/20 pb-2">
|
||||
<h3 className="text-body-md text-foreground font-bold">
|
||||
Aliases (Perceptions)
|
||||
</h3>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
const copy = [...entities];
|
||||
const target = entityIds.find(
|
||||
(id) => id !== selectedEnt.id && !selectedEnt.aliases[id],
|
||||
);
|
||||
if (target) {
|
||||
copy[selectedEntIndex].aliases = {
|
||||
...selectedEnt.aliases,
|
||||
[target]: "",
|
||||
};
|
||||
setEntities(copy);
|
||||
}
|
||||
}}
|
||||
disabled={entityIds.length <= 1}
|
||||
className="h-7 text-xs flex gap-1 cursor-pointer"
|
||||
>
|
||||
<Plus className="size-3" /> Add Alias
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Initial Memories Section */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-center border-b border-border/20 pb-2">
|
||||
<h3 className="text-body-md text-foreground font-bold">
|
||||
Initial Memories
|
||||
</h3>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
const copy = [...entities];
|
||||
const newMem: MemoryData = {
|
||||
id: generateUUID(),
|
||||
timestamp: startTime,
|
||||
locationId: selectedEnt.locationId || null,
|
||||
intent: {
|
||||
type: "dialogue",
|
||||
originalText: "",
|
||||
description: "",
|
||||
actorId: selectedEnt.id,
|
||||
targetIds: [],
|
||||
},
|
||||
};
|
||||
copy[selectedEntIndex].initialMemories = [
|
||||
...selectedEnt.initialMemories,
|
||||
newMem,
|
||||
];
|
||||
setEntities(copy);
|
||||
}}
|
||||
className="h-7 text-xs flex gap-1 cursor-pointer"
|
||||
>
|
||||
<Plus className="size-3" /> Add Memory
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{!selectedEnt.initialMemories ||
|
||||
selectedEnt.initialMemories.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground italic">
|
||||
No initial memories loaded. Entities will start blank.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-4 overflow-y-auto max-h-[300px] pr-1">
|
||||
{selectedEnt.initialMemories.map((mem, memIdx) => (
|
||||
{Object.keys(selectedEnt.aliases || {}).length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground italic">
|
||||
No descriptive aliases configured. Defaults to actual entity ID.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{Object.entries(selectedEnt.aliases).map(
|
||||
([targetId, aliasText]) => (
|
||||
<div
|
||||
key={mem.id}
|
||||
className="border border-border/20 bg-secondary/5 p-3 rounded space-y-3 relative"
|
||||
key={targetId}
|
||||
className="flex gap-2 items-center bg-secondary/15 p-2 rounded"
|
||||
>
|
||||
<span
|
||||
className="text-[11px] font-mono text-muted-foreground w-1/3 truncate"
|
||||
title={targetId}
|
||||
>
|
||||
{getEntityDisplayNameById(targetId, entities)}
|
||||
</span>
|
||||
<ChevronRight className="size-3 text-muted-foreground shrink-0" />
|
||||
<Input
|
||||
placeholder="Descriptive name (e.g. the guard)"
|
||||
value={aliasText}
|
||||
onChange={(e) => {
|
||||
const copy = [...entities];
|
||||
copy[selectedEntIndex].aliases = {
|
||||
...selectedEnt.aliases,
|
||||
[targetId]: e.target.value,
|
||||
};
|
||||
setEntities(copy);
|
||||
}}
|
||||
className="h-7 text-xs flex-1"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const copy = [...entities];
|
||||
copy[selectedEntIndex].initialMemories =
|
||||
selectedEnt.initialMemories.filter(
|
||||
(_, i) => i !== memIdx,
|
||||
);
|
||||
const updated = { ...selectedEnt.aliases };
|
||||
delete updated[targetId];
|
||||
copy[selectedEntIndex].aliases = updated;
|
||||
setEntities(copy);
|
||||
}}
|
||||
className="absolute top-2 right-2 text-muted-foreground hover:text-destructive cursor-pointer"
|
||||
className="text-muted-foreground hover:text-destructive cursor-pointer"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</button>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-1">
|
||||
<span className="text-[10px] font-semibold text-muted-foreground">
|
||||
Type
|
||||
</span>
|
||||
<select
|
||||
className="bg-card border border-border/30 px-2 py-0.5 text-xs outline-none w-full rounded"
|
||||
value={mem.intent.type}
|
||||
onChange={(e) => {
|
||||
const copy = [...entities];
|
||||
copy[selectedEntIndex].initialMemories[
|
||||
memIdx
|
||||
].intent.type = e.target.value as
|
||||
"dialogue" | "action" | "monologue";
|
||||
setEntities(copy);
|
||||
}}
|
||||
>
|
||||
<option value="dialogue">Dialogue</option>
|
||||
<option value="action">Action</option>
|
||||
<option value="monologue">Monologue</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<span className="text-[10px] font-semibold text-muted-foreground">
|
||||
Location
|
||||
</span>
|
||||
<select
|
||||
className="bg-card border border-border/30 px-2 py-0.5 text-xs outline-none w-full rounded font-mono"
|
||||
value={mem.locationId || ""}
|
||||
onChange={(e) => {
|
||||
const copy = [...entities];
|
||||
copy[selectedEntIndex].initialMemories[
|
||||
memIdx
|
||||
].locationId = e.target.value || null;
|
||||
setEntities(copy);
|
||||
}}
|
||||
>
|
||||
<option value="">-- Nowhere --</option>
|
||||
{locationIds.map((id) => (
|
||||
<option key={id} value={id}>
|
||||
{getLocationDisplayNameById(id, locations)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-[10px] font-semibold text-muted-foreground">
|
||||
Verbatim Text (originalText)
|
||||
</span>
|
||||
<Input
|
||||
placeholder='e.g. "We should leave," Alice said.'
|
||||
value={mem.intent.originalText}
|
||||
onChange={(e) => {
|
||||
const copy = [...entities];
|
||||
copy[selectedEntIndex].initialMemories[
|
||||
memIdx
|
||||
].intent.originalText = e.target.value;
|
||||
setEntities(copy);
|
||||
}}
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-[10px] font-semibold text-muted-foreground">
|
||||
Objective Description
|
||||
</span>
|
||||
<Input
|
||||
placeholder="e.g. Alice says she wants to leave."
|
||||
value={mem.intent.description}
|
||||
onChange={(e) => {
|
||||
const copy = [...entities];
|
||||
copy[selectedEntIndex].initialMemories[
|
||||
memIdx
|
||||
].intent.description = e.target.value;
|
||||
setEntities(copy);
|
||||
}}
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Targets multi-select */}
|
||||
<div className="space-y-1">
|
||||
<span className="text-[10px] font-semibold text-muted-foreground block">
|
||||
Involved Targets
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{entityIds
|
||||
.filter((id) => id !== selectedEnt.id)
|
||||
.map((entId) => {
|
||||
const isSelected =
|
||||
mem.intent.targetIds?.includes(entId);
|
||||
return (
|
||||
<button
|
||||
key={entId}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const copy = [...entities];
|
||||
const targets =
|
||||
copy[selectedEntIndex].initialMemories[
|
||||
memIdx
|
||||
].intent.targetIds || [];
|
||||
if (targets.includes(entId)) {
|
||||
copy[selectedEntIndex].initialMemories[
|
||||
memIdx
|
||||
].intent.targetIds = targets.filter(
|
||||
(t) => t !== entId,
|
||||
);
|
||||
} else {
|
||||
copy[selectedEntIndex].initialMemories[
|
||||
memIdx
|
||||
].intent.targetIds = [...targets, entId];
|
||||
}
|
||||
setEntities(copy);
|
||||
}}
|
||||
className={`px-1.5 py-0.5 rounded text-[9px] font-mono border transition-all cursor-pointer ${
|
||||
isSelected
|
||||
? "bg-primary/20 border-primary text-primary"
|
||||
: "bg-background border-border/30 text-muted-foreground hover:bg-secondary"
|
||||
}`}
|
||||
>
|
||||
{getEntityDisplayNameById(entId, entities)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Validation outcome */}
|
||||
{mem.intent.type === "action" && (
|
||||
<div className="border-t border-border/20 pt-2 space-y-2">
|
||||
<Label className="text-[10px] text-muted-foreground flex items-center gap-1.5 cursor-pointer">
|
||||
<Checkbox
|
||||
checked={!!mem.outcome}
|
||||
onCheckedChange={(checked) => {
|
||||
const copy = [...entities];
|
||||
if (checked) {
|
||||
copy[selectedEntIndex].initialMemories[
|
||||
memIdx
|
||||
].outcome = { isValid: true, reason: "" };
|
||||
} else {
|
||||
copy[selectedEntIndex].initialMemories[
|
||||
memIdx
|
||||
].outcome = undefined;
|
||||
}
|
||||
setEntities(copy);
|
||||
}}
|
||||
/>
|
||||
Include validation outcome
|
||||
</Label>
|
||||
{mem.outcome && (
|
||||
<div className="grid grid-cols-3 gap-2 bg-secondary/15 p-2 rounded">
|
||||
<div className="col-span-1 flex flex-col justify-center">
|
||||
<Label className="text-[9px] mb-1">
|
||||
isValid
|
||||
</Label>
|
||||
<Checkbox
|
||||
checked={mem.outcome.isValid}
|
||||
onCheckedChange={(checked) => {
|
||||
const copy = [...entities];
|
||||
if (
|
||||
copy[selectedEntIndex].initialMemories[
|
||||
memIdx
|
||||
].outcome
|
||||
) {
|
||||
copy[selectedEntIndex].initialMemories[
|
||||
memIdx
|
||||
].outcome!.isValid = !!checked;
|
||||
setEntities(copy);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2 space-y-1">
|
||||
<Label className="text-[9px]">Reason</Label>
|
||||
<Input
|
||||
placeholder="Reason for valid/invalid status"
|
||||
value={mem.outcome.reason}
|
||||
onChange={(e) => {
|
||||
const copy = [...entities];
|
||||
if (
|
||||
copy[selectedEntIndex].initialMemories[
|
||||
memIdx
|
||||
].outcome
|
||||
) {
|
||||
copy[selectedEntIndex].initialMemories[
|
||||
memIdx
|
||||
].outcome!.reason = e.target.value;
|
||||
setEntities(copy);
|
||||
}
|
||||
}}
|
||||
className="h-6 text-[10px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Initial Memories Card */}
|
||||
<div className="border border-border/20 bg-card p-6 shadow-[2px_2px_0_0_var(--border)] space-y-3">
|
||||
<div className="flex justify-between items-center border-b border-border/20 pb-2">
|
||||
<h3 className="text-body-md text-foreground font-bold">
|
||||
Initial Memories
|
||||
</h3>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
const copy = [...entities];
|
||||
const newMem: MemoryData = {
|
||||
id: generateUUID(),
|
||||
timestamp: startTime,
|
||||
locationId: selectedEnt.locationId || null,
|
||||
intent: {
|
||||
type: "dialogue",
|
||||
originalText: "",
|
||||
description: "",
|
||||
actorId: selectedEnt.id,
|
||||
targetIds: [],
|
||||
},
|
||||
};
|
||||
copy[selectedEntIndex].initialMemories = [
|
||||
...selectedEnt.initialMemories,
|
||||
newMem,
|
||||
];
|
||||
setEntities(copy);
|
||||
}}
|
||||
className="h-7 text-xs flex gap-1 cursor-pointer"
|
||||
>
|
||||
<Plus className="size-3" /> Add Memory
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{!selectedEnt.initialMemories ||
|
||||
selectedEnt.initialMemories.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground italic">
|
||||
No initial memories loaded. Entities will start blank.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-4 overflow-y-auto max-h-[300px] pr-1">
|
||||
{selectedEnt.initialMemories.map((mem, memIdx) => (
|
||||
<div
|
||||
key={mem.id}
|
||||
className="border border-border/20 bg-secondary/5 p-3 rounded space-y-3 relative"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const copy = [...entities];
|
||||
copy[selectedEntIndex].initialMemories =
|
||||
selectedEnt.initialMemories.filter(
|
||||
(_, i) => i !== memIdx,
|
||||
);
|
||||
setEntities(copy);
|
||||
}}
|
||||
className="absolute top-2 right-2 text-muted-foreground hover:text-destructive cursor-pointer"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</button>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-1">
|
||||
<span className="text-[10px] font-semibold text-muted-foreground">
|
||||
Type
|
||||
</span>
|
||||
<select
|
||||
className="bg-card border border-border/30 px-2 py-0.5 text-xs outline-none w-full rounded"
|
||||
value={mem.intent.type}
|
||||
onChange={(e) => {
|
||||
const copy = [...entities];
|
||||
copy[selectedEntIndex].initialMemories[
|
||||
memIdx
|
||||
].intent.type = e.target.value as
|
||||
"dialogue" | "action" | "monologue";
|
||||
setEntities(copy);
|
||||
}}
|
||||
>
|
||||
<option value="dialogue">Dialogue</option>
|
||||
<option value="action">Action</option>
|
||||
<option value="monologue">Monologue</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<span className="text-[10px] font-semibold text-muted-foreground">
|
||||
Location
|
||||
</span>
|
||||
<select
|
||||
className="bg-card border border-border/30 px-2 py-0.5 text-xs outline-none w-full rounded font-mono"
|
||||
value={mem.locationId || ""}
|
||||
onChange={(e) => {
|
||||
const copy = [...entities];
|
||||
copy[selectedEntIndex].initialMemories[
|
||||
memIdx
|
||||
].locationId = e.target.value || null;
|
||||
setEntities(copy);
|
||||
}}
|
||||
>
|
||||
<option value="">-- Nowhere --</option>
|
||||
{locationIds.map((id) => (
|
||||
<option key={id} value={id}>
|
||||
{getLocationDisplayNameById(id, locations)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-[10px] font-semibold text-muted-foreground">
|
||||
Verbatim Text (originalText)
|
||||
</span>
|
||||
<Input
|
||||
placeholder='e.g. "We should leave," Alice said.'
|
||||
value={mem.intent.originalText}
|
||||
onChange={(e) => {
|
||||
const copy = [...entities];
|
||||
copy[selectedEntIndex].initialMemories[
|
||||
memIdx
|
||||
].intent.originalText = e.target.value;
|
||||
setEntities(copy);
|
||||
}}
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-[10px] font-semibold text-muted-foreground">
|
||||
Objective Description
|
||||
</span>
|
||||
<Input
|
||||
placeholder="e.g. Alice says she wants to leave."
|
||||
value={mem.intent.description}
|
||||
onChange={(e) => {
|
||||
const copy = [...entities];
|
||||
copy[selectedEntIndex].initialMemories[
|
||||
memIdx
|
||||
].intent.description = e.target.value;
|
||||
setEntities(copy);
|
||||
}}
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Targets multi-select */}
|
||||
<div className="space-y-1">
|
||||
<span className="text-[10px] font-semibold text-muted-foreground block">
|
||||
Involved Targets
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{entityIds
|
||||
.filter((id) => id !== selectedEnt.id)
|
||||
.map((entId) => {
|
||||
const isSelected =
|
||||
mem.intent.targetIds?.includes(entId);
|
||||
return (
|
||||
<button
|
||||
key={entId}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const copy = [...entities];
|
||||
const targets =
|
||||
copy[selectedEntIndex].initialMemories[
|
||||
memIdx
|
||||
].intent.targetIds || [];
|
||||
if (targets.includes(entId)) {
|
||||
copy[selectedEntIndex].initialMemories[
|
||||
memIdx
|
||||
].intent.targetIds = targets.filter(
|
||||
(t) => t !== entId,
|
||||
);
|
||||
} else {
|
||||
copy[selectedEntIndex].initialMemories[
|
||||
memIdx
|
||||
].intent.targetIds = [...targets, entId];
|
||||
}
|
||||
setEntities(copy);
|
||||
}}
|
||||
className={`px-1.5 py-0.5 rounded text-[9px] font-mono border transition-all cursor-pointer ${
|
||||
isSelected
|
||||
? "bg-primary/20 border-primary text-primary"
|
||||
: "bg-background border-border/30 text-muted-foreground hover:bg-secondary"
|
||||
}`}
|
||||
>
|
||||
{getEntityDisplayNameById(entId, entities)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Validation outcome */}
|
||||
{mem.intent.type === "action" && (
|
||||
<div className="border-t border-border/20 pt-2 space-y-2">
|
||||
<Label className="text-[10px] text-muted-foreground flex items-center gap-1.5 cursor-pointer">
|
||||
<Checkbox
|
||||
checked={!!mem.outcome}
|
||||
onCheckedChange={(checked) => {
|
||||
const copy = [...entities];
|
||||
if (checked) {
|
||||
copy[selectedEntIndex].initialMemories[
|
||||
memIdx
|
||||
].outcome = { isValid: true, reason: "" };
|
||||
} else {
|
||||
copy[selectedEntIndex].initialMemories[
|
||||
memIdx
|
||||
].outcome = undefined;
|
||||
}
|
||||
setEntities(copy);
|
||||
}}
|
||||
/>
|
||||
Include validation outcome
|
||||
</Label>
|
||||
{mem.outcome && (
|
||||
<div className="grid grid-cols-3 gap-2 bg-secondary/15 p-2 rounded">
|
||||
<div className="col-span-1 flex flex-col justify-center">
|
||||
<Label className="text-[9px] mb-1">isValid</Label>
|
||||
<Checkbox
|
||||
checked={mem.outcome.isValid}
|
||||
onCheckedChange={(checked) => {
|
||||
const copy = [...entities];
|
||||
if (
|
||||
copy[selectedEntIndex].initialMemories[
|
||||
memIdx
|
||||
].outcome
|
||||
) {
|
||||
copy[selectedEntIndex].initialMemories[
|
||||
memIdx
|
||||
].outcome!.isValid = !!checked;
|
||||
setEntities(copy);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2 space-y-1">
|
||||
<Label className="text-[9px]">Reason</Label>
|
||||
<Input
|
||||
placeholder="Reason for valid/invalid status"
|
||||
value={mem.outcome.reason}
|
||||
onChange={(e) => {
|
||||
const copy = [...entities];
|
||||
if (
|
||||
copy[selectedEntIndex].initialMemories[
|
||||
memIdx
|
||||
].outcome
|
||||
) {
|
||||
copy[selectedEntIndex].initialMemories[
|
||||
memIdx
|
||||
].outcome!.reason = e.target.value;
|
||||
setEntities(copy);
|
||||
}
|
||||
}}
|
||||
className="h-6 text-[10px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -30,7 +30,7 @@ export function JsonTab({ compiledScenario, onCopySuccess }: JsonTabProps) {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<pre className="flex-1 bg-black/40 border border-border/10 p-4 rounded overflow-auto font-mono text-xs text-emerald-400 select-text leading-relaxed">
|
||||
<pre className="flex-1 bg-black border border-border/10 p-4 rounded overflow-auto font-mono text-xs text-emerald-400 select-text leading-relaxed">
|
||||
{JSON.stringify(compiledScenario, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,14 @@ import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxContent,
|
||||
ComboboxEmpty,
|
||||
ComboboxInput,
|
||||
ComboboxItem,
|
||||
ComboboxList,
|
||||
} from "@/components/ui/combobox";
|
||||
import { AttributeEditor } from "./AttributeEditor";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import type { LocationData, ConnectionData, EntityData } from "./types";
|
||||
@@ -162,25 +170,30 @@ export function LocationsTab({
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>Parent Location (Optional)</Label>
|
||||
<select
|
||||
className="bg-card border border-border/30 px-3 py-1.5 text-xs outline-none w-full rounded"
|
||||
<Combobox
|
||||
items={locationIds.filter((id) => id !== selectedLoc.id)}
|
||||
value={selectedLoc.parentId || ""}
|
||||
onChange={(e) => {
|
||||
onValueChange={(value) => {
|
||||
const copy = [...locations];
|
||||
copy[selectedLocIndex].parentId =
|
||||
e.target.value || undefined;
|
||||
copy[selectedLocIndex].parentId = value || undefined;
|
||||
setLocations(copy);
|
||||
}}
|
||||
>
|
||||
<option value="">-- No parent --</option>
|
||||
{locationIds
|
||||
.filter((id) => id !== selectedLoc.id)
|
||||
.map((id) => (
|
||||
<option key={id} value={id}>
|
||||
{getLocationDisplayNameById(id, locations)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ComboboxInput
|
||||
placeholder="Select a parent location..."
|
||||
showClear
|
||||
/>
|
||||
<ComboboxContent>
|
||||
<ComboboxEmpty>No locations found.</ComboboxEmpty>
|
||||
<ComboboxList>
|
||||
{(id: string) => (
|
||||
<ComboboxItem key={id} value={id}>
|
||||
{getLocationDisplayNameById(id, locations)}
|
||||
</ComboboxItem>
|
||||
)}
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
</div>
|
||||
|
||||
{/* Attributes for Location */}
|
||||
@@ -257,27 +270,35 @@ export function LocationsTab({
|
||||
<span className="text-[10px] font-semibold text-muted-foreground">
|
||||
Target Location
|
||||
</span>
|
||||
<select
|
||||
className="bg-card border border-border/30 px-2 py-1 text-xs outline-none w-full rounded"
|
||||
<Combobox
|
||||
items={locationIds.filter(
|
||||
(id) => id !== selectedLoc.id,
|
||||
)}
|
||||
value={conn.targetId}
|
||||
onChange={(e) =>
|
||||
onValueChange={(value) =>
|
||||
updateLocationConnection(
|
||||
selectedLocIndex,
|
||||
connIdx,
|
||||
"targetId",
|
||||
e.target.value,
|
||||
value ?? "",
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value="">-- Choose target --</option>
|
||||
{locationIds
|
||||
.filter((id) => id !== selectedLoc.id)
|
||||
.map((id) => (
|
||||
<option key={id} value={id}>
|
||||
{getLocationDisplayNameById(id, locations)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ComboboxInput
|
||||
placeholder="Choose target..."
|
||||
showClear
|
||||
/>
|
||||
<ComboboxContent>
|
||||
<ComboboxEmpty>No locations found.</ComboboxEmpty>
|
||||
<ComboboxList>
|
||||
{(id: string) => (
|
||||
<ComboboxItem key={id} value={id}>
|
||||
{getLocationDisplayNameById(id, locations)}
|
||||
</ComboboxItem>
|
||||
)}
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
@@ -42,6 +43,39 @@ export function MetadataTab({
|
||||
]);
|
||||
};
|
||||
|
||||
// Parse initial state from ISO string (or fallback to now)
|
||||
const parsedDate = useMemo(() => {
|
||||
try {
|
||||
const d = new Date(startTime);
|
||||
if (isNaN(d.getTime())) return new Date();
|
||||
return d;
|
||||
} catch {
|
||||
return new Date();
|
||||
}
|
||||
}, [startTime]);
|
||||
|
||||
const dateValue = useMemo(() => {
|
||||
return parsedDate.toISOString().split("T")[0];
|
||||
}, [parsedDate]);
|
||||
|
||||
const timeValue = useMemo(() => {
|
||||
return parsedDate.toISOString().split("T")[1].slice(0, 8);
|
||||
}, [parsedDate]);
|
||||
|
||||
const handleDateChange = (newDateStr: string) => {
|
||||
if (!newDateStr) return;
|
||||
const combined = `${newDateStr}T${timeValue}.000Z`;
|
||||
setStartTime(combined);
|
||||
};
|
||||
|
||||
const handleTimeChange = (newTimeStr: string) => {
|
||||
if (!newTimeStr) return;
|
||||
const formattedTime =
|
||||
newTimeStr.split(":").length === 2 ? `${newTimeStr}:00` : newTimeStr;
|
||||
const combined = `${dateValue}T${formattedTime}.000Z`;
|
||||
setStartTime(combined);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8 pb-12">
|
||||
{/* Basic Fields */}
|
||||
@@ -55,14 +89,9 @@ export function MetadataTab({
|
||||
<Label htmlFor="sc-id">Scenario Template ID</Label>
|
||||
<Input
|
||||
id="sc-id"
|
||||
placeholder="e.g. my-custom-scenario"
|
||||
value={scenarioId}
|
||||
onChange={(e) =>
|
||||
setScenarioId(
|
||||
e.target.value.toLowerCase().replace(/[^a-z0-9-_]/g, ""),
|
||||
)
|
||||
}
|
||||
className="font-mono text-xs"
|
||||
readOnly
|
||||
className="font-mono text-xs bg-muted cursor-not-allowed text-muted-foreground"
|
||||
/>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
Unique filename ID. Alphanumeric, hyphens and underscores only.
|
||||
@@ -70,16 +99,28 @@ export function MetadataTab({
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="sc-start">Start Time (ISO Date)</Label>
|
||||
<Input
|
||||
id="sc-start"
|
||||
placeholder="e.g. 2026-07-06T12:00:00.000Z"
|
||||
value={startTime}
|
||||
onChange={(e) => setStartTime(e.target.value)}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
<Label>Start Time</Label>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1 space-y-1">
|
||||
<Input
|
||||
type="date"
|
||||
value={dateValue}
|
||||
onChange={(e) => handleDateChange(e.target.value)}
|
||||
className="text-xs font-mono"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 space-y-1">
|
||||
<Input
|
||||
type="time"
|
||||
step="1"
|
||||
value={timeValue}
|
||||
onChange={(e) => handleTimeChange(e.target.value)}
|
||||
className="text-xs font-mono"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
Global clock starting timestamp.
|
||||
Global clock starting date and time (stored in ISO UTC).
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
300
apps/gui/src/components/ui/combobox.tsx
Normal file
300
apps/gui/src/components/ui/combobox.tsx
Normal file
@@ -0,0 +1,300 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Combobox as ComboboxPrimitive } from "@base-ui/react";
|
||||
import { CheckIcon, ChevronDownIcon, XIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupButton,
|
||||
InputGroupInput,
|
||||
} from "@/components/ui/input-group";
|
||||
|
||||
const Combobox = ComboboxPrimitive.Root;
|
||||
|
||||
function ComboboxValue({ ...props }: ComboboxPrimitive.Value.Props) {
|
||||
return <ComboboxPrimitive.Value data-slot="combobox-value" {...props} />;
|
||||
}
|
||||
|
||||
function ComboboxTrigger({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ComboboxPrimitive.Trigger.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Trigger
|
||||
data-slot="combobox-trigger"
|
||||
className={cn("[&_svg:not([class*='size-'])]:size-4", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
|
||||
</ComboboxPrimitive.Trigger>
|
||||
);
|
||||
}
|
||||
|
||||
function ComboboxClear({ className, ...props }: ComboboxPrimitive.Clear.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Clear
|
||||
data-slot="combobox-clear"
|
||||
render={<InputGroupButton variant="ghost" size="icon-xs" />}
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
>
|
||||
<XIcon className="pointer-events-none" />
|
||||
</ComboboxPrimitive.Clear>
|
||||
);
|
||||
}
|
||||
|
||||
function ComboboxInput({
|
||||
className,
|
||||
children,
|
||||
disabled = false,
|
||||
showTrigger = true,
|
||||
showClear = false,
|
||||
...props
|
||||
}: ComboboxPrimitive.Input.Props & {
|
||||
showTrigger?: boolean;
|
||||
showClear?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<InputGroup className={cn("w-auto", className)}>
|
||||
<ComboboxPrimitive.Input
|
||||
render={<InputGroupInput disabled={disabled} />}
|
||||
{...props}
|
||||
/>
|
||||
<InputGroupAddon align="inline-end">
|
||||
{showTrigger && (
|
||||
<InputGroupButton
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
render={<ComboboxTrigger />}
|
||||
data-slot="input-group-button"
|
||||
className="group-has-data-[slot=combobox-clear]/input-group:hidden data-pressed:bg-transparent"
|
||||
disabled={disabled}
|
||||
/>
|
||||
)}
|
||||
{showClear && <ComboboxClear disabled={disabled} />}
|
||||
</InputGroupAddon>
|
||||
{children}
|
||||
</InputGroup>
|
||||
);
|
||||
}
|
||||
|
||||
function ComboboxContent({
|
||||
className,
|
||||
side = "bottom",
|
||||
sideOffset = 6,
|
||||
align = "start",
|
||||
alignOffset = 0,
|
||||
anchor,
|
||||
...props
|
||||
}: ComboboxPrimitive.Popup.Props &
|
||||
Pick<
|
||||
ComboboxPrimitive.Positioner.Props,
|
||||
"side" | "align" | "sideOffset" | "alignOffset" | "anchor"
|
||||
>) {
|
||||
return (
|
||||
<ComboboxPrimitive.Portal>
|
||||
<ComboboxPrimitive.Positioner
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
anchor={anchor}
|
||||
className="isolate z-50"
|
||||
>
|
||||
<ComboboxPrimitive.Popup
|
||||
data-slot="combobox-content"
|
||||
data-chips={!!anchor}
|
||||
className={cn(
|
||||
"group/combobox-content relative max-h-(--available-height) w-(--anchor-width) max-w-(--available-width) min-w-[calc(var(--anchor-width)+--spacing(7))] origin-(--transform-origin) overflow-hidden rounded border-2 bg-popover text-popover-foreground shadow-md duration-100 data-[chips=true]:min-w-(--anchor-width) data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 *:data-[slot=input-group]:m-1 *:data-[slot=input-group]:mb-0 *:data-[slot=input-group]:h-8 *:data-[slot=input-group]:border-input/30 *:data-[slot=input-group]:bg-input/30 *:data-[slot=input-group]:shadow-none data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</ComboboxPrimitive.Positioner>
|
||||
</ComboboxPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
function ComboboxList({ className, ...props }: ComboboxPrimitive.List.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.List
|
||||
data-slot="combobox-list"
|
||||
className={cn(
|
||||
"no-scrollbar max-h-[min(calc(--spacing(72)---spacing(9)),calc(var(--available-height)---spacing(9)))] scroll-py-1 overflow-y-auto overscroll-contain p-1 data-empty:p-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ComboboxItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ComboboxPrimitive.Item.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Item
|
||||
data-slot="combobox-item"
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default items-center gap-2 rounded-sm py-1 pr-8 pl-1.5 text-sm outline-hidden select-none data-highlighted:bg-accent data-highlighted:text-accent-foreground not-data-[variant=destructive]:data-highlighted:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ComboboxPrimitive.ItemIndicator
|
||||
render={
|
||||
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
|
||||
}
|
||||
>
|
||||
<CheckIcon className="pointer-events-none" />
|
||||
</ComboboxPrimitive.ItemIndicator>
|
||||
</ComboboxPrimitive.Item>
|
||||
);
|
||||
}
|
||||
|
||||
function ComboboxGroup({ className, ...props }: ComboboxPrimitive.Group.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Group
|
||||
data-slot="combobox-group"
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ComboboxLabel({
|
||||
className,
|
||||
...props
|
||||
}: ComboboxPrimitive.GroupLabel.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.GroupLabel
|
||||
data-slot="combobox-label"
|
||||
className={cn("px-2 py-1.5 text-xs text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ComboboxCollection({ ...props }: ComboboxPrimitive.Collection.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Collection data-slot="combobox-collection" {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function ComboboxEmpty({ className, ...props }: ComboboxPrimitive.Empty.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Empty
|
||||
data-slot="combobox-empty"
|
||||
className={cn(
|
||||
"hidden w-full justify-center py-2 text-center text-sm text-muted-foreground group-data-empty/combobox-content:flex",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ComboboxSeparator({
|
||||
className,
|
||||
...props
|
||||
}: ComboboxPrimitive.Separator.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Separator
|
||||
data-slot="combobox-separator"
|
||||
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ComboboxChips({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentPropsWithRef<typeof ComboboxPrimitive.Chips> &
|
||||
ComboboxPrimitive.Chips.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Chips
|
||||
data-slot="combobox-chips"
|
||||
className={cn(
|
||||
"flex min-h-8 flex-wrap items-center gap-1 rounded border-2 bg-input bg-clip-padding px-2.5 py-1 text-sm shadow-sm transition-colors focus-within:outline-2 focus-within:outline-offset-2 focus-within:outline-primary has-aria-invalid:border-destructive has-data-[slot=combobox-chip]:px-1",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ComboboxChip({
|
||||
className,
|
||||
children,
|
||||
showRemove = true,
|
||||
...props
|
||||
}: ComboboxPrimitive.Chip.Props & {
|
||||
showRemove?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<ComboboxPrimitive.Chip
|
||||
data-slot="combobox-chip"
|
||||
className={cn(
|
||||
"flex h-[calc(--spacing(5.25))] w-fit items-center justify-center gap-1 rounded-sm border-2 bg-muted px-1.5 text-xs font-medium whitespace-nowrap text-foreground has-disabled:pointer-events-none has-disabled:cursor-not-allowed has-disabled:opacity-50 has-data-[slot=combobox-chip-remove]:pr-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showRemove && (
|
||||
<ComboboxPrimitive.ChipRemove
|
||||
render={<Button variant="ghost" size="icon-xs" />}
|
||||
className="-ml-1 opacity-50 hover:opacity-100"
|
||||
data-slot="combobox-chip-remove"
|
||||
>
|
||||
<XIcon className="pointer-events-none" />
|
||||
</ComboboxPrimitive.ChipRemove>
|
||||
)}
|
||||
</ComboboxPrimitive.Chip>
|
||||
);
|
||||
}
|
||||
|
||||
function ComboboxChipsInput({
|
||||
className,
|
||||
...props
|
||||
}: ComboboxPrimitive.Input.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Input
|
||||
data-slot="combobox-chip-input"
|
||||
className={cn("min-w-16 flex-1 outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function useComboboxAnchor() {
|
||||
return React.useRef<HTMLDivElement | null>(null);
|
||||
}
|
||||
|
||||
export {
|
||||
Combobox,
|
||||
ComboboxInput,
|
||||
ComboboxContent,
|
||||
ComboboxList,
|
||||
ComboboxItem,
|
||||
ComboboxGroup,
|
||||
ComboboxLabel,
|
||||
ComboboxCollection,
|
||||
ComboboxEmpty,
|
||||
ComboboxSeparator,
|
||||
ComboboxChips,
|
||||
ComboboxChip,
|
||||
ComboboxChipsInput,
|
||||
ComboboxTrigger,
|
||||
ComboboxValue,
|
||||
useComboboxAnchor,
|
||||
};
|
||||
170
apps/gui/src/components/ui/input-group.tsx
Normal file
170
apps/gui/src/components/ui/input-group.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="input-group"
|
||||
role="group"
|
||||
className={cn(
|
||||
"group/input-group border-input dark:bg-input/30 shadow-xs relative flex w-full items-center rounded-md border outline-none transition-[color,box-shadow]",
|
||||
"h-9 has-[>textarea]:h-auto",
|
||||
|
||||
// Variants based on alignment.
|
||||
"has-[>[data-align=inline-start]]:[&>input]:pl-2",
|
||||
"has-[>[data-align=inline-end]]:[&>input]:pr-2",
|
||||
"has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3",
|
||||
"has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3",
|
||||
|
||||
// Focus state.
|
||||
"has-[[data-slot=input-group-control]:focus-visible]:ring-ring has-[[data-slot=input-group-control]:focus-visible]:ring-1",
|
||||
|
||||
// Error state.
|
||||
"has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40",
|
||||
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const inputGroupAddonVariants = cva(
|
||||
"text-muted-foreground flex h-auto cursor-text select-none items-center justify-center gap-2 py-1.5 text-sm font-medium group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
align: {
|
||||
"inline-start":
|
||||
"order-first pl-3 has-[>button]:ml-[-0.45rem] has-[>kbd]:ml-[-0.35rem]",
|
||||
"inline-end":
|
||||
"order-last pr-3 has-[>button]:mr-[-0.4rem] has-[>kbd]:mr-[-0.35rem]",
|
||||
"block-start":
|
||||
"[.border-b]:pb-3 order-first w-full justify-start px-3 pt-3 group-has-[>input]/input-group:pt-2.5",
|
||||
"block-end":
|
||||
"[.border-t]:pt-3 order-last w-full justify-start px-3 pb-3 group-has-[>input]/input-group:pb-2.5",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
align: "inline-start",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function InputGroupAddon({
|
||||
className,
|
||||
align = "inline-start",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
data-slot="input-group-addon"
|
||||
data-align={align}
|
||||
className={cn(inputGroupAddonVariants({ align }), className)}
|
||||
onClick={(e) => {
|
||||
if ((e.target as HTMLElement).closest("button")) {
|
||||
return;
|
||||
}
|
||||
e.currentTarget.parentElement?.querySelector("input")?.focus();
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const inputGroupButtonVariants = cva(
|
||||
"flex items-center gap-2 text-sm shadow-none",
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
xs: "h-6 gap-1 rounded-[calc(var(--radius)-5px)] px-2 has-[>svg]:px-2 [&>svg:not([class*='size-'])]:size-3.5",
|
||||
sm: "h-8 gap-1.5 rounded-md px-2.5 has-[>svg]:px-2.5",
|
||||
"icon-xs":
|
||||
"size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0",
|
||||
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: "xs",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function InputGroupButton({
|
||||
className,
|
||||
type = "button",
|
||||
variant = "ghost",
|
||||
size = "xs",
|
||||
...props
|
||||
}: Omit<React.ComponentProps<typeof Button>, "size"> &
|
||||
VariantProps<typeof inputGroupButtonVariants>) {
|
||||
return (
|
||||
<Button
|
||||
type={type}
|
||||
data-size={size}
|
||||
variant={variant}
|
||||
className={cn(inputGroupButtonVariants({ size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"text-muted-foreground flex items-center gap-2 text-sm [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function InputGroupInput({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<Input
|
||||
data-slot="input-group-control"
|
||||
className={cn(
|
||||
"flex-1 rounded-none border-0 bg-transparent shadow-none focus-visible:ring-0 dark:bg-transparent",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function InputGroupTextarea({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"textarea">) {
|
||||
return (
|
||||
<Textarea
|
||||
data-slot="input-group-control"
|
||||
className={cn(
|
||||
"flex-1 resize-none rounded-none border-0 bg-transparent py-3 shadow-none focus-visible:ring-0 dark:bg-transparent",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupButton,
|
||||
InputGroupText,
|
||||
InputGroupInput,
|
||||
InputGroupTextarea,
|
||||
};
|
||||
Reference in New Issue
Block a user