323 lines
8.9 KiB
TypeScript
323 lines
8.9 KiB
TypeScript
|
|
"use client";
|
||
|
|
|
||
|
|
import * as React from "react";
|
||
|
|
import {
|
||
|
|
ExternalLink,
|
||
|
|
FileIcon,
|
||
|
|
Link2,
|
||
|
|
User,
|
||
|
|
X,
|
||
|
|
} from "lucide-react";
|
||
|
|
|
||
|
|
import { cn } from "@/lib/utils";
|
||
|
|
import {
|
||
|
|
Avatar,
|
||
|
|
AvatarFallback,
|
||
|
|
AvatarImage,
|
||
|
|
} from "@/components/ui/avatar";
|
||
|
|
import { Badge } from "@/components/ui/badge";
|
||
|
|
import { Button } from "@/components/ui/button";
|
||
|
|
import {
|
||
|
|
DropdownMenu,
|
||
|
|
DropdownMenuContent,
|
||
|
|
DropdownMenuItem,
|
||
|
|
DropdownMenuTrigger,
|
||
|
|
} from "@/components/ui/dropdown-menu";
|
||
|
|
import { Input } from "@/components/ui/input";
|
||
|
|
|
||
|
|
export type PropertyFieldType =
|
||
|
|
| "text"
|
||
|
|
| "richtext"
|
||
|
|
| "number"
|
||
|
|
| "date"
|
||
|
|
| "select"
|
||
|
|
| "multiselect"
|
||
|
|
| "person"
|
||
|
|
| "relation"
|
||
|
|
| "url"
|
||
|
|
| "file"
|
||
|
|
| "checkbox";
|
||
|
|
|
||
|
|
export interface PropertyDefinitionInput {
|
||
|
|
name: string;
|
||
|
|
fieldType: PropertyFieldType;
|
||
|
|
config?: {
|
||
|
|
options?: { label: string; value: string }[];
|
||
|
|
[key: string]: unknown;
|
||
|
|
} | null;
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface PropertyEditorProps {
|
||
|
|
definition: PropertyDefinitionInput;
|
||
|
|
value: unknown;
|
||
|
|
onChange: (value: unknown) => void;
|
||
|
|
className?: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
function initials(name: string) {
|
||
|
|
return name
|
||
|
|
.split(/\s+/)
|
||
|
|
.map((p) => p[0])
|
||
|
|
.join("")
|
||
|
|
.slice(0, 2)
|
||
|
|
.toUpperCase();
|
||
|
|
}
|
||
|
|
|
||
|
|
export function PropertyEditor({
|
||
|
|
definition,
|
||
|
|
value,
|
||
|
|
onChange,
|
||
|
|
className,
|
||
|
|
}: PropertyEditorProps) {
|
||
|
|
const { name, fieldType, config } = definition;
|
||
|
|
const options = config?.options ?? [];
|
||
|
|
|
||
|
|
const row = (control: React.ReactNode) => (
|
||
|
|
<div
|
||
|
|
className={cn(
|
||
|
|
"grid grid-cols-[minmax(0,1fr)_minmax(0,1.4fr)] items-center gap-2 py-1.5",
|
||
|
|
className,
|
||
|
|
)}
|
||
|
|
>
|
||
|
|
<label className="truncate text-xs font-medium text-muted-foreground">
|
||
|
|
{name}
|
||
|
|
</label>
|
||
|
|
<div className="min-w-0">{control}</div>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
|
||
|
|
switch (fieldType) {
|
||
|
|
case "text":
|
||
|
|
return row(
|
||
|
|
<Input
|
||
|
|
value={String(value ?? "")}
|
||
|
|
onChange={(e) => onChange(e.target.value)}
|
||
|
|
className="h-8 text-xs"
|
||
|
|
/>,
|
||
|
|
);
|
||
|
|
|
||
|
|
case "richtext":
|
||
|
|
return row(
|
||
|
|
<textarea
|
||
|
|
value={String(value ?? "")}
|
||
|
|
onChange={(e) => onChange(e.target.value)}
|
||
|
|
rows={3}
|
||
|
|
className="flex min-h-[72px] w-full resize-y rounded-md border border-input bg-background px-2 py-1.5 text-xs ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1"
|
||
|
|
placeholder="Rich text (TipTap later)"
|
||
|
|
/>,
|
||
|
|
);
|
||
|
|
|
||
|
|
case "number": {
|
||
|
|
const n =
|
||
|
|
value === null || value === undefined || value === ""
|
||
|
|
? ""
|
||
|
|
: Number(value);
|
||
|
|
return row(
|
||
|
|
<Input
|
||
|
|
type="number"
|
||
|
|
value={Number.isFinite(n as number) ? String(n) : ""}
|
||
|
|
onChange={(e) => {
|
||
|
|
const v = e.target.value;
|
||
|
|
onChange(v === "" ? null : Number(v));
|
||
|
|
}}
|
||
|
|
className="h-8 text-xs"
|
||
|
|
/>,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
case "date":
|
||
|
|
return row(
|
||
|
|
<Input
|
||
|
|
type="date"
|
||
|
|
value={String(value ?? "").slice(0, 10)}
|
||
|
|
onChange={(e) => onChange(e.target.value || null)}
|
||
|
|
className="h-8 text-xs"
|
||
|
|
/>,
|
||
|
|
);
|
||
|
|
|
||
|
|
case "select": {
|
||
|
|
const current = String(value ?? "");
|
||
|
|
const label =
|
||
|
|
options.find((o) => o.value === current)?.label ??
|
||
|
|
(current || "—");
|
||
|
|
return row(
|
||
|
|
<DropdownMenu>
|
||
|
|
<DropdownMenuTrigger asChild>
|
||
|
|
<Button
|
||
|
|
variant="outline"
|
||
|
|
size="sm"
|
||
|
|
className="h-8 w-full justify-between px-2 text-xs font-normal"
|
||
|
|
>
|
||
|
|
<span className="truncate">{label}</span>
|
||
|
|
</Button>
|
||
|
|
</DropdownMenuTrigger>
|
||
|
|
<DropdownMenuContent align="start" className="w-[var(--radix-dropdown-menu-trigger-width)]">
|
||
|
|
{options.map((opt) => (
|
||
|
|
<DropdownMenuItem
|
||
|
|
key={opt.value}
|
||
|
|
className="text-xs"
|
||
|
|
onClick={() => onChange(opt.value)}
|
||
|
|
>
|
||
|
|
{opt.label}
|
||
|
|
</DropdownMenuItem>
|
||
|
|
))}
|
||
|
|
</DropdownMenuContent>
|
||
|
|
</DropdownMenu>,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
case "multiselect": {
|
||
|
|
const arr = Array.isArray(value)
|
||
|
|
? (value as string[])
|
||
|
|
: value
|
||
|
|
? [String(value)]
|
||
|
|
: [];
|
||
|
|
const toggle = (v: string) => {
|
||
|
|
if (arr.includes(v)) onChange(arr.filter((x) => x !== v));
|
||
|
|
else onChange([...arr, v]);
|
||
|
|
};
|
||
|
|
return row(
|
||
|
|
<div className="flex flex-wrap gap-1">
|
||
|
|
{arr.map((v) => (
|
||
|
|
<Badge
|
||
|
|
key={v}
|
||
|
|
variant="secondary"
|
||
|
|
className="gap-0.5 pr-1 text-[10px] font-normal"
|
||
|
|
>
|
||
|
|
{options.find((o) => o.value === v)?.label ?? v}
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
className="ml-0.5 rounded p-0.5 hover:bg-muted"
|
||
|
|
onClick={() => toggle(v)}
|
||
|
|
aria-label={`Remove ${v}`}
|
||
|
|
>
|
||
|
|
<X className="size-3" />
|
||
|
|
</button>
|
||
|
|
</Badge>
|
||
|
|
))}
|
||
|
|
<DropdownMenu>
|
||
|
|
<DropdownMenuTrigger asChild>
|
||
|
|
<Button variant="ghost" size="sm" className="h-6 px-2 text-[10px]">
|
||
|
|
+ Add
|
||
|
|
</Button>
|
||
|
|
</DropdownMenuTrigger>
|
||
|
|
<DropdownMenuContent align="start" className="max-h-48 overflow-y-auto">
|
||
|
|
{options.map((opt) => (
|
||
|
|
<DropdownMenuItem
|
||
|
|
key={opt.value}
|
||
|
|
className="text-xs"
|
||
|
|
onClick={() => toggle(opt.value)}
|
||
|
|
>
|
||
|
|
{opt.label}
|
||
|
|
{arr.includes(opt.value) ? " ✓" : ""}
|
||
|
|
</DropdownMenuItem>
|
||
|
|
))}
|
||
|
|
</DropdownMenuContent>
|
||
|
|
</DropdownMenu>
|
||
|
|
</div>,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
case "person": {
|
||
|
|
const raw = value as
|
||
|
|
| { user?: { name?: string; avatarUrl?: string | null } }
|
||
|
|
| { name?: string; avatarUrl?: string | null }
|
||
|
|
| null
|
||
|
|
| undefined;
|
||
|
|
const person =
|
||
|
|
raw && typeof raw === "object" && "user" in raw && raw.user
|
||
|
|
? raw.user
|
||
|
|
: (raw as { name?: string; avatarUrl?: string | null } | undefined);
|
||
|
|
const displayName = person?.name ?? "Unassigned";
|
||
|
|
return row(
|
||
|
|
<div className="flex items-center gap-2">
|
||
|
|
<Avatar className="size-7 border border-border">
|
||
|
|
<AvatarImage src={person?.avatarUrl ?? undefined} alt="" />
|
||
|
|
<AvatarFallback className="text-[10px] font-medium">
|
||
|
|
{displayName === "Unassigned" ? (
|
||
|
|
<User className="size-3.5 opacity-70" />
|
||
|
|
) : (
|
||
|
|
initials(displayName)
|
||
|
|
)}
|
||
|
|
</AvatarFallback>
|
||
|
|
</Avatar>
|
||
|
|
<span className="truncate text-xs">{displayName}</span>
|
||
|
|
</div>,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
case "relation": {
|
||
|
|
const rel = value as
|
||
|
|
| { id?: string; title?: string }
|
||
|
|
| string
|
||
|
|
| null
|
||
|
|
| undefined;
|
||
|
|
const title =
|
||
|
|
typeof rel === "object" && rel && "title" in rel
|
||
|
|
? String(rel.title)
|
||
|
|
: typeof rel === "string"
|
||
|
|
? rel
|
||
|
|
: "—";
|
||
|
|
return row(
|
||
|
|
<Button
|
||
|
|
variant="link"
|
||
|
|
className="h-auto min-h-0 justify-start p-0 text-xs text-primary"
|
||
|
|
type="button"
|
||
|
|
>
|
||
|
|
<Link2 className="mr-1 size-3.5 shrink-0" />
|
||
|
|
<span className="truncate">{title}</span>
|
||
|
|
</Button>,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
case "url":
|
||
|
|
return row(
|
||
|
|
<div className="relative">
|
||
|
|
<ExternalLink className="pointer-events-none absolute left-2 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
|
||
|
|
<Input
|
||
|
|
type="url"
|
||
|
|
value={String(value ?? "")}
|
||
|
|
onChange={(e) => onChange(e.target.value)}
|
||
|
|
className="h-8 pl-8 text-xs"
|
||
|
|
placeholder="https://"
|
||
|
|
/>
|
||
|
|
</div>,
|
||
|
|
);
|
||
|
|
|
||
|
|
case "file":
|
||
|
|
return row(
|
||
|
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||
|
|
<FileIcon className="size-3.5 shrink-0" />
|
||
|
|
<span className="truncate">{String(value ?? "No file")}</span>
|
||
|
|
</div>,
|
||
|
|
);
|
||
|
|
|
||
|
|
case "checkbox":
|
||
|
|
return row(
|
||
|
|
<label className="flex cursor-pointer items-center gap-2">
|
||
|
|
<input
|
||
|
|
type="checkbox"
|
||
|
|
checked={Boolean(value)}
|
||
|
|
onChange={(e) => onChange(e.target.checked)}
|
||
|
|
className={cn(
|
||
|
|
"size-4 rounded border border-input bg-background",
|
||
|
|
"text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1",
|
||
|
|
)}
|
||
|
|
/>
|
||
|
|
<span className="text-xs text-muted-foreground">
|
||
|
|
{Boolean(value) ? "Yes" : "No"}
|
||
|
|
</span>
|
||
|
|
</label>,
|
||
|
|
);
|
||
|
|
|
||
|
|
default:
|
||
|
|
return row(
|
||
|
|
<Input
|
||
|
|
value={String(value ?? "")}
|
||
|
|
onChange={(e) => onChange(e.target.value)}
|
||
|
|
className="h-8 text-xs"
|
||
|
|
/>,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|