diff --git a/surfsense_web/atoms/public-chat-snapshots/public-chat-snapshots-mutation.atoms.ts b/surfsense_web/atoms/public-chat-snapshots/public-chat-snapshots-mutation.atoms.ts new file mode 100644 index 000000000..a0343fb9d --- /dev/null +++ b/surfsense_web/atoms/public-chat-snapshots/public-chat-snapshots-mutation.atoms.ts @@ -0,0 +1,17 @@ +import { atomWithMutation } from "jotai-tanstack-query"; +import { toast } from "sonner"; +import type { PublicChatSnapshotDeleteRequest } from "@/contracts/types/chat-threads.types"; +import { chatThreadsApiService } from "@/lib/apis/chat-threads-api.service"; + +export const deletePublicChatSnapshotMutationAtom = atomWithMutation(() => ({ + mutationFn: async (request: PublicChatSnapshotDeleteRequest) => { + return chatThreadsApiService.deletePublicChatSnapshot(request); + }, + onSuccess: () => { + toast.success("Public link deleted"); + }, + onError: (error: Error) => { + console.error("Failed to delete public chat link:", error); + toast.error("Failed to delete public link"); + }, +})); diff --git a/surfsense_web/components/public-chat-snapshots/public-chat-snapshot-row.tsx b/surfsense_web/components/public-chat-snapshots/public-chat-snapshot-row.tsx new file mode 100644 index 000000000..696d32466 --- /dev/null +++ b/surfsense_web/components/public-chat-snapshots/public-chat-snapshot-row.tsx @@ -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 ( +
+
+

+ {snapshot.thread_title} +

+
+ {formattedDate} + + + {snapshot.message_count} + +
+
+
+ + {canDelete && ( + + )} +
+
+ ); +} diff --git a/surfsense_web/components/public-chat-snapshots/public-chat-snapshots-empty-state.tsx b/surfsense_web/components/public-chat-snapshots/public-chat-snapshots-empty-state.tsx new file mode 100644 index 000000000..4bb295217 --- /dev/null +++ b/surfsense_web/components/public-chat-snapshots/public-chat-snapshots-empty-state.tsx @@ -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 ( +
+
+ +
+

{title}

+

{description}

+
+ ); +} diff --git a/surfsense_web/components/public-chat-snapshots/public-chat-snapshots-list.tsx b/surfsense_web/components/public-chat-snapshots/public-chat-snapshots-list.tsx new file mode 100644 index 000000000..38c435059 --- /dev/null +++ b/surfsense_web/components/public-chat-snapshots/public-chat-snapshots-list.tsx @@ -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 ; + } + + return ( +
+ {snapshots.map((snapshot) => ( + + ))} +
+ ); +} diff --git a/surfsense_web/components/public-chat-snapshots/public-chat-snapshots-manager.tsx b/surfsense_web/components/public-chat-snapshots/public-chat-snapshots-manager.tsx new file mode 100644 index 000000000..a24efcd77 --- /dev/null +++ b/surfsense_web/components/public-chat-snapshots/public-chat-snapshots-manager.tsx @@ -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(); + + // 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 ( +
+ + + + + + + + + +
+ ); + } + + // Permission denied + if (!canView) { + return ( + + + + You don't have permission to view public chat links in this search space. + + + ); + } + + const snapshots = snapshotsData?.snapshots ?? []; + + return ( +
+ + + + 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. + + + + + + + + Public Chat Links + + + Manage public links to chats in this search space. + + + + + + +
+ ); +}