"use client" import { useState, useEffect, useRef } from "react" import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query" import { Group, Panel, Separator } from "react-resizable-panels" import { createSupabaseBrowserClient } from "@/lib/supabase/client" import { useIsMobile } from "@/lib/hooks/use-is-mobile" import { useUserInterfaceStore } from "@/lib/stores/user-interface-store" import { EntryDetailPanel } from "@/app/reader/_components/entry-detail-panel" import { ErrorBoundary } from "@/app/reader/_components/error-boundary" import { classNames } from "@/lib/utilities" import { notify } from "@/lib/notify" interface SharedEntry { identifier: string entryIdentifier: string shareToken: string createdAt: string expiresAt: string | null note: string | null noteIsPublic: boolean viewCount: number lastViewedAt: string | null entryTitle: string | null entryUrl: string | null } function useSharedEntries() { const supabaseClient = createSupabaseBrowserClient() return useQuery({ queryKey: ["shared-entries"], queryFn: async () => { const { data, error } = await supabaseClient .from("shared_entries") .select("id, entry_id, share_token, created_at, expires_at, note, note_is_public, view_count, last_viewed_at, entries(title, url)") .order("created_at", { ascending: false }) if (error) throw error return (data ?? []).map( (row) => { const entryData = row.entries as unknown as { title: string | null url: string | null } | null const rowData = row as Record return { identifier: row.id, entryIdentifier: row.entry_id, shareToken: row.share_token, createdAt: row.created_at, expiresAt: row.expires_at, note: rowData.note as string | null, noteIsPublic: (rowData.note_is_public as boolean) ?? false, viewCount: (rowData.view_count as number) ?? 0, lastViewedAt: rowData.last_viewed_at as string | null, entryTitle: entryData?.title ?? null, entryUrl: entryData?.url ?? null, } } ) }, }) } function useRevokeShare() { const supabaseClient = createSupabaseBrowserClient() const queryClient = useQueryClient() return useMutation({ mutationFn: async (shareIdentifier: string) => { const { error } = await supabaseClient .from("shared_entries") .delete() .eq("id", shareIdentifier) if (error) throw error }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["shared-entries"] }) notify("share revoked") }, onError: () => { notify("failed to revoke share") }, }) } function useUpdateShareNote() { const queryClient = useQueryClient() return useMutation({ mutationFn: async ({ shareToken, note, noteIsPublic }: { shareToken: string; note?: string | null; noteIsPublic?: boolean }) => { const payload: Record = {} if (note !== undefined) payload.note = note if (noteIsPublic !== undefined) payload.noteIsPublic = noteIsPublic const response = await fetch(`/api/share/${shareToken}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }) if (!response.ok) throw new Error("failed to update share") }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["shared-entries"] }) notify("share updated") }, onError: () => { notify("failed to update share") }, }) } function isExpired(expiresAt: string | null): boolean { if (!expiresAt) return false return new Date(expiresAt) < new Date() } function ShareRow({ share, isSelected, isFocused, viewMode, onSelect, }: { share: SharedEntry isSelected: boolean isFocused: boolean viewMode: "compact" | "comfortable" | "expanded" onSelect: (entryIdentifier: string) => void }) { const [showRevokeConfirm, setShowRevokeConfirm] = useState(false) const [isEditingNote, setIsEditingNote] = useState(false) const [editedNote, setEditedNote] = useState(share.note ?? "") const revokeShare = useRevokeShare() const updateNote = useUpdateShareNote() const expired = isExpired(share.expiresAt) function handleCopyLink() { const shareUrl = `${window.location.origin}/shared/${share.shareToken}` navigator.clipboard.writeText(shareUrl) notify("link copied") } function handleSaveNote() { const trimmedNote = editedNote.trim() updateNote.mutate({ shareToken: share.shareToken, note: trimmedNote || null }) setIsEditingNote(false) } const sharedDate = new Date(share.createdAt).toLocaleDateString("en-GB", { day: "numeric", month: "short", year: "numeric", }) const rowClassName = classNames( "cursor-pointer border-b border-border px-4 transition-colors last:border-b-0", isSelected ? "bg-background-tertiary" : isFocused ? "bg-background-secondary" : "hover:bg-background-secondary", isFocused && !isSelected ? "border-l-2 border-l-text-dim" : "" ) if (viewMode === "compact") { return (
onSelect(share.entryIdentifier)} className={rowClassName} >
{share.entryTitle ?? "untitled"} {expired && expired} {share.viewCount > 0 && ( {share.viewCount} view{share.viewCount !== 1 ? "s" : ""} )} {sharedDate}
) } if (viewMode === "comfortable") { return (
onSelect(share.entryIdentifier)} className={rowClassName} >
{share.entryTitle ?? "untitled"}
shared {sharedDate} {share.expiresAt && ( <> · {expired ? ( expired ) : ( expires{" "} {new Date(share.expiresAt).toLocaleDateString("en-GB", { day: "numeric", month: "short", year: "numeric", })} )} )} {share.viewCount > 0 && ( <> · {share.viewCount} view{share.viewCount !== 1 ? "s" : ""} )} {share.note && ( <> · {share.note} )}
) } return (
onSelect(share.entryIdentifier)} className={classNames(rowClassName, "flex flex-col gap-1 py-3")} >
{share.entryTitle ?? "untitled"} {isEditingNote ? (
setEditedNote(event.target.value)} placeholder="add a note..." className="min-w-0 flex-1 border border-border bg-background-primary px-2 py-1 text-text-primary outline-none focus:border-text-dim" onKeyDown={(event) => { if (event.key === "Enter") handleSaveNote() if (event.key === "Escape") setIsEditingNote(false) }} autoFocus />
) : share.note ? (

{share.note}

) : null}

