feat: add public chat snapshots components

This commit is contained in:
CREDO23 2026-02-02 16:24:13 +02:00
parent d890c562d4
commit ea2dd20a78
5 changed files with 280 additions and 0 deletions

View file

@ -0,0 +1,67 @@
"use client";
import { Copy, MessageSquare, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import type { PublicChatSnapshotDetail } from "@/contracts/types/chat-threads.types";
interface PublicChatSnapshotRowProps {
snapshot: PublicChatSnapshotDetail;
canDelete: boolean;
onCopy: (snapshot: PublicChatSnapshotDetail) => void;
onDelete: (snapshot: PublicChatSnapshotDetail) => void;
isDeleting?: boolean;
}
export function PublicChatSnapshotRow({
snapshot,
canDelete,
onCopy,
onDelete,
isDeleting = false,
}: PublicChatSnapshotRowProps) {
const formattedDate = new Date(snapshot.created_at).toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
});
return (
<div className="flex items-center justify-between py-3 px-4 border-b last:border-b-0 hover:bg-muted/50 transition-colors">
<div className="flex-1 min-w-0 mr-4">
<h4 className="text-sm font-medium truncate" title={snapshot.thread_title}>
{snapshot.thread_title}
</h4>
<div className="flex items-center gap-3 mt-1 text-xs text-muted-foreground">
<span>{formattedDate}</span>
<span className="flex items-center gap-1">
<MessageSquare className="h-3 w-3" />
{snapshot.message_count}
</span>
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => onCopy(snapshot)}
className="h-8 px-2"
title="Copy link"
>
<Copy className="h-4 w-4" />
</Button>
{canDelete && (
<Button
variant="ghost"
size="sm"
onClick={() => onDelete(snapshot)}
disabled={isDeleting}
className="h-8 px-2 text-destructive hover:text-destructive hover:bg-destructive/10"
title="Delete link"
>
<Trash2 className="h-4 w-4" />
</Button>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,23 @@
"use client";
import { Link2Off } from "lucide-react";
interface PublicChatSnapshotsEmptyStateProps {
title?: string;
description?: string;
}
export function PublicChatSnapshotsEmptyState({
title = "No public chat links",
description = "When you create public links to share chats, they will appear here.",
}: PublicChatSnapshotsEmptyStateProps) {
return (
<div className="flex flex-col items-center justify-center py-12 text-center">
<div className="rounded-full bg-muted p-3 mb-4">
<Link2Off className="h-6 w-6 text-muted-foreground" />
</div>
<h3 className="text-sm font-medium text-foreground mb-1">{title}</h3>
<p className="text-xs text-muted-foreground max-w-sm">{description}</p>
</div>
);
}

View file

@ -0,0 +1,40 @@
"use client";
import type { PublicChatSnapshotDetail } from "@/contracts/types/chat-threads.types";
import { PublicChatSnapshotRow } from "./public-chat-snapshot-row";
import { PublicChatSnapshotsEmptyState } from "./public-chat-snapshots-empty-state";
interface PublicChatSnapshotsListProps {
snapshots: PublicChatSnapshotDetail[];
canDelete: boolean;
onCopy: (snapshot: PublicChatSnapshotDetail) => void;
onDelete: (snapshot: PublicChatSnapshotDetail) => void;
deletingId?: number;
}
export function PublicChatSnapshotsList({
snapshots,
canDelete,
onCopy,
onDelete,
deletingId,
}: PublicChatSnapshotsListProps) {
if (snapshots.length === 0) {
return <PublicChatSnapshotsEmptyState />;
}
return (
<div className="border rounded-md divide-y">
{snapshots.map((snapshot) => (
<PublicChatSnapshotRow
key={snapshot.id}
snapshot={snapshot}
canDelete={canDelete}
onCopy={onCopy}
onDelete={onDelete}
isDeleting={deletingId === snapshot.id}
/>
))}
</div>
);
}

View file

@ -0,0 +1,133 @@
"use client";
import { useAtomValue } from "jotai";
import { Globe, Info } from "lucide-react";
import { useCallback, useMemo, useState } from "react";
import { toast } from "sonner";
import { deletePublicChatSnapshotMutationAtom } from "@/atoms/public-chat-snapshots/public-chat-snapshots-mutation.atoms";
import { publicChatSnapshotsAtom } from "@/atoms/public-chat-snapshots/public-chat-snapshots-query.atoms";
import { myAccessAtom } from "@/atoms/members/members-query.atoms";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import type { PublicChatSnapshotDetail } from "@/contracts/types/chat-threads.types";
import { PublicChatSnapshotsList } from "./public-chat-snapshots-list";
interface PublicChatSnapshotsManagerProps {
searchSpaceId: number;
}
export function PublicChatSnapshotsManager({
searchSpaceId: _searchSpaceId,
}: PublicChatSnapshotsManagerProps) {
const [deletingId, setDeletingId] = useState<number | undefined>();
// Data fetching
const { data: snapshotsData, isLoading, refetch } = useAtomValue(publicChatSnapshotsAtom);
// Permissions
const { data: access } = useAtomValue(myAccessAtom);
const canView = useMemo(() => {
if (!access) return false;
if (access.is_owner) return true;
return access.permissions?.includes("public_sharing:view") ?? false;
}, [access]);
const canDelete = useMemo(() => {
if (!access) return false;
if (access.is_owner) return true;
return access.permissions?.includes("public_sharing:delete") ?? false;
}, [access]);
// Mutations
const { mutateAsync: deleteSnapshot } = useAtomValue(deletePublicChatSnapshotMutationAtom);
// Handlers
const handleCopy = useCallback((snapshot: PublicChatSnapshotDetail) => {
const publicUrl = `${window.location.origin}/public/${snapshot.share_token}`;
navigator.clipboard.writeText(publicUrl);
toast.success("Link copied to clipboard");
}, []);
const handleDelete = useCallback(
async (snapshot: PublicChatSnapshotDetail) => {
try {
setDeletingId(snapshot.id);
await deleteSnapshot({
thread_id: snapshot.thread_id,
snapshot_id: snapshot.id,
});
await refetch();
} catch (error) {
console.error("Failed to delete snapshot:", error);
} finally {
setDeletingId(undefined);
}
},
[deleteSnapshot, refetch]
);
// Loading state
if (isLoading) {
return (
<div className="space-y-4 md:space-y-6">
<Card>
<CardHeader className="px-3 md:px-6 pt-3 md:pt-6 pb-2 md:pb-3">
<Skeleton className="h-5 md:h-6 w-36 md:w-48" />
<Skeleton className="h-3 md:h-4 w-full max-w-md mt-2" />
</CardHeader>
<CardContent className="px-3 md:px-6 pb-3 md:pb-6">
<Skeleton className="h-24 w-full" />
</CardContent>
</Card>
</div>
);
}
// Permission denied
if (!canView) {
return (
<Alert variant="destructive">
<Info className="h-4 w-4" />
<AlertDescription>
You don't have permission to view public chat links in this search space.
</AlertDescription>
</Alert>
);
}
const snapshots = snapshotsData?.snapshots ?? [];
return (
<div className="space-y-4 md:space-y-6">
<Alert className="py-3 md:py-4">
<Globe className="h-3 w-3 md:h-4 md:w-4 shrink-0" />
<AlertDescription className="text-xs md:text-sm">
Public chat links allow anyone with the URL to view a snapshot of a chat. These links do
not update when the original chat changes.
</AlertDescription>
</Alert>
<Card>
<CardHeader className="px-3 md:px-6 pt-3 md:pt-6 pb-2 md:pb-3">
<CardTitle className="text-base md:text-lg flex items-center gap-2">
<Globe className="h-4 w-4 md:h-5 md:w-5" />
Public Chat Links
</CardTitle>
<CardDescription className="text-xs md:text-sm">
Manage public links to chats in this search space.
</CardDescription>
</CardHeader>
<CardContent className="px-3 md:px-6 pb-3 md:pb-6">
<PublicChatSnapshotsList
snapshots={snapshots}
canDelete={canDelete}
onCopy={handleCopy}
onDelete={handleDelete}
deletingId={deletingId}
/>
</CardContent>
</Card>
</div>
);
}