Complete architecture for a ClickUp/Notion/Miro-class project management app: - Turborepo monorepo with Next.js 15, TypeScript, PostgreSQL (Drizzle ORM) - Object-centered database schema (everything is an Object: tasks, projects, docs, whiteboards) - NextAuth v5 authentication with credentials + OAuth providers - tRPC v11 API layer with full CRUD for objects, properties, relations, templates, search - Three-panel UI: collapsible sidebar, center content area, push-in right panel - Purple/teal theme with light/dark mode via Shadcn/ui + Tailwind CSS - Multiple views: List, Kanban board (dnd-kit), Table (spreadsheet), Embedded iframe - TipTap rich text editor with slash commands, custom blocks (callout, toggle, mention, embed, divider), AI block - Real-time collaboration via Yjs + Hocuspocus with presence/cursors - tldraw whiteboard with custom shape cards (task, document, project) - MCP server exposing all app data/tools for AI agents - AI chat panel, editor AI slash commands, Cmd+K command palette - Template system with built-in templates (Bug Report, Meeting Notes, Sprint) - Full-text search with result highlighting - Docker Compose for full-stack deployment (web + collab + postgres + redis) Made-with: Cursor
168 lines
4.7 KiB
TypeScript
168 lines
4.7 KiB
TypeScript
"use client";
|
|
|
|
import * as React from "react";
|
|
import { RefreshCw } from "lucide-react";
|
|
|
|
import { cn } from "@/lib/utils";
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
Tooltip,
|
|
TooltipContent,
|
|
TooltipProvider,
|
|
TooltipTrigger,
|
|
} from "@/components/ui/tooltip";
|
|
|
|
import { colorFromUserName } from "@/lib/yjs-provider";
|
|
|
|
export type PresenceUser = {
|
|
clientId: number;
|
|
name: string;
|
|
color: string;
|
|
imageUrl?: string;
|
|
};
|
|
|
|
function initials(name: string): string {
|
|
const parts = name.trim().split(/\s+/).filter(Boolean);
|
|
if (parts.length === 0) return "?";
|
|
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
|
|
return `${parts[0][0] ?? ""}${parts[1][0] ?? ""}`.toUpperCase();
|
|
}
|
|
|
|
export type PresenceAvatarsProps = {
|
|
users: PresenceUser[];
|
|
/** @default 5 */
|
|
maxVisible?: number;
|
|
className?: string;
|
|
};
|
|
|
|
/**
|
|
* Horizontal stack of collaborator avatars (initials or image) with cursor-colored borders.
|
|
*/
|
|
export function PresenceAvatars({
|
|
users,
|
|
maxVisible = 5,
|
|
className,
|
|
}: PresenceAvatarsProps) {
|
|
const visible = users.slice(0, maxVisible);
|
|
const overflow = Math.max(0, users.length - maxVisible);
|
|
|
|
if (users.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<TooltipProvider delayDuration={150}>
|
|
<div
|
|
className={cn("flex flex-row items-center -space-x-2", className)}
|
|
aria-label="Collaborators in this document"
|
|
>
|
|
{visible.map((u) => {
|
|
const border = u.color || colorFromUserName(u.name);
|
|
return (
|
|
<Tooltip key={u.clientId}>
|
|
<TooltipTrigger asChild>
|
|
<div
|
|
className={cn(
|
|
"relative flex size-8 shrink-0 items-center justify-center rounded-full border-2 bg-muted text-[11px] font-semibold text-foreground shadow-sm ring-2 ring-background transition-transform hover:z-10 hover:scale-105",
|
|
)}
|
|
style={{ borderColor: border }}
|
|
>
|
|
{u.imageUrl ? (
|
|
// eslint-disable-next-line @next/next/no-img-element
|
|
<img
|
|
src={u.imageUrl}
|
|
alt=""
|
|
className="size-full rounded-full object-cover"
|
|
/>
|
|
) : (
|
|
<span className="select-none">{initials(u.name)}</span>
|
|
)}
|
|
</div>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="bottom">{u.name}</TooltipContent>
|
|
</Tooltip>
|
|
);
|
|
})}
|
|
{overflow > 0 ? (
|
|
<div
|
|
className="z-0 flex size-8 shrink-0 items-center justify-center rounded-full border-2 border-dashed border-muted-foreground/40 bg-muted/80 text-[10px] font-medium text-muted-foreground ring-2 ring-background"
|
|
title={`${overflow} more`}
|
|
>
|
|
+{overflow}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</TooltipProvider>
|
|
);
|
|
}
|
|
|
|
export type ConnectionUiStatus = "connected" | "connecting" | "disconnected";
|
|
|
|
export type ConnectionStatusProps = {
|
|
status: ConnectionUiStatus;
|
|
onRetry?: () => void;
|
|
className?: string;
|
|
};
|
|
|
|
const statusConfig: Record<
|
|
ConnectionUiStatus,
|
|
{ label: string; dot: string; pulse?: boolean }
|
|
> = {
|
|
connected: {
|
|
label: "Connected",
|
|
dot: "bg-emerald-500 shadow-[0_0_0_3px_rgba(16,185,129,0.25)]",
|
|
},
|
|
connecting: {
|
|
label: "Connecting…",
|
|
dot: "bg-amber-400 shadow-[0_0_0_3px_rgba(251,191,36,0.3)]",
|
|
pulse: true,
|
|
},
|
|
disconnected: {
|
|
label: "Disconnected",
|
|
dot: "bg-red-500 shadow-[0_0_0_3px_rgba(239,68,68,0.25)]",
|
|
},
|
|
};
|
|
|
|
/**
|
|
* Compact live connection indicator with optional retry when offline.
|
|
*/
|
|
export function ConnectionStatus({
|
|
status,
|
|
onRetry,
|
|
className,
|
|
}: ConnectionStatusProps) {
|
|
const cfg = statusConfig[status];
|
|
|
|
return (
|
|
<div
|
|
className={cn(
|
|
"inline-flex items-center gap-2 rounded-full border border-border/60 bg-background/80 px-2.5 py-1 text-xs text-muted-foreground shadow-sm backdrop-blur-sm",
|
|
className,
|
|
)}
|
|
>
|
|
<span className="relative flex size-2.5 items-center justify-center">
|
|
<span
|
|
className={cn(
|
|
"size-2 rounded-full",
|
|
cfg.dot,
|
|
cfg.pulse && "animate-pulse",
|
|
)}
|
|
aria-hidden
|
|
/>
|
|
</span>
|
|
<span className="font-medium text-foreground/90">{cfg.label}</span>
|
|
{status === "disconnected" && onRetry ? (
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-6 gap-1 px-1.5 text-[11px] text-foreground"
|
|
onClick={onRetry}
|
|
>
|
|
<RefreshCw className="size-3" />
|
|
Retry
|
|
</Button>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|