shared {sharedDate} {share.expiresAt && ( <> {" \u00b7 "} {expired ? ( expired ) : ( <> expires{" "} {new Date(share.expiresAt).toLocaleDateString( "en-GB", { day: "numeric", month: "short", year: "numeric", } )} )} )} {share.viewCount > 0 && ( <> {" \u00b7 "} {share.viewCount} view{share.viewCount !== 1 ? "s" : ""} )} {share.noteIsPublic && share.note && ( <> {" \u00b7 "} note is public )}

{!expired && ( )} {share.note && ( )} {showRevokeConfirm ? (
revoke?
) : ( )}
) } function SharesList({ shares, selectedEntryIdentifier, focusedEntryIdentifier, viewMode, onSelect, }: { shares: SharedEntry[] selectedEntryIdentifier: string | null focusedEntryIdentifier: string | null viewMode: "compact" | "comfortable" | "expanded" onSelect: (entryIdentifier: string) => void }) { const listReference = useRef(null) useEffect(() => { if (!focusedEntryIdentifier) return const container = listReference.current if (!container) return const items = container.querySelectorAll("[data-share-list-item]") const focusedIndex = shares.findIndex( (share) => share.entryIdentifier === focusedEntryIdentifier ) items[focusedIndex]?.scrollIntoView({ block: "nearest" }) }, [focusedEntryIdentifier, shares]) if (shares.length === 0) { return (
no shared entries yet
) } return (
{shares.map((share) => ( ))}
) } export function SharesContent() { const { data: shares, isLoading } = useSharedEntries() const selectedEntryIdentifier = useUserInterfaceStore( (state) => state.selectedEntryIdentifier ) const setSelectedEntryIdentifier = useUserInterfaceStore( (state) => state.setSelectedEntryIdentifier ) const focusedEntryIdentifier = useUserInterfaceStore( (state) => state.focusedEntryIdentifier ) const setNavigableEntryIdentifiers = useUserInterfaceStore( (state) => state.setNavigableEntryIdentifiers ) const focusedPanel = useUserInterfaceStore((state) => state.focusedPanel) const isMobile = useIsMobile() const sharesList = shares ?? [] useEffect(() => { setSelectedEntryIdentifier(null) setNavigableEntryIdentifiers([]) }, [setSelectedEntryIdentifier, setNavigableEntryIdentifiers]) useEffect(() => { setNavigableEntryIdentifiers( sharesList.map((share) => share.entryIdentifier) ) // eslint-disable-next-line react-hooks/exhaustive-deps }, [sharesList.length, setNavigableEntryIdentifiers]) if (isLoading) { return

loading shares ...

} const activeShareCount = sharesList.filter( (share) => !isExpired(share.expiresAt) ).length return (
{isMobile && selectedEntryIdentifier && ( )}

shares

{activeShareCount} active share{activeShareCount !== 1 ? "s" : ""}
{isMobile ? ( selectedEntryIdentifier ? (
) : (
) ) : (
{selectedEntryIdentifier && ( <>
)}
)}
) }