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
659 lines
23 KiB
TypeScript
659 lines
23 KiB
TypeScript
"use client";
|
|
|
|
import * as React from "react";
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@radix-ui/react-tabs";
|
|
import {
|
|
Check,
|
|
CheckSquare,
|
|
ChevronDown,
|
|
ExternalLink,
|
|
FileText,
|
|
Folder,
|
|
Link2,
|
|
Plus,
|
|
X,
|
|
} from "lucide-react";
|
|
|
|
import { isTRPCClientError } from "@trpc/client";
|
|
|
|
import { cn } from "@/lib/utils";
|
|
import { api } from "@/lib/trpc";
|
|
import {
|
|
usePanelStore,
|
|
type PanelDetailTab,
|
|
} from "@/lib/stores/panel-store";
|
|
import { useWorkspaceStore } from "@/lib/stores/workspace-store";
|
|
import { AssigneePicker } from "@/components/panels/assignee-picker";
|
|
import {
|
|
PropertyEditor,
|
|
type PropertyFieldType,
|
|
} from "@/components/panels/property-editor";
|
|
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";
|
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
import { Separator } from "@/components/ui/separator";
|
|
|
|
type StatusValue = "open" | "in_progress" | "done" | "closed";
|
|
|
|
const STATUS_OPTIONS: {
|
|
value: StatusValue;
|
|
label: string;
|
|
dot: string;
|
|
}[] = [
|
|
{ value: "open", label: "Open", dot: "bg-slate-400" },
|
|
{ value: "in_progress", label: "In progress", dot: "bg-amber-500" },
|
|
{ value: "done", label: "Done", dot: "bg-emerald-500" },
|
|
{ value: "closed", label: "Closed", dot: "bg-zinc-400" },
|
|
];
|
|
|
|
export interface ObjectDetailData {
|
|
id: string;
|
|
title: string;
|
|
type: string;
|
|
status: string;
|
|
description: string | null;
|
|
assignees: {
|
|
user: { id?: string; name: string; avatarUrl?: string | null };
|
|
}[];
|
|
propertyValues: {
|
|
id?: string;
|
|
propertyDefinition: {
|
|
id: string;
|
|
name: string;
|
|
fieldType: string;
|
|
config?: Record<string, unknown> | null;
|
|
};
|
|
value: unknown;
|
|
}[];
|
|
children?: { id: string; title: string; type: string }[];
|
|
relations?: { id: string; title: string; type: string }[];
|
|
}
|
|
|
|
function normalizeFieldType(raw: string): PropertyFieldType {
|
|
const t = raw.toLowerCase().replace(/[\s-]+/g, "_") as PropertyFieldType;
|
|
const allowed: PropertyFieldType[] = [
|
|
"text",
|
|
"richtext",
|
|
"number",
|
|
"date",
|
|
"select",
|
|
"multiselect",
|
|
"person",
|
|
"relation",
|
|
"url",
|
|
"file",
|
|
"checkbox",
|
|
];
|
|
return allowed.includes(t) ? t : "text";
|
|
}
|
|
|
|
function typeLabel(type: string) {
|
|
const t = type.toLowerCase();
|
|
if (t === "task") return "Task";
|
|
if (t === "document") return "Document";
|
|
return type.charAt(0).toUpperCase() + type.slice(1);
|
|
}
|
|
|
|
function TypeIcon({ type }: { type: string }) {
|
|
const t = type.toLowerCase();
|
|
if (t === "document") return <FileText className="size-4 text-teal-600 dark:text-teal-400" />;
|
|
if (t === "task") return <CheckSquare className="size-4 text-violet-600 dark:text-violet-400" />;
|
|
return <Folder className="size-4 text-muted-foreground" />;
|
|
}
|
|
|
|
function initials(name: string) {
|
|
return name
|
|
.split(/\s+/)
|
|
.map((p) => p[0])
|
|
.join("")
|
|
.slice(0, 2)
|
|
.toUpperCase();
|
|
}
|
|
|
|
function useObjectDetailQuery(objectId: string | null) {
|
|
return api.objects.getById.useQuery(
|
|
{ id: objectId as string },
|
|
{ enabled: Boolean(objectId) },
|
|
);
|
|
}
|
|
|
|
function DetailSkeleton() {
|
|
return (
|
|
<div className="flex animate-pulse flex-col gap-3 p-3">
|
|
<div className="flex gap-2">
|
|
<div className="size-8 rounded-md bg-muted" />
|
|
<div className="flex flex-1 flex-col gap-2">
|
|
<div className="h-3 w-20 rounded bg-muted" />
|
|
<div className="h-6 w-full rounded bg-muted" />
|
|
</div>
|
|
</div>
|
|
<div className="h-9 w-full rounded-md bg-muted" />
|
|
<div className="h-24 w-full rounded-md bg-muted" />
|
|
<div className="h-32 w-full rounded-md bg-muted" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function ObjectDetail() {
|
|
const objectId = usePanelStore((s) => s.objectId);
|
|
const activeTab = usePanelStore((s) => s.activeTab);
|
|
const setActiveTab = usePanelStore((s) => s.setActiveTab);
|
|
const closePanel = usePanelStore((s) => s.close);
|
|
const openPanel = usePanelStore((s) => s.open);
|
|
|
|
const utils = api.useUtils();
|
|
const workspace = useWorkspaceStore((s) => s.currentWorkspace);
|
|
const workspaceId = workspace?.id;
|
|
|
|
const { data: workspaceMembersList } = api.workspaces.listMembers.useQuery(
|
|
{ workspaceId: workspaceId! },
|
|
{ enabled: Boolean(workspaceId) },
|
|
);
|
|
|
|
const objectDetailQuery = useObjectDetailQuery(objectId);
|
|
const data = objectDetailQuery.data as ObjectDetailData | undefined;
|
|
const { isPending, isError, error } = objectDetailQuery;
|
|
|
|
const [titleDraft, setTitleDraft] = React.useState("");
|
|
const [editingTitle, setEditingTitle] = React.useState(false);
|
|
const [descriptionDraft, setDescriptionDraft] = React.useState("");
|
|
const [assigneeOpen, setAssigneeOpen] = React.useState(false);
|
|
|
|
React.useEffect(() => {
|
|
if (data?.title != null) setTitleDraft(data.title);
|
|
}, [data?.title]);
|
|
|
|
React.useEffect(() => {
|
|
if (data?.description != null) setDescriptionDraft(data.description ?? "");
|
|
}, [data?.description]);
|
|
|
|
const mergeObjectCache = React.useCallback(
|
|
(id: string, patch: Partial<ObjectDetailData>) => {
|
|
utils.objects.getById.setData({ id }, (old) => {
|
|
if (!old) return old;
|
|
return {
|
|
...(old as ObjectDetailData),
|
|
...patch,
|
|
} as typeof old;
|
|
});
|
|
},
|
|
[utils],
|
|
);
|
|
|
|
const updateObjectMutation = api.objects.update.useMutation({
|
|
onMutate: async (variables) => {
|
|
mergeObjectCache(variables.id, variables as Partial<ObjectDetailData>);
|
|
},
|
|
onSuccess: async (_data, variables) => {
|
|
await utils.objects.getById.invalidate({ id: variables.id });
|
|
},
|
|
});
|
|
|
|
const assignMutation = api.objects.assign.useMutation({
|
|
onSuccess: async (_data, variables) => {
|
|
await utils.objects.getById.invalidate({ id: variables.objectId });
|
|
},
|
|
});
|
|
|
|
const setPropertyValueMutation = api.properties.setValue.useMutation({
|
|
onSuccess: async (_data, variables) => {
|
|
await utils.objects.getById.invalidate({ id: variables.objectId });
|
|
},
|
|
});
|
|
|
|
const handlePropertyChange = (
|
|
index: number,
|
|
row: ObjectDetailData["propertyValues"][number],
|
|
next: unknown,
|
|
) => {
|
|
if (!data) return;
|
|
const nextRows = [...data.propertyValues];
|
|
nextRows[index] = { ...row, value: next };
|
|
mergeObjectCache(data.id, { propertyValues: nextRows });
|
|
setPropertyValueMutation.mutate({
|
|
objectId: data.id,
|
|
propertyDefId: row.propertyDefinition.id,
|
|
value: next,
|
|
});
|
|
};
|
|
|
|
const assignedIds = React.useMemo(() => {
|
|
if (!data) return [];
|
|
return data.assignees
|
|
.map((a) => a.user.id)
|
|
.filter((id): id is string => Boolean(id));
|
|
}, [data]);
|
|
|
|
const toggleAssignee = (userId: string) => {
|
|
if (!data) return;
|
|
const has = assignedIds.includes(userId);
|
|
const member = workspaceMembersList?.find((u) => u.id === userId);
|
|
const displayName = member
|
|
? (member.name ?? member.email)
|
|
: "User";
|
|
let nextAssignees: ObjectDetailData["assignees"];
|
|
if (has) {
|
|
nextAssignees = data.assignees.filter((a) => a.user.id !== userId);
|
|
} else {
|
|
nextAssignees = [
|
|
...data.assignees,
|
|
{
|
|
user: {
|
|
id: userId,
|
|
name: displayName,
|
|
avatarUrl: member?.avatarUrl ?? null,
|
|
},
|
|
},
|
|
];
|
|
}
|
|
mergeObjectCache(data.id, { assignees: nextAssignees });
|
|
assignMutation.mutate({
|
|
objectId: data.id,
|
|
userId,
|
|
action: has ? "remove" : "add",
|
|
});
|
|
};
|
|
|
|
const commitTitle = () => {
|
|
if (!data || titleDraft.trim() === data.title) {
|
|
setEditingTitle(false);
|
|
return;
|
|
}
|
|
updateObjectMutation.mutate({ id: data.id, title: titleDraft.trim() });
|
|
setEditingTitle(false);
|
|
};
|
|
|
|
const commitDescription = () => {
|
|
if (!data) return;
|
|
if (descriptionDraft === (data.description ?? "")) return;
|
|
updateObjectMutation.mutate({ id: data.id, description: descriptionDraft });
|
|
};
|
|
|
|
const setStatus = (status: StatusValue) => {
|
|
if (!data) return;
|
|
updateObjectMutation.mutate({ id: data.id, status });
|
|
};
|
|
|
|
const onTabChange = (v: string) => {
|
|
setActiveTab(v as PanelDetailTab);
|
|
};
|
|
|
|
if (!objectId) {
|
|
return (
|
|
<div className="flex flex-1 flex-col items-center justify-center gap-2 p-6 text-center text-xs text-muted-foreground">
|
|
<p>No object selected.</p>
|
|
<Button variant="outline" size="sm" onClick={() => closePanel()}>
|
|
Close panel
|
|
</Button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (isPending) {
|
|
return <DetailSkeleton />;
|
|
}
|
|
|
|
if (isError || !data) {
|
|
const notFound =
|
|
isTRPCClientError(error) && error.data?.code === "NOT_FOUND";
|
|
return (
|
|
<div className="p-4 text-xs text-destructive">
|
|
{isError
|
|
? notFound
|
|
? "Not found"
|
|
: error instanceof Error
|
|
? error.message
|
|
: "Failed to load object."
|
|
: "Not found"}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const status =
|
|
STATUS_OPTIONS.find((s) => s.value === data.status)?.value ??
|
|
(data.status as StatusValue);
|
|
const statusMeta =
|
|
STATUS_OPTIONS.find((s) => s.value === status) ?? STATUS_OPTIONS[0];
|
|
|
|
return (
|
|
<div className="flex h-full min-h-0 flex-1 flex-col bg-card">
|
|
<header className="shrink-0 border-b border-border px-3 pb-2 pt-3">
|
|
<div className="flex items-start gap-2">
|
|
<div className="mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-md border border-border bg-muted/40">
|
|
<TypeIcon type={data.type} />
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<div className="mb-1 flex items-center gap-2">
|
|
<Badge
|
|
variant="secondary"
|
|
className="h-5 px-1.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground"
|
|
>
|
|
{typeLabel(data.type)}
|
|
</Badge>
|
|
</div>
|
|
{editingTitle ? (
|
|
<Input
|
|
autoFocus
|
|
value={titleDraft}
|
|
onChange={(e) => setTitleDraft(e.target.value)}
|
|
onBlur={commitTitle}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter") {
|
|
e.preventDefault();
|
|
commitTitle();
|
|
}
|
|
if (e.key === "Escape") {
|
|
setTitleDraft(data.title);
|
|
setEditingTitle(false);
|
|
}
|
|
}}
|
|
className="h-8 text-sm font-semibold"
|
|
/>
|
|
) : (
|
|
<button
|
|
type="button"
|
|
onClick={() => setEditingTitle(true)}
|
|
className="w-full rounded px-0.5 text-left text-sm font-semibold leading-snug text-foreground hover:bg-muted/60"
|
|
>
|
|
{data.title}
|
|
</button>
|
|
)}
|
|
</div>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="size-8 shrink-0 text-muted-foreground hover:text-foreground"
|
|
onClick={() => closePanel()}
|
|
aria-label="Close panel"
|
|
>
|
|
<X className="size-4" />
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="mt-2 flex items-center gap-2">
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-7 gap-1.5 border-border px-2 text-xs font-normal"
|
|
>
|
|
<span
|
|
className={cn("size-2 shrink-0 rounded-full", statusMeta.dot)}
|
|
/>
|
|
{statusMeta.label}
|
|
<ChevronDown className="size-3.5 opacity-60" />
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="start" className="w-44">
|
|
{STATUS_OPTIONS.map((opt) => (
|
|
<DropdownMenuItem
|
|
key={opt.value}
|
|
className="gap-2 text-xs"
|
|
onClick={() => setStatus(opt.value)}
|
|
>
|
|
<span className={cn("size-2 rounded-full", opt.dot)} />
|
|
{opt.label}
|
|
{opt.value === status ? (
|
|
<Check className="ml-auto size-3.5 text-primary" />
|
|
) : null}
|
|
</DropdownMenuItem>
|
|
))}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</div>
|
|
</header>
|
|
|
|
<Tabs
|
|
value={activeTab}
|
|
onValueChange={onTabChange}
|
|
className="flex min-h-0 flex-1 flex-col"
|
|
>
|
|
<TabsList className="flex shrink-0 gap-0 border-b border-border px-1">
|
|
{(
|
|
[
|
|
["details", "Details"],
|
|
["activity", "Activity"],
|
|
["comments", "Comments"],
|
|
] as const
|
|
).map(([value, label]) => (
|
|
<TabsTrigger
|
|
key={value}
|
|
value={value}
|
|
className={cn(
|
|
"relative flex-1 px-2 py-2 text-center text-[11px] font-semibold uppercase tracking-wide text-muted-foreground transition-colors",
|
|
"hover:text-foreground",
|
|
"data-[state=active]:text-foreground",
|
|
"data-[state=active]:after:absolute data-[state=active]:after:inset-x-2 data-[state=active]:after:bottom-0 data-[state=active]:after:h-0.5 data-[state=active]:after:rounded-full data-[state=active]:after:bg-primary",
|
|
)}
|
|
>
|
|
{label}
|
|
</TabsTrigger>
|
|
))}
|
|
</TabsList>
|
|
|
|
<TabsContent
|
|
value="details"
|
|
className="flex min-h-0 flex-1 flex-col overflow-hidden animate-in fade-in-0 duration-200 ease-out data-[state=inactive]:hidden"
|
|
>
|
|
<ScrollArea className="min-h-0 flex-1">
|
|
<div className="flex flex-col gap-0 px-3 pb-6 pt-2">
|
|
<section className="py-2">
|
|
<h3 className="mb-2 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
|
Assignees
|
|
</h3>
|
|
<div className="flex flex-wrap items-center gap-1.5">
|
|
{data.assignees.map((a, i) => (
|
|
<Avatar
|
|
key={`${a.user.id ?? a.user.name}-${i}`}
|
|
className="size-7 border border-border"
|
|
title={a.user.name}
|
|
>
|
|
<AvatarImage src={a.user.avatarUrl ?? undefined} alt="" />
|
|
<AvatarFallback className="text-[10px]">
|
|
{initials(a.user.name)}
|
|
</AvatarFallback>
|
|
</Avatar>
|
|
))}
|
|
<AssigneePicker
|
|
open={assigneeOpen}
|
|
onOpenChange={setAssigneeOpen}
|
|
assignedIds={assignedIds}
|
|
onToggle={toggleAssignee}
|
|
workspaceId={workspaceId}
|
|
>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-7 gap-1 px-2 text-[11px] font-normal"
|
|
>
|
|
<Plus className="size-3.5" />
|
|
Add assignee
|
|
</Button>
|
|
</AssigneePicker>
|
|
</div>
|
|
</section>
|
|
|
|
<Separator className="bg-border/80" />
|
|
|
|
<section className="py-2">
|
|
<div className="mb-2 flex items-center justify-between gap-2">
|
|
<h3 className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
|
Properties
|
|
</h3>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-6 px-2 text-[10px] font-medium text-primary"
|
|
type="button"
|
|
>
|
|
<Plus className="mr-1 size-3" />
|
|
Add property
|
|
</Button>
|
|
</div>
|
|
<div className="rounded-md border border-border/80 bg-muted/20">
|
|
{data.propertyValues.length === 0 ? (
|
|
<p className="px-3 py-4 text-center text-[11px] text-muted-foreground">
|
|
No custom properties yet.
|
|
</p>
|
|
) : (
|
|
data.propertyValues.map((row, index) => (
|
|
<div
|
|
key={row.id ?? `${row.propertyDefinition.name}-${index}`}
|
|
className="border-b border-border/60 px-2 last:border-b-0"
|
|
>
|
|
<PropertyEditor
|
|
definition={{
|
|
name: row.propertyDefinition.name,
|
|
fieldType: normalizeFieldType(
|
|
row.propertyDefinition.fieldType,
|
|
),
|
|
config: row.propertyDefinition.config as
|
|
| { options?: { label: string; value: string }[] }
|
|
| undefined,
|
|
}}
|
|
value={row.value}
|
|
onChange={(v) => handlePropertyChange(index, row, v)}
|
|
/>
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
</section>
|
|
|
|
<Separator className="bg-border/80" />
|
|
|
|
<section className="py-2">
|
|
<h3 className="mb-2 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
|
Description
|
|
</h3>
|
|
<textarea
|
|
value={descriptionDraft}
|
|
onChange={(e) => setDescriptionDraft(e.target.value)}
|
|
onBlur={commitDescription}
|
|
rows={4}
|
|
placeholder="Add a description…"
|
|
className="w-full resize-y rounded-md border border-input bg-background px-2 py-1.5 text-xs leading-relaxed ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1"
|
|
/>
|
|
</section>
|
|
|
|
{data.children && data.children.length > 0 ? (
|
|
<>
|
|
<Separator className="bg-border/80" />
|
|
<section className="py-2">
|
|
<h3 className="mb-2 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
|
Subtasks
|
|
</h3>
|
|
<ul className="space-y-1">
|
|
{data.children.map((c) => (
|
|
<li key={c.id}>
|
|
<button
|
|
type="button"
|
|
onClick={() => openPanel("object-detail", c.id)}
|
|
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs transition-colors hover:bg-muted/70"
|
|
>
|
|
<CheckSquare className="size-3.5 shrink-0 text-violet-500" />
|
|
<span className="min-w-0 flex-1 truncate font-medium">
|
|
{c.title}
|
|
</span>
|
|
<Badge
|
|
variant="outline"
|
|
className="h-5 shrink-0 px-1.5 text-[9px] font-normal uppercase"
|
|
>
|
|
{typeLabel(c.type)}
|
|
</Badge>
|
|
</button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</section>
|
|
</>
|
|
) : null}
|
|
|
|
<Separator className="bg-border/80" />
|
|
|
|
<section className="py-2">
|
|
<div className="mb-2 flex items-center justify-between gap-2">
|
|
<h3 className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
|
Relations
|
|
</h3>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-6 px-2 text-[10px] font-medium text-primary"
|
|
type="button"
|
|
>
|
|
<Link2 className="mr-1 size-3" />
|
|
Add relation
|
|
</Button>
|
|
</div>
|
|
{data.relations && data.relations.length > 0 ? (
|
|
<ul className="space-y-1">
|
|
{data.relations.map((r) => (
|
|
<li key={r.id}>
|
|
<button
|
|
type="button"
|
|
onClick={() => openPanel("object-detail", r.id)}
|
|
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs transition-colors hover:bg-muted/70"
|
|
>
|
|
<ExternalLink className="size-3.5 shrink-0 text-muted-foreground" />
|
|
<span className="min-w-0 flex-1 truncate">
|
|
{r.title}
|
|
</span>
|
|
<Badge
|
|
variant="secondary"
|
|
className="h-5 shrink-0 px-1.5 text-[9px] font-normal uppercase"
|
|
>
|
|
{typeLabel(r.type)}
|
|
</Badge>
|
|
</button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
) : (
|
|
<p className="py-2 text-[11px] text-muted-foreground">
|
|
No relations yet.
|
|
</p>
|
|
)}
|
|
</section>
|
|
</div>
|
|
</ScrollArea>
|
|
</TabsContent>
|
|
|
|
<TabsContent
|
|
value="activity"
|
|
className="min-h-0 flex-1 animate-in fade-in-0 duration-200 ease-out data-[state=inactive]:hidden"
|
|
>
|
|
<div className="p-4 text-xs text-muted-foreground">
|
|
<p className="rounded-md border border-dashed border-border bg-muted/20 px-3 py-6 text-center">
|
|
Activity feed will appear here (audit log, updates, mentions).
|
|
</p>
|
|
</div>
|
|
</TabsContent>
|
|
|
|
<TabsContent
|
|
value="comments"
|
|
className="min-h-0 flex-1 animate-in fade-in-0 duration-200 ease-out data-[state=inactive]:hidden"
|
|
>
|
|
<div className="p-4 text-xs text-muted-foreground">
|
|
<p className="rounded-md border border-dashed border-border bg-muted/20 px-3 py-6 text-center">
|
|
Comments thread — coming soon.
|
|
</p>
|
|
</div>
|
|
</TabsContent>
|
|
</Tabs>
|
|
</div>
|
|
);
|
|
}
|