release gate / check (push) Failing after 6m43s
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.
174 lines
7.4 KiB
TypeScript
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>
|
|
)
|
|
}
|