'use client' import { useState, useEffect, useCallback } from 'react' import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Badge } from '@/components/ui/badge' import { Textarea } from '@/components/ui/textarea' import { ExternalLink, Edit, Save, X, Trash2, Share2, Globe, Users, Calendar, Clock, Mail, Tag } from 'lucide-react' import { toast } from 'sonner' import type { Bookmark } from '@/components/app-shell' import ReactMarkdown from 'react-markdown' import remarkGfm from 'remark-gfm' import { BookmarkExtras } from '@/components/bookmark-extras' import { fetchReference } from '@/lib/reference-data' import { format } from 'date-fns' interface Props { bookmark: Bookmark | null onClose: () => void onUpdate: () => void } export function BookmarkDetailSheet({ bookmark, onClose, onUpdate }: Props) { const [editing, setEditing] = useState(false) const [notes, setNotes] = useState('') const [kanban, setKanban] = useState('INBOX') const [dueDate, setDueDate] = useState('') const [alertAt, setAlertAt] = useState('') const [isPublic, setIsPublic] = useState(false) const [imapEnabled, setImapEnabled] = useState(false) const [imapQuery, setImapQuery] = useState('') const [shareSearch, setShareSearch] = useState('') const [shareResults, setShareResults] = useState([]) const [saving, setSaving] = useState(false) const [effectiveId, setEffectiveId] = useState(null) const [columns, setColumns] = useState([]) const [contacts, setContacts] = useState([]) const [columnId, setColumnId] = useState('') const [contactId, setContactId] = useState('') const [title, setTitle] = useState('') const [url, setUrl] = useState('') const [description, setDescription] = useState('') const [tagIds, setTagIds] = useState([]) const [allTags, setAllTags] = useState<{ id: string; name: string; color: string; ownerId?: string }[]>([]) // Review CR-04: set when a save hit a 409. The form keeps the unsaved // draft; the banner is the explicit, persistent resolution path (a toast // action alone would sit under the sheet's stacking context and vanish). const [conflict, setConflict] = useState(false) const canEdit = !!bookmark && effectiveId === bookmark.ownerId useEffect(() => { if (!bookmark) return let cancelled = false // Cached + deduplicated: opening the sheet repeatedly no longer fires // four identical requests each time (fetchReference never rejects). ;(async () => { const [me, cols, people, tags] = await Promise.all([ fetchReference<{ effectiveUser?: { id: string } }>('me'), fetchReference('columns'), fetchReference('contacts'), fetchReference<{ id: string; name: string; color: string; ownerId?: string }[]>('tags'), ]) if (cancelled) return if (me?.effectiveUser?.id) setEffectiveId(me.effectiveUser.id) if (cols) setColumns(cols) if (people) setContacts(people) if (tags) setAllTags(tags) })() return () => { cancelled = true } }, [bookmark]) useEffect(() => { if (bookmark) { const timer = setTimeout(() => { setNotes(bookmark?.notes ?? '') setKanban(bookmark?.kanbanStatus ?? 'INBOX') setDueDate(bookmark?.dueDate ? new Date(bookmark.dueDate).toISOString().split('T')[0] : '') setAlertAt(bookmark?.alertAt ? format(new Date(bookmark.alertAt), "yyyy-MM-dd'T'HH:mm") : '') setIsPublic(bookmark?.isPublic ?? false) setImapEnabled(bookmark?.imapWatchEnabled ?? false) setImapQuery(bookmark?.imapQuery ?? '') setEditing(false) setColumnId(bookmark.kanbanColumnId || '') setContactId(bookmark.contactId || '') setTitle(bookmark.title || '') setUrl(bookmark.url) setDescription(bookmark.description || '') setTagIds(bookmark.tags.map(({ tag }) => tag.id)) // A refreshed bookmark prop (after an explicit reload) clears the // conflict state along with the form. setConflict(false) }, 0) return () => clearTimeout(timer) } }, [bookmark]) const handleSave = useCallback(async () => { if (!bookmark) return setSaving(true) try { const res = await fetch(`/api/bookmarks/${bookmark.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ notes, title, url, description: description || null, tagIds: tagIds.length === bookmark.tags.length && bookmark.tags.every(({ tag }) => tagIds.includes(tag.id)) ? undefined : tagIds, kanbanColumnId: columnId || null, contactId: contactId || null, kanbanStatus: kanban, dueDate: dueDate || null, alertAt: alertAt ? new Date(alertAt).toISOString() : null, isPublic, imapWatchEnabled: imapEnabled, imapQuery: imapQuery || null, // Optimistic concurrency (review CR-04): the save carries the // version this form was opened from; a newer edit elsewhere is a // 409 and this draft is NOT lost silently. version: bookmark.version ?? 0, }), }) if (res.ok) { toast.success('Bookmark updated') setEditing(false) setConflict(false) onUpdate() } else if (res.status === 409) { // Review CR-04: keep the unsaved form exactly as it is; the inline // conflict banner below is the explicit resolution path. Reloading // is a deliberate user choice so anything worth keeping can be // copied first. setConflict(true) toast.error('This bookmark was changed elsewhere — your edits were NOT saved') } else { toast.error('Failed to update') } } catch { toast.error('Error saving') } setSaving(false) }, [bookmark, notes, title, url, description, tagIds, columnId, contactId, kanban, dueDate, alertAt, isPublic, imapEnabled, imapQuery, onUpdate]) const handleDelete = useCallback(async () => { if (!bookmark || !confirm('Move this bookmark to the trash? It can be restored.')) return const response = await fetch(`/api/bookmarks/${bookmark.id}`, { method: 'DELETE' }) if (!response.ok) { toast.error('Could not move bookmark to trash'); return } toast.success('Bookmark moved to trash') onClose() onUpdate() }, [bookmark, onClose, onUpdate]) const searchUsers = useCallback(async (q: string) => { setShareSearch(q) if (q.length < 2) { setShareResults([]); return } try { const res = await fetch(`/api/users/search?q=${encodeURIComponent(q)}`) if (res.ok) setShareResults(await res.json()) } catch { /* ignore */ } }, []) const shareWith = useCallback(async (userId: string) => { if (!bookmark) return const response = await fetch(`/api/bookmarks/${bookmark.id}/share`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ userIds: [userId] }), }) if (!response.ok) { toast.error('Could not share bookmark'); return } toast.success('Shared!') setShareSearch('') setShareResults([]) onUpdate() }, [bookmark, onUpdate]) const togglePublic = useCallback(async () => { if (!bookmark) return const response = await fetch(`/api/bookmarks/${bookmark.id}/share`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ isPublic: !isPublic }), }) if (!response.ok) { toast.error('Could not change public access'); return } setIsPublic(!isPublic) toast.success(isPublic ? 'Made private' : 'Made public') onUpdate() }, [bookmark, isPublic, onUpdate]) const domain = (() => { try { return bookmark ? new URL(bookmark.url).hostname : '' } catch { return '' } })() return ( onClose()}> {bookmark?.favicon && ( { e.target.style.display = 'none' }} /> )} {bookmark?.title ?? 'Untitled'}
{/* URL */} {bookmark?.url ?? ''} {canEdit &&