Files
brain/web/components/bookmark-detail-sheet.tsx
T
Andrew S Erwin b9721ff13f
release gate / check (push) Failing after 8m35s
initial commit on Federted servers
2026-10-19 16:23:55 -05:00

392 lines
19 KiB
TypeScript

'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<any[]>([])
const [saving, setSaving] = useState(false)
const [effectiveId, setEffectiveId] = useState<string | null>(null)
const [columns, setColumns] = useState<any[]>([])
const [contacts, setContacts] = useState<any[]>([])
const [columnId, setColumnId] = useState('')
const [contactId, setContactId] = useState('')
const [title, setTitle] = useState('')
const [url, setUrl] = useState('')
const [description, setDescription] = useState('')
const [tagIds, setTagIds] = useState<string[]>([])
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<any[]>('columns'),
fetchReference<any[]>('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('Delete this bookmark?')) return
const response = await fetch(`/api/bookmarks/${bookmark.id}`, { method: 'DELETE' })
if (!response.ok) { toast.error('Could not delete bookmark'); return }
toast.success('Bookmark deleted')
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 (
<Sheet open={!!bookmark} onOpenChange={() => onClose()}>
<SheetContent className="w-full sm:max-w-lg overflow-y-auto">
<SheetHeader>
<SheetTitle className="flex items-center gap-2">
{bookmark?.favicon && (
<img src={bookmark.favicon} alt="" referrerPolicy="no-referrer" className="h-5 w-5 rounded" onError={(e: any) => { e.target.style.display = 'none' }} />
)}
<span className="truncate">{bookmark?.title ?? 'Untitled'}</span>
</SheetTitle>
</SheetHeader>
<div className="mt-4 space-y-6">
{/* URL */}
<a
href={bookmark?.url ?? '#'}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 rounded-lg bg-primary/10 px-4 py-3 text-sm font-medium text-primary hover:bg-primary/20 transition-colors"
>
<ExternalLink className="h-4 w-4 shrink-0" />
<span className="truncate">{bookmark?.url ?? ''}</span>
</a>
{canEdit && <div className="space-y-3">
<label className="block text-xs font-medium" htmlFor="bookmark-url">URL<Input id="bookmark-url" value={url} onChange={e => setUrl(e.target.value)} className="mt-1" /></label>
<label className="block text-xs font-medium" htmlFor="bookmark-description">Description<Textarea id="bookmark-description" value={description} onChange={e => setDescription(e.target.value)} rows={2} className="mt-1" /></label>
</div>}
{/* OG Preview */}
{bookmark?.ogImage && (
<div className="rounded-lg border overflow-hidden">
<img src={bookmark.ogImage} alt="" referrerPolicy="no-referrer" className="w-full aspect-video object-cover" onError={(e: any) => { e.target.style.display = 'none' }} />
<div className="p-3">
<p className="text-sm font-medium">{bookmark?.ogTitle ?? bookmark?.title}</p>
<p className="text-xs text-muted-foreground mt-1">{bookmark?.ogDescription}</p>
<p className="text-xs text-muted-foreground mt-1">{domain}</p>
</div>
</div>
)}
{/* Tags */}
<div>
<p className="text-xs font-medium text-muted-foreground mb-1.5 flex items-center gap-1"><Tag className="h-3 w-3" /> Tags</p>
<div className="flex flex-wrap gap-1.5">
{(bookmark?.tags ?? []).map((bt: any) => (
<Badge key={bt?.tag?.id} variant="secondary" style={{ backgroundColor: `${bt?.tag?.color ?? '#6366f1'}20`, color: bt?.tag?.color ?? '#6366f1' }}>
{bt?.tag?.name ?? ''}
</Badge>
))}
{(bookmark?.tags?.length ?? 0) === 0 && <span className="text-xs text-muted-foreground">No tags</span>}
</div>
{canEdit && bookmark && <div className="mt-2 space-y-1">
{[...new Map([...allTags, ...bookmark.tags.map(({ tag }) => tag)].map(tag => [tag.id, tag])).values()].map(tag => <label key={tag.id} className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={tagIds.includes(tag.id)} onChange={e => setTagIds(previous => e.target.checked ? [...previous, tag.id] : previous.filter(id => id !== tag.id))} />{tag.name}
{/* Group-wide tag sharing is a product decision; make the audience
visible before a tag is attached to private material (review). */}
{tag.ownerId && effectiveId && tag.ownerId !== effectiveId && (
<span className="text-xs text-muted-foreground">(shared tag — everyone with this tag can see bookmarks under it)</span>
)}
</label>)}
</div>}
</div>
{/* Notes */}
{canEdit && <div className="space-y-2"><label className="text-xs font-medium">Title</label><Input value={title} onChange={e => setTitle(e.target.value)} /></div>}
<div>
<div className="flex items-center justify-between mb-1.5">
<p className="text-xs font-medium text-muted-foreground">Notes</p>
<Button disabled={!canEdit} variant="ghost" size="xs" onClick={() => setEditing(!editing)}>
{editing ? <X className="h-3 w-3" /> : <Edit className="h-3 w-3" />}
</Button>
</div>
{editing ? (
<Textarea value={notes} onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setNotes(e.target.value)} rows={6} placeholder="Markdown notes..." />
) : (
<div className="prose prose-sm dark:prose-invert max-w-none rounded-lg bg-muted/50 p-3 min-h-[60px]">
{notes ? <ReactMarkdown remarkPlugins={[remarkGfm]}>{notes}</ReactMarkdown> : <span className="text-muted-foreground text-xs">No notes</span>}
</div>
)}
</div>
{/* Kanban Status */}
<fieldset disabled={!canEdit} className="space-y-4">
<div className="grid grid-cols-2 gap-3">
<label className="text-xs font-medium">Column<select className="mt-1 w-full rounded border bg-background p-2" value={columnId} onChange={e => setColumnId(e.target.value)}><option value="">Use legacy status</option>{columns.map(c => <option key={c.id} value={c.id}>{c.label}</option>)}</select></label>
<label className="text-xs font-medium">Contact<select className="mt-1 w-full rounded border bg-background p-2" value={contactId} onChange={e => setContactId(e.target.value)}><option value="">No contact</option>{contacts.map(c => <option key={c.id} value={c.id}>{c.displayName}</option>)}</select></label>
</div>
<div>
<p className="text-xs font-medium text-muted-foreground mb-1.5">Status</p>
<div className="flex gap-1.5">
{['INBOX', 'TODO', 'IN_PROGRESS', 'DONE'].map((s: string) => (
<Button
key={s}
variant={kanban === s ? 'default' : 'outline'}
size="xs"
onClick={() => setKanban(s)}
>
{s.replace('_', ' ')}
</Button>
))}
</div>
</div>
{/* Due Date & Alert */}
<div className="grid grid-cols-2 gap-3">
<div>
<p className="text-xs font-medium text-muted-foreground mb-1.5 flex items-center gap-1"><Calendar className="h-3 w-3" /> Due Date</p>
<Input type="date" value={dueDate} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setDueDate(e.target.value)} />
</div>
<div>
<p className="text-xs font-medium text-muted-foreground mb-1.5 flex items-center gap-1"><Clock className="h-3 w-3" /> Alert</p>
<Input type="datetime-local" value={alertAt} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setAlertAt(e.target.value)} />
</div>
</div>
{/* Contact */}
{bookmark?.contact && (
<div>
<p className="text-xs font-medium text-muted-foreground mb-1.5 flex items-center gap-1"><Users className="h-3 w-3" /> Contact</p>
<div className="flex items-center gap-2 rounded-lg bg-muted/50 px-3 py-2">
<div className="h-8 w-8 rounded-full bg-primary/20 flex items-center justify-center text-xs font-bold text-primary">
{(bookmark.contact?.displayName ?? '?')[0]?.toUpperCase()}
</div>
<div>
<p className="text-sm font-medium">{bookmark.contact?.displayName}</p>
<p className="text-xs text-muted-foreground">{bookmark.contact?.email}</p>
</div>
</div>
</div>
)}
{/* IMAP Watch */}
<div>
<p className="text-xs font-medium text-muted-foreground mb-1.5 flex items-center gap-1"><Mail className="h-3 w-3" /> Mailbox Watch</p>
<div className="flex items-center gap-2">
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={imapEnabled} onChange={() => setImapEnabled(!imapEnabled)} className="rounded" />
Enabled
</label>
{imapEnabled && (
<Input placeholder="Search query (subject/from)" value={imapQuery} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setImapQuery(e.target.value)} className="flex-1" />
)}
</div>
</div>
{/* Sharing */}
<div>
<p className="text-xs font-medium text-muted-foreground mb-1.5 flex items-center gap-1"><Share2 className="h-3 w-3" /> Sharing</p>
<div className="space-y-2">
<Button variant={isPublic ? 'default' : 'outline'} size="sm" onClick={togglePublic} className="gap-1.5">
<Globe className="h-3 w-3" />
{isPublic ? 'Public' : 'Private'}
</Button>
{isPublic && (
<div className="space-y-1 text-xs text-muted-foreground">
<p>
Public link: <code className="bg-muted px-1 rounded">/share/bookmark/{bookmark?.id}</code>
</p>
<p>Anyone with the link sees the title, description, <span className="font-medium text-foreground">notes</span>, due date, tags and preview. Contacts, alerts, mailbox watches and your identity stay private.</p>
</div>
)}
<Input placeholder="Search users to share with..." value={shareSearch} onChange={(e: React.ChangeEvent<HTMLInputElement>) => searchUsers(e.target.value)} />
{shareResults.map((u: any) => (
<button key={u?.id} onClick={() => shareWith(u?.id)} className="flex items-center gap-2 w-full rounded-md px-2 py-1.5 hover:bg-muted text-sm">
<span className="h-6 w-6 rounded-full bg-primary/20 flex items-center justify-center text-xs font-bold text-primary">
{(u?.name ?? u?.email ?? '?')[0]?.toUpperCase()}
</span>
{u?.name ?? u?.email}
</button>
))}
</div>
</div>
{/* Actions */}
</fieldset>
{conflict && canEdit && (
<div role="alert" className="rounded-md border border-destructive/40 bg-destructive/10 p-3 text-sm space-y-2">
<p className="font-medium text-destructive">This bookmark was changed elsewhere</p>
<p className="text-muted-foreground">
Your edits are kept in this form but were NOT saved — saving again would overwrite the
newer version. Copy anything you want to keep, then load the latest version.
</p>
<Button size="sm" variant="outline" onClick={() => onUpdate()}>Load latest version</Button>
</div>
)}
{bookmark && <BookmarkExtras bookmark={bookmark} canEdit={canEdit} onUpdate={onUpdate} />}
{!canEdit && <p className="text-xs text-muted-foreground">Shared bookmark — read only</p>}
{canEdit &&
<div className="flex items-center gap-2 pt-2 border-t">
<Button onClick={handleSave} loading={saving} className="flex-1 gap-1.5">
<Save className="h-4 w-4" /> Save Changes
</Button>
<Button variant="destructive" size="icon" onClick={handleDelete}>
<Trash2 className="h-4 w-4" />
</Button>
</div>}
</div>
</SheetContent>
</Sheet>
)
}