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
156 lines
5 KiB
TypeScript
156 lines
5 KiB
TypeScript
"use client";
|
|
|
|
import * as React from "react";
|
|
import { useMemo } from "react";
|
|
import * as Popover from "@radix-ui/react-popover";
|
|
import { Check, Search } from "lucide-react";
|
|
|
|
import { cn } from "@/lib/utils";
|
|
import { api } from "@/lib/trpc";
|
|
import {
|
|
Avatar,
|
|
AvatarFallback,
|
|
AvatarImage,
|
|
} from "@/components/ui/avatar";
|
|
import { Input } from "@/components/ui/input";
|
|
|
|
export interface WorkspaceUser {
|
|
id: string;
|
|
name: string;
|
|
avatarUrl?: string | null;
|
|
}
|
|
|
|
/** Internal default for prop comparison only; list comes from `listMembers` when `workspaceId` is set */
|
|
const WORKSPACE_USERS: WorkspaceUser[] = [
|
|
{ id: "u1", name: "Alice", avatarUrl: null },
|
|
{ id: "u2", name: "Bob", avatarUrl: null },
|
|
{ id: "u3", name: "Charlie", avatarUrl: null },
|
|
{ id: "u4", name: "Diana", avatarUrl: null },
|
|
];
|
|
|
|
function initials(name: string) {
|
|
return name
|
|
.split(/\s+/)
|
|
.map((p) => p[0])
|
|
.join("")
|
|
.slice(0, 2)
|
|
.toUpperCase();
|
|
}
|
|
|
|
export interface AssigneePickerProps {
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
assignedIds: string[];
|
|
onToggle: (userId: string) => void;
|
|
users?: WorkspaceUser[];
|
|
workspaceId?: string;
|
|
children: React.ReactNode;
|
|
side?: "top" | "right" | "bottom" | "left";
|
|
align?: "start" | "center" | "end";
|
|
}
|
|
|
|
export function AssigneePicker({
|
|
open,
|
|
onOpenChange,
|
|
assignedIds,
|
|
onToggle,
|
|
users = WORKSPACE_USERS,
|
|
workspaceId,
|
|
children,
|
|
side = "bottom",
|
|
align = "start",
|
|
}: AssigneePickerProps) {
|
|
const { data: members } = api.workspaces.listMembers.useQuery(
|
|
{ workspaceId: workspaceId! },
|
|
{ enabled: Boolean(workspaceId) },
|
|
);
|
|
|
|
const resolvedUsers = useMemo(() => {
|
|
if (users && users !== WORKSPACE_USERS) return users;
|
|
if (!members) return [];
|
|
return members.map((m) => ({
|
|
id: m.id,
|
|
name: m.name ?? m.email,
|
|
avatarUrl: m.avatarUrl,
|
|
}));
|
|
}, [users, members]);
|
|
|
|
const [q, setQ] = React.useState("");
|
|
const filtered = React.useMemo(() => {
|
|
const s = q.trim().toLowerCase();
|
|
if (!s) return resolvedUsers;
|
|
return resolvedUsers.filter((u) => u.name.toLowerCase().includes(s));
|
|
}, [q, resolvedUsers]);
|
|
|
|
React.useEffect(() => {
|
|
if (!open) setQ("");
|
|
}, [open]);
|
|
|
|
return (
|
|
<Popover.Root open={open} onOpenChange={onOpenChange}>
|
|
<Popover.Trigger asChild>{children}</Popover.Trigger>
|
|
<Popover.Portal>
|
|
<Popover.Content
|
|
side={side}
|
|
align={align}
|
|
sideOffset={6}
|
|
className={cn(
|
|
"z-50 w-[min(100vw-2rem,280px)] rounded-lg border border-border bg-popover p-0 shadow-lg outline-none",
|
|
"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 data-[side=bottom]:slide-in-from-top-2",
|
|
)}
|
|
>
|
|
<div className="border-b border-border p-2">
|
|
<div className="relative">
|
|
<Search className="pointer-events-none absolute left-2 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
|
|
<Input
|
|
value={q}
|
|
onChange={(e) => setQ(e.target.value)}
|
|
placeholder="Search people…"
|
|
className="h-8 border-0 bg-muted/50 pl-8 text-xs focus-visible:ring-1"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="max-h-56 overflow-y-auto p-1">
|
|
{filtered.length === 0 ? (
|
|
<p className="px-2 py-4 text-center text-xs text-muted-foreground">
|
|
No matches
|
|
</p>
|
|
) : (
|
|
filtered.map((user) => {
|
|
const assigned = assignedIds.includes(user.id);
|
|
return (
|
|
<button
|
|
key={user.id}
|
|
type="button"
|
|
onClick={() => onToggle(user.id)}
|
|
className={cn(
|
|
"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs transition-colors",
|
|
"hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
|
assigned && "bg-primary/5",
|
|
)}
|
|
>
|
|
<Avatar className="size-7 border border-border">
|
|
<AvatarImage src={user.avatarUrl ?? undefined} alt="" />
|
|
<AvatarFallback className="text-[10px] font-medium">
|
|
{initials(user.name)}
|
|
</AvatarFallback>
|
|
</Avatar>
|
|
<span className="min-w-0 flex-1 truncate font-medium text-foreground">
|
|
{user.name}
|
|
</span>
|
|
{assigned ? (
|
|
<Check className="size-4 shrink-0 text-primary" aria-hidden />
|
|
) : (
|
|
<span className="size-4 shrink-0" />
|
|
)}
|
|
</button>
|
|
);
|
|
})
|
|
)}
|
|
</div>
|
|
</Popover.Content>
|
|
</Popover.Portal>
|
|
</Popover.Root>
|
|
);
|
|
}
|