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
425 lines
14 KiB
TypeScript
425 lines
14 KiB
TypeScript
"use client";
|
|
|
|
import * as React from "react";
|
|
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
|
import { useRouter } from "next/navigation";
|
|
import {
|
|
CheckSquare,
|
|
ClipboardList,
|
|
FileStack,
|
|
FileText,
|
|
LayoutGrid,
|
|
Presentation,
|
|
X,
|
|
} from "lucide-react";
|
|
|
|
import { TemplatePicker, type PickerTemplate } from "@/components/templates";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { api } from "@/lib/trpc";
|
|
import { useWorkspaceStore } from "@/lib/stores/workspace-store";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
const CREATE_TYPES = ["task", "document", "space", "whiteboard", "form"] as const;
|
|
type CreateObjectType = (typeof CREATE_TYPES)[number];
|
|
|
|
const TYPE_OPTIONS: {
|
|
value: CreateObjectType;
|
|
label: string;
|
|
icon: React.ComponentType<{ className?: string }>;
|
|
}[] = [
|
|
{ value: "task", label: "Task", icon: CheckSquare },
|
|
{ value: "document", label: "Document", icon: FileText },
|
|
{ value: "space", label: "Space", icon: LayoutGrid },
|
|
{ value: "whiteboard", label: "Whiteboard", icon: Presentation },
|
|
{ value: "form", label: "Form", icon: ClipboardList },
|
|
];
|
|
|
|
const TASK_STATUSES = [
|
|
{ value: "open", label: "Open" },
|
|
{ value: "in_progress", label: "In Progress" },
|
|
{ value: "done", label: "Done" },
|
|
] as const;
|
|
|
|
const selectFieldClass =
|
|
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50";
|
|
|
|
function normalizeDefaultType(raw?: string): CreateObjectType {
|
|
if (!raw) return "task";
|
|
const v = raw.toLowerCase().trim().replace(/\s+/g, "_");
|
|
if (v === "doc") return "document";
|
|
if (v === "white_board") return "whiteboard";
|
|
if (v === "list") return "task";
|
|
if (v === "form") return "form";
|
|
if ((CREATE_TYPES as readonly string[]).includes(v)) {
|
|
return v as CreateObjectType;
|
|
}
|
|
return "task";
|
|
}
|
|
|
|
export interface CreateObjectDialogProps {
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
defaultType?: string;
|
|
defaultParentId?: string;
|
|
workspaceId?: string;
|
|
}
|
|
|
|
export function CreateObjectDialog({
|
|
open,
|
|
onOpenChange,
|
|
defaultType,
|
|
defaultParentId,
|
|
workspaceId: workspaceIdProp,
|
|
}: CreateObjectDialogProps) {
|
|
const router = useRouter();
|
|
const storeWorkspaceId = useWorkspaceStore((s) => s.currentWorkspace?.id);
|
|
const resolvedWorkspaceId = workspaceIdProp ?? storeWorkspaceId ?? undefined;
|
|
|
|
const utils = api.useUtils();
|
|
const titleInputRef = React.useRef<HTMLInputElement>(null);
|
|
const selectedTemplateRef = React.useRef<PickerTemplate | null>(null);
|
|
|
|
const [objectType, setObjectType] = React.useState<CreateObjectType>(() =>
|
|
normalizeDefaultType(defaultType),
|
|
);
|
|
const [title, setTitle] = React.useState("");
|
|
const [titleError, setTitleError] = React.useState(false);
|
|
const [parentId, setParentId] = React.useState("");
|
|
const [taskStatus, setTaskStatus] = React.useState<
|
|
(typeof TASK_STATUSES)[number]["value"]
|
|
>("open");
|
|
const [selectedTemplate, setSelectedTemplate] = React.useState<PickerTemplate | null>(
|
|
null,
|
|
);
|
|
const [showTemplatePicker, setShowTemplatePicker] = React.useState(false);
|
|
|
|
React.useEffect(() => {
|
|
selectedTemplateRef.current = selectedTemplate;
|
|
}, [selectedTemplate]);
|
|
|
|
const showParentPicker =
|
|
objectType === "task" || objectType === "document";
|
|
|
|
const spacesQuery = api.objects.list.useQuery(
|
|
{
|
|
workspaceId: resolvedWorkspaceId!,
|
|
type: "space",
|
|
limit: 500,
|
|
},
|
|
{ enabled: Boolean(open && resolvedWorkspaceId && showParentPicker) },
|
|
);
|
|
|
|
const spaces = spacesQuery.data?.objects ?? [];
|
|
|
|
React.useEffect(() => {
|
|
if (!open) return;
|
|
setObjectType(normalizeDefaultType(defaultType));
|
|
setTitle("");
|
|
setTitleError(false);
|
|
setParentId(defaultParentId ?? "");
|
|
setTaskStatus("open");
|
|
setSelectedTemplate(null);
|
|
setShowTemplatePicker(false);
|
|
}, [open, defaultType, defaultParentId]);
|
|
|
|
React.useEffect(() => {
|
|
if (!open) return;
|
|
const t = window.setTimeout(() => titleInputRef.current?.focus(), 0);
|
|
return () => window.clearTimeout(t);
|
|
}, [open]);
|
|
|
|
const applyTemplateMutation = api.templates.applyTemplate.useMutation();
|
|
|
|
const createMutation = api.objects.create.useMutation({
|
|
onSuccess: async (newObj) => {
|
|
const t = selectedTemplateRef.current;
|
|
if (t && newObj?.id) {
|
|
try {
|
|
await applyTemplateMutation.mutateAsync({
|
|
templateId: t.id,
|
|
objectId: newObj.id,
|
|
});
|
|
} catch {
|
|
// Template application failure shouldn't block creation
|
|
}
|
|
}
|
|
await utils.objects.list.invalidate();
|
|
await utils.objects.getTree.invalidate();
|
|
onOpenChange(false);
|
|
},
|
|
});
|
|
|
|
const createFormMutation = api.forms.create.useMutation({
|
|
onSuccess: (data) => {
|
|
utils.objects.getTree.invalidate();
|
|
const newId = (data as { id?: string }).id;
|
|
if (newId && resolvedWorkspaceId) {
|
|
router.push(`/${resolvedWorkspaceId}/forms/${newId}/edit`);
|
|
}
|
|
onOpenChange(false);
|
|
},
|
|
});
|
|
|
|
const handleSubmit = (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
const trimmed = title.trim();
|
|
if (!trimmed) {
|
|
setTitleError(true);
|
|
return;
|
|
}
|
|
if (!resolvedWorkspaceId) {
|
|
return;
|
|
}
|
|
setTitleError(false);
|
|
|
|
if (objectType === "form") {
|
|
createFormMutation.mutate({
|
|
workspaceId: resolvedWorkspaceId,
|
|
title: trimmed,
|
|
});
|
|
return;
|
|
}
|
|
|
|
const parentForCreate =
|
|
objectType === "task" || objectType === "document"
|
|
? parentId || null
|
|
: null;
|
|
|
|
createMutation.mutate({
|
|
type: objectType,
|
|
title: trimmed,
|
|
workspaceId: resolvedWorkspaceId,
|
|
parentId: parentForCreate,
|
|
...(objectType === "task" ? { status: taskStatus } : {}),
|
|
});
|
|
};
|
|
|
|
const isSubmitting =
|
|
createMutation.isPending ||
|
|
createFormMutation.isPending ||
|
|
applyTemplateMutation.isPending;
|
|
|
|
return (
|
|
<DialogPrimitive.Root open={open} onOpenChange={onOpenChange}>
|
|
<DialogPrimitive.Portal>
|
|
<DialogPrimitive.Overlay
|
|
className={cn(
|
|
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
|
)}
|
|
/>
|
|
<DialogPrimitive.Content
|
|
className={cn(
|
|
"fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 border bg-background p-6 shadow-lg duration-200",
|
|
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
|
|
"rounded-lg",
|
|
)}
|
|
onOpenAutoFocus={(ev) => ev.preventDefault()}
|
|
>
|
|
<div className="flex flex-col gap-1.5 pr-8 text-left">
|
|
<DialogPrimitive.Title className="text-lg font-semibold leading-none tracking-tight">
|
|
Create object
|
|
</DialogPrimitive.Title>
|
|
<DialogPrimitive.Description className="sr-only">
|
|
Choose a type, enter a title, and optional parent space or task
|
|
status.
|
|
</DialogPrimitive.Description>
|
|
</div>
|
|
|
|
<DialogPrimitive.Close
|
|
className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none"
|
|
aria-label="Close"
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</DialogPrimitive.Close>
|
|
|
|
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
|
{!resolvedWorkspaceId ? (
|
|
<p className="text-sm text-muted-foreground">
|
|
Select a workspace to create objects.
|
|
</p>
|
|
) : null}
|
|
|
|
<div className="space-y-2">
|
|
<span className="text-sm font-medium">Type</span>
|
|
<div
|
|
className="grid grid-cols-2 gap-2 sm:grid-cols-5"
|
|
role="radiogroup"
|
|
aria-label="Object type"
|
|
>
|
|
{TYPE_OPTIONS.map(({ value, label, icon: Icon }) => {
|
|
const selected = objectType === value;
|
|
return (
|
|
<button
|
|
key={value}
|
|
type="button"
|
|
role="radio"
|
|
aria-checked={selected}
|
|
onClick={() => setObjectType(value)}
|
|
className={cn(
|
|
"flex flex-col items-center gap-2 rounded-md border p-3 text-center transition-colors",
|
|
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
|
selected
|
|
? "border-primary bg-accent"
|
|
: "border-input bg-background hover:bg-accent/50",
|
|
)}
|
|
>
|
|
<Badge
|
|
variant={selected ? "default" : "outline"}
|
|
className="gap-1 px-2 py-1 font-normal"
|
|
>
|
|
<Icon className="size-4" />
|
|
<span>{label}</span>
|
|
</Badge>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
{(objectType === "task" || objectType === "document") && (
|
|
<div className="space-y-2">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-sm font-medium">Template</span>
|
|
{selectedTemplate ? (
|
|
<button
|
|
type="button"
|
|
className="text-xs text-muted-foreground hover:text-foreground"
|
|
onClick={() => setSelectedTemplate(null)}
|
|
>
|
|
Clear
|
|
</button>
|
|
) : null}
|
|
</div>
|
|
{selectedTemplate ? (
|
|
<div className="flex items-center gap-2 rounded-md border bg-accent/50 px-3 py-2 text-sm">
|
|
<Badge variant="secondary" className="gap-1">
|
|
<FileStack className="size-3" />
|
|
{selectedTemplate.name}
|
|
</Badge>
|
|
</div>
|
|
) : (
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
className="w-full gap-1.5"
|
|
onClick={() => setShowTemplatePicker(true)}
|
|
>
|
|
<FileStack className="size-4" />
|
|
Use Template
|
|
</Button>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<div className="space-y-2">
|
|
<label htmlFor="create-object-title" className="text-sm font-medium">
|
|
Title
|
|
</label>
|
|
<Input
|
|
id="create-object-title"
|
|
ref={titleInputRef}
|
|
value={title}
|
|
onChange={(ev) => {
|
|
setTitle(ev.target.value);
|
|
if (titleError) setTitleError(false);
|
|
}}
|
|
placeholder="Name"
|
|
autoComplete="off"
|
|
aria-invalid={titleError}
|
|
/>
|
|
{titleError ? (
|
|
<p className="text-sm text-destructive" role="alert">
|
|
Title is required
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
|
|
{showParentPicker ? (
|
|
<div className="space-y-2">
|
|
<label
|
|
htmlFor="create-object-parent"
|
|
className="text-sm font-medium"
|
|
>
|
|
Parent space
|
|
</label>
|
|
<select
|
|
id="create-object-parent"
|
|
className={selectFieldClass}
|
|
value={parentId}
|
|
onChange={(e) => setParentId(e.target.value)}
|
|
disabled={spacesQuery.isLoading}
|
|
>
|
|
<option value="">Workspace root</option>
|
|
{spaces.map((s) => (
|
|
<option key={s.id} value={s.id}>
|
|
{s.title}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
) : null}
|
|
|
|
{objectType === "task" ? (
|
|
<div className="space-y-2">
|
|
<label
|
|
htmlFor="create-object-status"
|
|
className="text-sm font-medium"
|
|
>
|
|
Status
|
|
</label>
|
|
<select
|
|
id="create-object-status"
|
|
className={selectFieldClass}
|
|
value={taskStatus}
|
|
onChange={(e) =>
|
|
setTaskStatus(
|
|
e.target.value as (typeof TASK_STATUSES)[number]["value"],
|
|
)
|
|
}
|
|
>
|
|
{TASK_STATUSES.map((s) => (
|
|
<option key={s.value} value={s.value}>
|
|
{s.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
) : null}
|
|
|
|
<div className="flex justify-end gap-2 pt-2">
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
onClick={() => onOpenChange(false)}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
type="submit"
|
|
disabled={!resolvedWorkspaceId || isSubmitting}
|
|
>
|
|
{isSubmitting ? "Creating…" : "Create"}
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
</DialogPrimitive.Content>
|
|
</DialogPrimitive.Portal>
|
|
|
|
{showTemplatePicker && resolvedWorkspaceId && (
|
|
<TemplatePicker
|
|
open={showTemplatePicker}
|
|
onOpenChange={setShowTemplatePicker}
|
|
workspaceId={resolvedWorkspaceId}
|
|
objectType={objectType}
|
|
onSelect={(template) => {
|
|
setSelectedTemplate(template);
|
|
setShowTemplatePicker(false);
|
|
}}
|
|
/>
|
|
)}
|
|
</DialogPrimitive.Root>
|
|
);
|
|
}
|