ubiquitous-invention/apps/web/components/ui/presence.tsx

169 lines
4.7 KiB
TypeScript
Raw Normal View History

"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>
);
}