Files
brain/web/components/add-bookmark-dialog.tsx
T
Andrew S Erwin 4e421f6ea2
release gate / check (push) Failing after 6m43s
Address code review findings across API, web and deployment
High severity:
- wire TRUSTED_PROXY through Compose/deploy/docs and warn at web startup
- bucket public rate limits on effective client IP, add per-bookmark budget,
  and forward the signed browser identity from the share page
- disable first-run bootstrap in server deployments and document seeding
- serialize last-admin demotion in one advisory-locked transaction

Medium/low:
- cap and batch share recipient inserts
- add a shared outbound budget for preview/CardDAV/IMAP/signal work
- pin CardDAV dials to 443 and bound IMAP logout
- fix rightward kanban column drags and normalize cards on column delete
- classify backend outages in auth guards, add request timeouts and an
  error boundary, remove the dead login route, surface throttling
- enforce pinned/unpinned page reorder grouping server-side
- avoid preview work for stale bookmark versions; drop duplicate dialog fetch
- sequence-guard collection/tag/calendar/quick-find loads
- validate property config and fix MULTI_SELECT/DATE filter inputs
- document index maintenance; apply same-origin check to sign-out
- trim signup names, unique shared dates and kanban keys (migration 010),
  reset reference cache on identity changes, hide OIDC issuer, return 413
  for oversized direct bodies, escape Markdown link destinations
- remove dead template files and duplicate test helper

Verified with go build/vet, full PostgreSQL race suite, db convergence,
tsc, both ESLint configs and a Next production build.
2026-10-20 00:13:30 -05:00

174 lines
7.4 KiB
TypeScript

'use client'
import { useState, useCallback, useEffect } from 'react'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Badge } from '@/components/ui/badge'
import { Link2, Plus, X } from 'lucide-react'
import { toast } from 'sonner'
import { invalidateReference } from '@/lib/reference-data'
interface Props {
open: boolean
onClose: () => void
onCreated: () => void
}
export function AddBookmarkDialog({ open, onClose, onCreated }: Props) {
const [url, setUrl] = useState('')
const [title, setTitle] = useState('')
const [description, setDescription] = useState('')
const [notes, setNotes] = useState('')
const [dueDate, setDueDate] = useState('')
const [kanban, setKanban] = useState('INBOX')
const [loading, setLoading] = useState(false)
const [tags, setTags] = useState<{ id: string; name: string; color: string }[]>([])
const [allTags, setAllTags] = useState<any[]>([])
const [newTagName, setNewTagName] = useState('')
useEffect(() => {
if (open) {
fetch('/api/tags').then(r => r.json()).then(setAllTags).catch(() => {})
const timer = setTimeout(() => { setUrl(''); setTitle(''); setDescription(''); setNotes(''); setDueDate(''); setKanban('INBOX'); setTags([]) }, 0)
return () => clearTimeout(timer)
}
}, [open])
// Review M-10: no client-side preview fetch. The server fetches preview
// metadata once during create (and fills title/description defaults), so a
// blur followed by Save no longer spends the preview budget twice.
const handleSubmit = useCallback(async () => {
if (!url) { toast.error('URL is required'); return }
setLoading(true)
try {
const res = await fetch('/api/bookmarks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
url,
title: title || undefined,
description: description || undefined,
notes: notes || undefined,
dueDate: dueDate || undefined,
kanbanStatus: kanban,
tagIds: tags.map(t => t.id),
}),
})
if (res.ok) {
toast.success('Bookmark created')
onCreated()
} else {
const data = await res.json().catch(() => ({}))
toast.error(data?.error ?? 'Failed')
}
} catch { toast.error('Error creating bookmark') }
setLoading(false)
}, [url, title, description, notes, dueDate, kanban, tags, onCreated])
const createTag = useCallback(async () => {
if (!newTagName) return
try {
const res = await fetch('/api/tags', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: newTagName }),
})
if (res.ok) {
const tag = await res.json()
setTags(prev => [...prev, { id: tag.id, name: tag.name, color: tag.color }])
setAllTags(prev => [...prev, tag])
setNewTagName('')
invalidateReference('tags')
}
} catch { /* ignore */ }
}, [newTagName])
return (
<Dialog open={open} onOpenChange={() => onClose()}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2"><Link2 className="h-5 w-5" /> Add Bookmark</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div>
<label className="text-xs font-medium text-muted-foreground">URL *</label>
<div className="relative">
<Input
placeholder="https://..."
value={url}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setUrl(e.target.value)}
/>
</div>
</div>
<div>
<label className="text-xs font-medium text-muted-foreground">Title</label>
<Input value={title} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setTitle(e.target.value)} placeholder="Auto-filled from URL" />
</div>
<div>
<label className="text-xs font-medium text-muted-foreground">Description</label>
<Input value={description} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setDescription(e.target.value)} />
</div>
<div>
<label className="text-xs font-medium text-muted-foreground">Notes (Markdown)</label>
<Textarea value={notes} onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setNotes(e.target.value)} rows={3} />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs font-medium text-muted-foreground">Due Date</label>
<Input type="date" value={dueDate} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setDueDate(e.target.value)} />
</div>
<div>
<label className="text-xs font-medium text-muted-foreground">Status</label>
<select
value={kanban}
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => setKanban(e.target.value)}
className="w-full rounded-md border bg-background px-3 py-2 text-sm"
>
<option value="INBOX">Inbox</option>
<option value="TODO">To Do</option>
<option value="IN_PROGRESS">In Progress</option>
<option value="DONE">Done</option>
</select>
</div>
</div>
<div>
<label className="text-xs font-medium text-muted-foreground">Tags</label>
<div className="flex flex-wrap gap-1.5 mb-2">
{tags.map(t => (
<Badge key={t.id} variant="secondary" className="gap-1" style={{ backgroundColor: `${t.color}20`, color: t.color }}>
{t.name}
<button onClick={() => setTags(prev => prev.filter(x => x.id !== t.id))}><X className="h-3 w-3" /></button>
</Badge>
))}
</div>
<div className="flex gap-1.5">
<select
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => {
const tag = allTags.find((t: any) => t.id === e.target.value)
if (tag && !tags.find(t => t.id === tag.id)) setTags(prev => [...prev, { id: tag.id, name: tag.name, color: tag.color }])
e.target.value = ''
}}
className="flex-1 rounded-md border bg-background px-3 py-1.5 text-sm"
defaultValue=""
>
<option value="" disabled>Select tag...</option>
{allTags.map((t: any) => <option key={t.id} value={t.id}>{t.name}</option>)}
</select>
<div className="flex gap-1">
<Input placeholder="New tag" value={newTagName} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setNewTagName(e.target.value)} className="w-24" />
<Button variant="outline" size="icon-sm" onClick={createTag}><Plus className="h-3 w-3" /></Button>
</div>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>Cancel</Button>
<Button onClick={handleSubmit} loading={loading}>Save Bookmark</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}