Bundles in-flight ECHODO work with the Coolify deployment configuration: App - New routes: ai, forms, planner, settings (templates/types), teams, doc detail, whiteboard detail - New components: app shell rework (icon-rail, top-header), forms builder/renderer/responses, types manager, objects creation dialog, card primitive, form + overview views - New tRPC routers: favorites, forms, types, workspaces; updates to health and objects routers - Markdown backlog sync (packages/database) + cursor-sync schema/migrations - Schema additions: forms, types, favorites, markdown_backlog, cursor_sync - Initial Drizzle migrations checked in Deployment - docker/docker-compose.coolify.yml: drops bundled Postgres/Redis (uses CT 102 shared services), removes host port mappings, adds Coolify SERVICE_FQDN_* magic vars for web + collab - .env.example rewritten as the full ECHODO/Coolify variable manifest - NextAuth gains an Authentik OIDC provider (gated on env presence) - Root layout injects Umami tracking script when configured; metadata title flipped to ECHODO Security - .gitignore expanded to exclude AGENT-DEPLOY.md, .env.*, secrets/, credentials.*, *.key, *.crt, *.pem, ssh keys Made-with: Cursor
182 lines
4.8 KiB
TypeScript
182 lines
4.8 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useState } from "react";
|
|
import { useSortable } from "@dnd-kit/sortable";
|
|
import { CSS } from "@dnd-kit/utilities";
|
|
import { GripVertical, Trash2 } from "lucide-react";
|
|
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
type FormField = {
|
|
id: string;
|
|
label: string;
|
|
type: string;
|
|
required: boolean;
|
|
placeholder?: string;
|
|
helpText?: string;
|
|
options?: { label: string; value: string }[];
|
|
defaultValue?: unknown;
|
|
mappedProperty: string | null;
|
|
validation?: {
|
|
min?: number;
|
|
max?: number;
|
|
pattern?: string;
|
|
maxLength?: number;
|
|
};
|
|
conditionals?: {
|
|
fieldId: string;
|
|
operator: string;
|
|
value: unknown;
|
|
action: string;
|
|
}[];
|
|
};
|
|
|
|
function formatTypeLabel(type: string) {
|
|
return type
|
|
.split("_")
|
|
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
|
.join(" ");
|
|
}
|
|
|
|
export function FormFieldCard({
|
|
field,
|
|
isSelected,
|
|
onSelect,
|
|
onChange,
|
|
onDelete,
|
|
}: {
|
|
field: FormField;
|
|
isSelected: boolean;
|
|
onSelect: () => void;
|
|
onChange: (patch: Partial<FormField>) => void;
|
|
onDelete: () => void;
|
|
}) {
|
|
const {
|
|
attributes,
|
|
listeners,
|
|
setNodeRef,
|
|
transform,
|
|
transition,
|
|
isDragging,
|
|
} = useSortable({ id: field.id });
|
|
|
|
const style = {
|
|
transform: CSS.Transform.toString(transform),
|
|
transition,
|
|
};
|
|
|
|
const [editingLabel, setEditingLabel] = useState(false);
|
|
const [labelDraft, setLabelDraft] = useState(field.label);
|
|
|
|
useEffect(() => {
|
|
setLabelDraft(field.label);
|
|
}, [field.label]);
|
|
|
|
return (
|
|
<div
|
|
ref={setNodeRef}
|
|
style={style}
|
|
className={cn(
|
|
"flex items-stretch gap-2 rounded-lg border bg-card p-2 shadow-sm transition-shadow",
|
|
isSelected && "ring-2 ring-ring ring-offset-2 ring-offset-background",
|
|
isDragging && "z-10 opacity-90 shadow-md",
|
|
)}
|
|
>
|
|
<button
|
|
type="button"
|
|
className="flex shrink-0 cursor-grab touch-none items-center rounded-md border border-transparent px-1 text-muted-foreground hover:bg-muted active:cursor-grabbing"
|
|
aria-label="Drag to reorder"
|
|
{...attributes}
|
|
{...listeners}
|
|
>
|
|
<GripVertical className="size-4" />
|
|
</button>
|
|
|
|
<div
|
|
role="button"
|
|
tabIndex={0}
|
|
className="min-w-0 flex-1 cursor-pointer text-left"
|
|
onClick={onSelect}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter" || e.key === " ") {
|
|
e.preventDefault();
|
|
onSelect();
|
|
}
|
|
}}
|
|
>
|
|
{editingLabel ? (
|
|
<Input
|
|
autoFocus
|
|
value={labelDraft}
|
|
onChange={(e) => setLabelDraft(e.target.value)}
|
|
onBlur={() => {
|
|
setEditingLabel(false);
|
|
if (labelDraft.trim() !== field.label) {
|
|
onChange({ label: labelDraft.trim() || "Untitled" });
|
|
}
|
|
}}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter") (e.target as HTMLInputElement).blur();
|
|
e.stopPropagation();
|
|
}}
|
|
className="h-8"
|
|
onClick={(e) => e.stopPropagation()}
|
|
/>
|
|
) : (
|
|
<button
|
|
type="button"
|
|
className="block w-full truncate text-left font-medium text-foreground hover:underline"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
setEditingLabel(true);
|
|
}}
|
|
>
|
|
{field.label || "Untitled"}
|
|
</button>
|
|
)}
|
|
<div className="mt-1 flex flex-wrap items-center gap-2">
|
|
<Badge variant="secondary" className="font-normal">
|
|
{formatTypeLabel(field.type)}
|
|
</Badge>
|
|
{field.required ? (
|
|
<Badge variant="outline" className="text-xs">
|
|
Required
|
|
</Badge>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex shrink-0 flex-col items-end justify-center gap-1">
|
|
<label className="flex cursor-pointer items-center gap-1.5 text-xs text-muted-foreground">
|
|
<input
|
|
type="checkbox"
|
|
checked={field.required}
|
|
onChange={(e) => {
|
|
e.stopPropagation();
|
|
onChange({ required: e.target.checked });
|
|
}}
|
|
onClick={(e) => e.stopPropagation()}
|
|
className="size-3.5 rounded border-input accent-primary"
|
|
/>
|
|
Req.
|
|
</label>
|
|
<Button
|
|
type="button"
|
|
size="icon"
|
|
variant="ghost"
|
|
className="size-8 text-muted-foreground hover:text-destructive"
|
|
aria-label="Delete field"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onDelete();
|
|
}}
|
|
>
|
|
<Trash2 className="size-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|