This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { Eye, Shield } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
type U = { id: string; name: string | null; email: string; role: string; _count?: { bookmarks: number; pages: number; tags: number } }
|
||||
|
||||
export function AdminClient({ currentUserId }: { currentUserId: string }) {
|
||||
const [users, setUsers] = useState<U[]>([])
|
||||
|
||||
const load = () =>
|
||||
fetch('/api/admin/users')
|
||||
.then((r) => (r.ok ? r.json() : []))
|
||||
.then((d) => setUsers(Array.isArray(d) ? d : d?.users ?? []))
|
||||
.catch((e) => console.error('Failed to load users', e))
|
||||
|
||||
useEffect(() => { load() }, [])
|
||||
|
||||
const viewAs = async (userId: string) => {
|
||||
const res = await fetch('/api/admin/view-as', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ userId }) })
|
||||
if (!res.ok) return toast.error('Could not switch account')
|
||||
window.location.href = '/pages'
|
||||
}
|
||||
|
||||
const toggleRole = async (u: U) => {
|
||||
const res = await fetch('/api/admin/users', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ userId: u.id, role: u.role === 'ADMIN' ? 'USER' : 'ADMIN' }) })
|
||||
if (!res.ok) return toast.error((await res.json().catch(() => null))?.error ?? 'Could not change role')
|
||||
load()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl">
|
||||
<h1 className="font-display text-2xl font-bold">Admin</h1>
|
||||
<p className="mb-6 text-sm text-muted-foreground">All accounts in the group. “View as” opens an account with a visible banner; exit any time.</p>
|
||||
<div className="divide-y divide-border/60 rounded-xl bg-card shadow-sm ring-1 ring-border/50">
|
||||
{users.map((u) => (
|
||||
<div key={u.id} className="flex flex-wrap items-center gap-3 p-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate font-medium">{u.name || u.email} {u.role === 'ADMIN' && <Shield className="ml-1 inline h-3.5 w-3.5 text-primary" />}</p>
|
||||
<p className="truncate text-xs text-muted-foreground">{u.email} · {u._count?.bookmarks ?? 0} bookmarks · {u._count?.pages ?? 0} pages</p>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" disabled={u.id === currentUserId} onClick={() => toggleRole(u)}>{u.role === 'ADMIN' ? 'Make user' : 'Make admin'}</Button>
|
||||
<Button size="sm" disabled={u.id === currentUserId} onClick={() => viewAs(u.id)}><Eye className="mr-1 h-4 w-4" />View as</Button>
|
||||
</div>
|
||||
))}
|
||||
{users.length === 0 && <p className="p-6 text-center text-sm text-muted-foreground">No users found.</p>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export const dynamic = 'force-dynamic'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { getRealUser } from '@/lib/auth-guard'
|
||||
import { AdminClient } from './client'
|
||||
|
||||
export default async function AdminPage() {
|
||||
const user = await getRealUser()
|
||||
if (!user) redirect('/login')
|
||||
if (user.role !== 'ADMIN') redirect('/pages')
|
||||
return <AdminClient currentUserId={user.id} />
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { BookmarkDetailSheet } from '@/components/bookmark-detail-sheet'
|
||||
import { ExternalLink, Search, Trash2, ChevronUp, ChevronDown, Inbox, Share2 } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import type { Bookmark } from '@/components/app-shell'
|
||||
import { fetchBookmarks } from '@/lib/bookmarks'
|
||||
|
||||
export function BookmarksClient({ initialSearch = null, initialOpen = null }: { initialSearch?: string | null; initialOpen?: string | null }) {
|
||||
const [bookmarks, setBookmarks] = useState<Bookmark[]>([])
|
||||
const [sharedBookmarks, setSharedBookmarks] = useState<Bookmark[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [search, setSearch] = useState('')
|
||||
const [selected, setSelected] = useState<Bookmark | null>(null)
|
||||
// Review CR-04: the sheet must receive the CURRENT row after mutations —
|
||||
// its conflict banner ("Load latest version") and successful saves need
|
||||
// the fresh version, or the next save would send a stale base.
|
||||
const selectedRef = useRef<Bookmark | null>(null)
|
||||
selectedRef.current = selected
|
||||
const refreshSelected = useCallback(async () => {
|
||||
const id = selectedRef.current?.id
|
||||
if (!id) return
|
||||
try {
|
||||
const res = await fetch(`/api/bookmarks/${encodeURIComponent(id)}`, { cache: 'no-store' })
|
||||
if (res.ok) setSelected(await res.json())
|
||||
} catch { /* the list load surfaces errors */ }
|
||||
}, [])
|
||||
const [tab, setTab] = useState<'mine' | 'shared'>('mine')
|
||||
const [sortField, setSortField] = useState<'title' | 'updatedAt' | 'kanbanStatus'>('updatedAt')
|
||||
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc')
|
||||
|
||||
// Debounced, abortable, sequence-guarded load: a slow response for an older
|
||||
// query can never overwrite newer results (same discipline as pages client).
|
||||
const loadSeq = useRef(0)
|
||||
const abortRef = useRef<AbortController | null>(null)
|
||||
const load = useCallback(() => {
|
||||
abortRef.current?.abort()
|
||||
const ac = new AbortController()
|
||||
abortRef.current = ac
|
||||
const seq = ++loadSeq.current
|
||||
setLoading(true)
|
||||
const params = new URLSearchParams()
|
||||
if (search) params.set('search', search)
|
||||
;(async () => {
|
||||
try {
|
||||
const mine = await fetchBookmarks(params, ac.signal)
|
||||
if (seq !== loadSeq.current) return
|
||||
setBookmarks(mine ?? [])
|
||||
} catch {
|
||||
if (seq === loadSeq.current && !ac.signal.aborted) toast.error('Could not load bookmarks')
|
||||
} finally {
|
||||
if (seq === loadSeq.current) setLoading(false)
|
||||
}
|
||||
})()
|
||||
}, [search])
|
||||
|
||||
// The shared list is not search-filtered; load it once and on changes only.
|
||||
const loadShared = useCallback(async () => {
|
||||
try { setSharedBookmarks(await fetchBookmarks(new URLSearchParams({ shared: 'true' })) ?? []) }
|
||||
catch { /* the mine-tab load surfaces errors */ }
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(load, search ? 250 : 0)
|
||||
const reload = () => { load(); void loadShared() }
|
||||
window.addEventListener('computer:bookmarks-changed', reload)
|
||||
return () => { clearTimeout(timer); window.removeEventListener('computer:bookmarks-changed', reload); abortRef.current?.abort() }
|
||||
}, [load, loadShared, search])
|
||||
useEffect(() => { const timer = setTimeout(loadShared, 0); return () => clearTimeout(timer) }, [loadShared])
|
||||
// Quick Find and the dashboard redirect land here with ?search=; the prop
|
||||
// comes from the server component so query changes apply without a remount.
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setSearch(initialSearch || ''), 0)
|
||||
return () => clearTimeout(timer)
|
||||
}, [initialSearch])
|
||||
// Quick Find deep-links straight to a bookmark's detail sheet via ?open=<id>.
|
||||
useEffect(() => {
|
||||
if (!initialOpen) return
|
||||
const timer = setTimeout(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/bookmarks/${encodeURIComponent(initialOpen)}`)
|
||||
if (res.ok) setSelected(await res.json())
|
||||
} catch { /* stale or inaccessible id; the list still renders */ }
|
||||
}, 0)
|
||||
return () => clearTimeout(timer)
|
||||
}, [initialOpen])
|
||||
|
||||
const displayBookmarks = tab === 'mine' ? bookmarks : sharedBookmarks
|
||||
const sorted = [...(displayBookmarks ?? [])].sort((a: any, b: any) => {
|
||||
const va = a?.[sortField] ?? ''
|
||||
const vb = b?.[sortField] ?? ''
|
||||
return sortDir === 'asc' ? String(va).localeCompare(String(vb)) : String(vb).localeCompare(String(va))
|
||||
})
|
||||
|
||||
const toggleSort = (field: typeof sortField) => {
|
||||
if (sortField === field) setSortDir(d => d === 'asc' ? 'desc' : 'asc')
|
||||
else { setSortField(field); setSortDir('asc') }
|
||||
}
|
||||
|
||||
const sortIcon = (field: typeof sortField) => {
|
||||
if (sortField !== field) return null
|
||||
return sortDir === 'asc' ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="font-display text-2xl font-bold tracking-tight">All Bookmarks</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">Every saved link. Use the sidebar to filter by calendar, tags, board, or contacts.</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative flex-1 max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input placeholder="Filter..." value={search} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setSearch(e.target.value)} className="pl-9" />
|
||||
</div>
|
||||
<div className="flex rounded-lg border overflow-hidden">
|
||||
<button onClick={() => setTab('mine')} className={`flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium transition-colors ${tab === 'mine' ? 'bg-primary text-primary-foreground' : 'bg-card hover:bg-muted'}`}>
|
||||
<Inbox className="h-3.5 w-3.5" /> Mine
|
||||
</button>
|
||||
<button onClick={() => setTab('shared')} className={`flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium transition-colors ${tab === 'shared' ? 'bg-primary text-primary-foreground' : 'bg-card hover:bg-muted'}`}>
|
||||
<Share2 className="h-3.5 w-3.5" /> Shared
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-muted/50 text-left">
|
||||
<th className="px-4 py-3 font-medium cursor-pointer" onClick={() => toggleSort('title')}>
|
||||
<span className="flex items-center gap-1">Title {sortIcon('title')}</span>
|
||||
</th>
|
||||
<th className="px-4 py-3 font-medium">URL</th>
|
||||
<th className="px-4 py-3 font-medium">Tags</th>
|
||||
<th className="px-4 py-3 font-medium cursor-pointer" onClick={() => toggleSort('kanbanStatus')}>
|
||||
<span className="flex items-center gap-1">Status {sortIcon('kanbanStatus')}</span>
|
||||
</th>
|
||||
<th className="px-4 py-3 font-medium cursor-pointer" onClick={() => toggleSort('updatedAt')}>
|
||||
<span className="flex items-center gap-1">Updated {sortIcon('updatedAt')}</span>
|
||||
</th>
|
||||
<th className="px-4 py-3 font-medium w-20">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{loading ? (
|
||||
<tr><td colSpan={6} className="px-4 py-8 text-center text-muted-foreground">Loading...</td></tr>
|
||||
) : sorted.length === 0 ? (
|
||||
<tr><td colSpan={6} className="px-4 py-8 text-center text-muted-foreground">No bookmarks found</td></tr>
|
||||
) : sorted.map((b: Bookmark) => (
|
||||
<tr key={b.id} tabIndex={0} className="hover:bg-muted/30 cursor-pointer focus-visible:outline-2 focus-visible:outline-primary" onClick={() => setSelected(b)} onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setSelected(b) } }}>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
{b.favicon && <img src={b.favicon} alt="" referrerPolicy="no-referrer" className="h-4 w-4 rounded" onError={(e: any) => { e.target.style.display = 'none' }} />}
|
||||
<span className="truncate max-w-[200px]">{b.title ?? 'Untitled'}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<a href={b.url} target="_blank" rel="noopener noreferrer" onClick={(e: React.MouseEvent) => e.stopPropagation()} className="text-primary hover:underline truncate max-w-[200px] block">
|
||||
{(() => { try { return new URL(b.url).hostname } catch { return b.url } })()}
|
||||
</a>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex gap-1">
|
||||
{(b.tags ?? []).slice(0, 2).map((bt: any) => (
|
||||
<Badge key={bt?.tag?.id} variant="secondary" className="text-[10px] px-1.5 py-0" style={{ backgroundColor: `${bt?.tag?.color}20`, color: bt?.tag?.color }}>{bt?.tag?.name}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3"><Badge variant="outline" className="text-[10px]">{b.kanbanStatus?.replace('_', ' ')}</Badge></td>
|
||||
<td className="px-4 py-3 text-xs text-muted-foreground">{new Date(b.updatedAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric', timeZone: 'UTC' })}</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex gap-1" onClick={(e: React.MouseEvent) => e.stopPropagation()}>
|
||||
<a href={b.url} target="_blank" rel="noopener noreferrer"><ExternalLink className="h-4 w-4 text-muted-foreground hover:text-primary" /></a>
|
||||
{tab === 'mine' && (
|
||||
<button onClick={async () => { if (!confirm('Delete?')) return; try { const res = await fetch(`/api/bookmarks/${b.id}`, { method: 'DELETE' }); if (res.ok) { toast.success('Deleted'); load() } else toast.error('Could not delete bookmark') } catch { toast.error('Could not delete bookmark') } }}><Trash2 className="h-4 w-4 text-muted-foreground hover:text-destructive" /></button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<BookmarkDetailSheet bookmark={selected} onClose={() => setSelected(null)} onUpdate={() => { load(); void refreshSelected() }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { BookmarksClient } from './client'
|
||||
|
||||
// Quick Find deep-links (?open=<id>, ?search=<q>) arrive through searchParams
|
||||
// so the client reacts to query changes without remounting.
|
||||
export default async function BookmarksPage({ searchParams }: { searchParams: Promise<{ search?: string; open?: string }> }) {
|
||||
const { search, open } = await searchParams
|
||||
return <BookmarksClient initialSearch={search ?? null} initialOpen={open ?? null} />
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { BookmarkCard } from '@/components/bookmark-card'
|
||||
import { BookmarkDetailSheet } from '@/components/bookmark-detail-sheet'
|
||||
import { ChevronLeft, ChevronRight, Share2 } from 'lucide-react'
|
||||
import { format, startOfMonth, endOfMonth, eachDayOfInterval, isSameDay, isSameMonth, addMonths, subMonths, getDay } from 'date-fns'
|
||||
import type { Bookmark } from '@/components/app-shell'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { toast } from 'sonner'
|
||||
import { fetchBookmarks } from '@/lib/bookmarks'
|
||||
|
||||
const INITIAL_DATE = new Date(2026, 9, 1)
|
||||
|
||||
export function CalendarClient() {
|
||||
const [currentMonth, setCurrentMonth] = useState<Date>(INITIAL_DATE)
|
||||
const [today, setToday] = useState<Date>(INITIAL_DATE)
|
||||
const [bookmarks, setBookmarks] = useState<Bookmark[]>([])
|
||||
const [selectedDay, setSelectedDay] = useState<Date | null>(null)
|
||||
const [selectedBookmark, setSelectedBookmark] = useState<Bookmark | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [sharedDates, setSharedDates] = useState<any[]>([])
|
||||
const [shareQuery, setShareQuery] = useState('')
|
||||
const [shareUsers, setShareUsers] = useState<any[]>([])
|
||||
|
||||
// Debounced user search; failures keep the previous results silently.
|
||||
useEffect(() => {
|
||||
const q = shareQuery.trim()
|
||||
const t = setTimeout(async () => {
|
||||
if (q.length < 2) { setShareUsers([]); return }
|
||||
try {
|
||||
const res = await fetch(`/api/users/search?q=${encodeURIComponent(q)}`)
|
||||
if (res.ok) setShareUsers(await res.json())
|
||||
} catch { /* transient; retried on next keystroke */ }
|
||||
}, q.length < 2 ? 0 : 250)
|
||||
return () => clearTimeout(t)
|
||||
}, [shareQuery])
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => { const now = new Date(); setCurrentMonth(now); setToday(now) }, 0)
|
||||
return () => clearTimeout(timer)
|
||||
}, [])
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [mine, shared, dates] = await Promise.all([fetchBookmarks(), fetchBookmarks(new URLSearchParams({ shared: 'true' })), fetch('/api/shared-dates')])
|
||||
setBookmarks([...mine, ...shared])
|
||||
if (dates.ok) setSharedDates(await dates.json())
|
||||
} catch { toast.error('Could not load calendar') }
|
||||
finally { setLoading(false) }
|
||||
}, [])
|
||||
|
||||
useEffect(() => { const timer = setTimeout(load, 0); window.addEventListener('computer:bookmarks-changed', load); return () => { clearTimeout(timer); window.removeEventListener('computer:bookmarks-changed', load) } }, [load])
|
||||
|
||||
const days = useMemo(() => {
|
||||
const start = startOfMonth(currentMonth)
|
||||
const end = endOfMonth(currentMonth)
|
||||
return eachDayOfInterval({ start, end })
|
||||
}, [currentMonth])
|
||||
|
||||
const startDayOfWeek = getDay(startOfMonth(currentMonth))
|
||||
|
||||
const bookmarksByDay = useMemo(() => {
|
||||
const map: Record<string, Bookmark[]> = {}
|
||||
for (const b of bookmarks) {
|
||||
if (b?.dueDate) {
|
||||
const key = b.dueDate.slice(0, 10)
|
||||
if (!map[key]) map[key] = []
|
||||
map[key].push(b)
|
||||
}
|
||||
}
|
||||
return map
|
||||
}, [bookmarks])
|
||||
|
||||
const selectedBookmarks = useMemo(() => {
|
||||
if (!selectedDay) return []
|
||||
const key = format(selectedDay, 'yyyy-MM-dd')
|
||||
return bookmarksByDay[key] ?? []
|
||||
}, [selectedDay, bookmarksByDay])
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="font-display text-2xl font-bold tracking-tight">Calendar</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">Bookmarks organized by due date</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="icon-sm" onClick={() => setCurrentMonth(subMonths(currentMonth, 1))}>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="font-medium text-sm min-w-[140px] text-center">{format(currentMonth, 'MMMM yyyy')}</span>
|
||||
<Button variant="outline" size="icon-sm" onClick={() => setCurrentMonth(addMonths(currentMonth, 1))}>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-7 gap-px bg-border rounded-lg overflow-hidden">
|
||||
{['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].map(d => (
|
||||
<div key={d} className="bg-muted/50 px-2 py-2 text-center text-xs font-medium text-muted-foreground">{d}</div>
|
||||
))}
|
||||
{Array.from({ length: startDayOfWeek }).map((_, i) => (
|
||||
<div key={`empty-${i}`} className="bg-card min-h-[80px]" />
|
||||
))}
|
||||
{days.map(day => {
|
||||
const key = format(day, 'yyyy-MM-dd')
|
||||
const dayBookmarks = bookmarksByDay[key] ?? []
|
||||
const isSelected = selectedDay ? isSameDay(day, selectedDay) : false
|
||||
const isToday = isSameDay(day, today)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-pressed={isSelected}
|
||||
aria-label={format(day, 'EEEE, MMMM d, yyyy')}
|
||||
onClick={() => setSelectedDay(day)}
|
||||
onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setSelectedDay(day) } }}
|
||||
className={`bg-card min-h-[80px] p-1.5 cursor-pointer transition-colors hover:bg-muted/50 focus-visible:outline-2 focus-visible:outline-primary ${
|
||||
isSelected ? 'ring-2 ring-primary ring-inset' : ''
|
||||
}`}
|
||||
>
|
||||
<span className={`text-xs font-medium inline-flex h-6 w-6 items-center justify-center rounded-full ${
|
||||
isToday ? 'bg-primary text-primary-foreground' : 'text-foreground'
|
||||
}`}>
|
||||
{format(day, 'd')}
|
||||
</span>
|
||||
<div className="space-y-0.5 mt-0.5">
|
||||
{sharedDates.filter(s => s.date.slice(0, 10) === key).map(s => <p key={s.id} className="truncate text-[10px] text-primary" title={s.note || ''}>Shared: {s.creator.name || s.creator.email}</p>)}
|
||||
{dayBookmarks.slice(0, 2).map((b: Bookmark) => (
|
||||
<div key={b.id} role="button" tabIndex={0} className="text-[10px] truncate rounded bg-primary/10 text-primary px-1 py-0.5 cursor-pointer focus-visible:outline-1 focus-visible:outline-primary" onClick={(e: React.MouseEvent) => { e.stopPropagation(); setSelectedBookmark(b) }} onKeyDown={(e: React.KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); e.stopPropagation(); setSelectedBookmark(b) } }}>
|
||||
{b.title ?? 'Untitled'}
|
||||
</div>
|
||||
))}
|
||||
{dayBookmarks.length > 2 && (
|
||||
<div className="text-[10px] text-muted-foreground px-1">+{dayBookmarks.length - 2} more</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{selectedDay && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="font-medium">{format(selectedDay, 'EEEE, MMMM d, yyyy')}</h2>
|
||||
<div className="flex gap-2"><Input placeholder="Share date with user…" value={shareQuery} onChange={e => setShareQuery(e.target.value)} /></div>
|
||||
</div>
|
||||
{shareUsers.map(u => <Button key={u.id} size="sm" variant="outline" onClick={async () => { const res = await fetch('/api/shared-dates', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ date: format(selectedDay, 'yyyy-MM-dd'), recipientIds: [u.id] }) }); if (!res.ok) { toast.error('Could not share date'); return } toast.success('Date shared'); setShareQuery(''); setShareUsers([]); load() }}>Share with {u.name || u.email}</Button>)}
|
||||
{sharedDates.filter(s => s.date.slice(0, 10) === format(selectedDay, 'yyyy-MM-dd')).map(s => <div key={s.id} className="flex items-center gap-2 text-xs text-muted-foreground"><span>{s.creator.name || s.creator.email} shared with {s.recipient.name || s.recipient.email}</span><Button size="xs" variant="ghost" onClick={async () => { const res = await fetch(`/api/shared-dates/${s.id}`, { method: 'DELETE' }); if (!res.ok) { toast.error('Only the owner can stop sharing'); return } load() }}>Stop sharing</Button></div>)}
|
||||
{selectedBookmarks.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No bookmarks on this date</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{selectedBookmarks.map((b: Bookmark) => (
|
||||
<BookmarkCard key={b.id} bookmark={b} compact onClick={() => setSelectedBookmark(b)} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<BookmarkDetailSheet bookmark={selectedBookmark} onClose={() => setSelectedBookmark(null)} onUpdate={load} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { CalendarClient } from './client'
|
||||
|
||||
export default function CalendarPage() {
|
||||
return <CalendarClient />
|
||||
}
|
||||
@@ -0,0 +1,883 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
import { CalendarDays, Columns3, Filter, GripVertical, Kanban, Layers, List, Plus, Table2, Trash2, X } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import type { Bookmark } from '@/components/app-shell'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// Collections workspace (Notion-like upgrades phase 2). Contextual workflow
|
||||
// state over existing bookmarks: the same link can be "Done" in one
|
||||
// collection and "Unread" in another, and removing a membership never
|
||||
// deletes the bookmark. Every mutating PATCH carries the expected version;
|
||||
// a 409 reloads instead of clobbering (the DATA-05 contract).
|
||||
|
||||
type PropertyOption = { id: string; label: string; color?: string }
|
||||
type CollectionProperty = {
|
||||
id: string; name: string
|
||||
type: 'TEXT' | 'NUMBER' | 'SELECT' | 'MULTI_SELECT' | 'DATE' | 'CHECKBOX' | 'URL'
|
||||
position: number; config: { options?: PropertyOption[]; mode?: 'date' | 'datetime' }; version: number
|
||||
}
|
||||
type FilterClause = { propertyId: string; op: string; value?: unknown }
|
||||
type CollectionView = {
|
||||
id: string; name: string; type: 'TABLE' | 'BOARD' | 'CALENDAR' | 'LIST'; position: number
|
||||
filters: { operator: 'AND' | 'OR'; clauses: FilterClause[] }
|
||||
sorts: { field: string; dir: string }[]
|
||||
groupByPropertyId: string | null; displaySettings: Record<string, unknown>; version: number
|
||||
}
|
||||
type CollectionItem = {
|
||||
id: string; collectionId: string; bookmarkId: string
|
||||
propertyValues: Record<string, unknown>; position: number; version: number
|
||||
addedAt: string; updatedAt: string; bookmark: Bookmark
|
||||
}
|
||||
type CollectionSummary = {
|
||||
id: string; name: string; description: string | null; icon: string | null
|
||||
version: number; createdAt: string; updatedAt: string
|
||||
_count: { items: number; properties: number; views: number }
|
||||
}
|
||||
type CollectionDetail = CollectionSummary & { properties: CollectionProperty[]; views: CollectionView[] }
|
||||
|
||||
const VIEW_ICONS = { TABLE: Table2, BOARD: Columns3, CALENDAR: CalendarDays, LIST: List } as const
|
||||
const OP_LABELS: Record<string, string> = {
|
||||
eq: 'is', ne: 'is not', contains: 'contains', gt: 'after/above', gte: '≥', lt: 'before/below', lte: '≤',
|
||||
empty: 'is empty', notEmpty: 'is not empty',
|
||||
}
|
||||
// Mirrors the server allowlists in internal/handlers/collections.go.
|
||||
const FILTER_OPS: Record<string, string[]> = {
|
||||
TEXT: ['eq', 'ne', 'contains', 'empty', 'notEmpty'],
|
||||
URL: ['eq', 'ne', 'contains', 'empty', 'notEmpty'],
|
||||
NUMBER: ['eq', 'ne', 'gt', 'gte', 'lt', 'lte', 'empty', 'notEmpty'],
|
||||
DATE: ['eq', 'ne', 'gt', 'gte', 'lt', 'lte', 'empty', 'notEmpty'],
|
||||
SELECT: ['eq', 'ne', 'empty', 'notEmpty'],
|
||||
CHECKBOX: ['eq', 'ne', 'empty', 'notEmpty'],
|
||||
MULTI_SELECT: ['contains', 'empty', 'notEmpty'],
|
||||
}
|
||||
const PROPERTY_TYPES = ['TEXT', 'NUMBER', 'SELECT', 'MULTI_SELECT', 'DATE', 'CHECKBOX', 'URL'] as const
|
||||
const NONE = '__none__'
|
||||
|
||||
async function json(res: Response): Promise<any> { return res.json().catch(() => null) }
|
||||
|
||||
// datetime-local inputs need local "YYYY-MM-DDTHH:mm"; the server stores a
|
||||
// fixed-width UTC RFC3339 string, so convert through Date on both ends.
|
||||
function toLocalInput(stored: unknown): string {
|
||||
if (typeof stored !== 'string' || !stored) return ''
|
||||
const d = new Date(stored)
|
||||
if (Number.isNaN(d.getTime())) return ''
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||||
}
|
||||
|
||||
function BookmarkCell({ bookmark }: { bookmark: Bookmark }) {
|
||||
const host = (() => { try { return new URL(bookmark.url).hostname } catch { return bookmark.url } })()
|
||||
return (
|
||||
<Link href={`/bookmarks?open=${bookmark.id}`} className="flex min-w-0 items-center gap-2 hover:underline">
|
||||
{bookmark.favicon
|
||||
// eslint-disable-next-line @next/next/no-img-element -- remote favicons stay no-referrer (web/AGENTS.md)
|
||||
? <img src={bookmark.favicon} alt="" referrerPolicy="no-referrer" className="h-4 w-4 shrink-0 rounded-sm" />
|
||||
: <Layers className="h-4 w-4 shrink-0 text-muted-foreground" />}
|
||||
<span className="min-w-0">
|
||||
<span className="block max-w-[16rem] truncate text-sm">{bookmark.title || bookmark.url}</span>
|
||||
<span className="block max-w-[16rem] truncate text-xs text-muted-foreground">{host}</span>
|
||||
</span>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
const asText = (v: unknown) => (typeof v === 'string' || typeof v === 'number' ? String(v) : '')
|
||||
|
||||
// Per-type inline value editor. Saves immediately on commit gestures
|
||||
// (change/blur/Enter); failures toast and the prop value re-renders the
|
||||
// server state, so a rejected edit never sticks locally.
|
||||
function ValueEditor({ prop, value, onSave }: { prop: CollectionProperty; value: unknown; onSave: (v: unknown) => Promise<void> }) {
|
||||
const [text, setText] = useState(() => asText(value))
|
||||
const [seen, setSeen] = useState(value)
|
||||
// Adjusting state when a prop changes: the React-documented render-time
|
||||
// pattern (guarded, converges immediately) instead of a cascading effect.
|
||||
if (value !== seen) {
|
||||
setSeen(value)
|
||||
setText(asText(value))
|
||||
}
|
||||
const [open, setOpen] = useState(false)
|
||||
const commitText = async () => {
|
||||
const next = text.trim()
|
||||
const current = asText(value)
|
||||
if (next === current) return
|
||||
if (next === '') { await onSave(null); return }
|
||||
await onSave(prop.type === 'NUMBER' ? Number(next) : next)
|
||||
}
|
||||
|
||||
switch (prop.type) {
|
||||
case 'CHECKBOX':
|
||||
return <Checkbox aria-label={prop.name} checked={value === true} onCheckedChange={(c) => { void onSave(c === true) }} />
|
||||
case 'SELECT': {
|
||||
const options = prop.config.options ?? []
|
||||
return (
|
||||
<Select
|
||||
value={typeof value === 'string' && value ? value : NONE}
|
||||
onValueChange={(id) => { void onSave(id === NONE ? null : id) }}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-[9.5rem]" aria-label={prop.name}><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={NONE}>—</SelectItem>
|
||||
{options.map((o) => (
|
||||
<SelectItem key={o.id} value={o.id}>
|
||||
<span className="flex items-center gap-1.5">
|
||||
{o.color && <span className="h-2 w-2 rounded-full" style={{ backgroundColor: o.color }} />}
|
||||
{o.label}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
}
|
||||
case 'MULTI_SELECT': {
|
||||
const options = prop.config.options ?? []
|
||||
const selected = new Set(Array.isArray(value) ? (value as string[]) : [])
|
||||
const toggle = async (id: string) => {
|
||||
const next = new Set(selected)
|
||||
if (next.has(id)) next.delete(id); else next.add(id)
|
||||
await onSave([...next])
|
||||
}
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button type="button" aria-label={`Edit ${prop.name}`} className="flex min-h-8 w-[9.5rem] flex-wrap items-center gap-1 rounded-md border border-input px-2 py-1 text-left hover:bg-muted">
|
||||
{selected.size === 0 && <span className="text-xs text-muted-foreground">—</span>}
|
||||
{options.filter((o) => selected.has(o.id)).map((o) => (
|
||||
<Badge key={o.id} variant="secondary" className="gap-1 text-xs" style={o.color ? { borderColor: o.color } : undefined}>{o.label}</Badge>
|
||||
))}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-52 space-y-2" align="start">
|
||||
{options.map((o) => (
|
||||
<label key={o.id} className="flex items-center gap-2 text-sm">
|
||||
<Checkbox checked={selected.has(o.id)} onCheckedChange={() => { void toggle(o.id) }} />
|
||||
{o.color && <span className="h-2 w-2 rounded-full" style={{ backgroundColor: o.color }} />}
|
||||
{o.label}
|
||||
</label>
|
||||
))}
|
||||
{options.length === 0 && <p className="text-xs text-muted-foreground">No options yet</p>}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
case 'DATE':
|
||||
return prop.config.mode === 'datetime'
|
||||
? <Input type="datetime-local" aria-label={prop.name} className="h-8 w-[13rem]" value={toLocalInput(value)} onChange={(e) => { const v = e.target.value; void onSave(v ? new Date(v).toISOString() : null) }} />
|
||||
: <Input type="date" aria-label={prop.name} className="h-8 w-[9.5rem]" value={typeof value === 'string' ? value : ''} onChange={(e) => { void onSave(e.target.value || null) }} />
|
||||
case 'NUMBER':
|
||||
case 'TEXT':
|
||||
case 'URL':
|
||||
default:
|
||||
return (
|
||||
<Input
|
||||
aria-label={prop.name}
|
||||
className="h-8 w-[9.5rem]"
|
||||
type={prop.type === 'NUMBER' ? 'number' : 'text'}
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onBlur={() => { void commitText() }}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur() }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export function CollectionsClient() {
|
||||
const [collections, setCollections] = useState<CollectionSummary[]>([])
|
||||
const [activeId, setActiveId] = useState<string | null>(null)
|
||||
const [detail, setDetail] = useState<CollectionDetail | null>(null)
|
||||
const [items, setItems] = useState<CollectionItem[]>([])
|
||||
const [cursor, setCursor] = useState<string | null>(null)
|
||||
const [draggedItemId, setDraggedItemId] = useState<string | null>(null)
|
||||
const [boardDropKey, setBoardDropKey] = useState<string | null>(null)
|
||||
const [viewId, setViewId] = useState<string | null>(null)
|
||||
const [newName, setNewName] = useState('')
|
||||
const [nameDraft, setNameDraft] = useState('')
|
||||
const [addOpen, setAddOpen] = useState(false)
|
||||
const [addQuery, setAddQuery] = useState('')
|
||||
const [addResults, setAddResults] = useState<Bookmark[]>([])
|
||||
const [propOpen, setPropOpen] = useState(false)
|
||||
const [propName, setPropName] = useState('')
|
||||
const [propType, setPropType] = useState<(typeof PROPERTY_TYPES)[number]>('SELECT')
|
||||
const [propOptions, setPropOptions] = useState<{ label: string; color: string }[]>([])
|
||||
const [propMode, setPropMode] = useState<'date' | 'datetime'>('date')
|
||||
const [viewOpen, setViewOpen] = useState(false)
|
||||
const [viewName, setViewName] = useState('')
|
||||
const [viewType, setViewType] = useState<'TABLE' | 'BOARD' | 'CALENDAR' | 'LIST'>('TABLE')
|
||||
const [filterOpen, setFilterOpen] = useState(false)
|
||||
const [fProp, setFProp] = useState('')
|
||||
const [fOp, setFOp] = useState('eq')
|
||||
const [fValue, setFValue] = useState('')
|
||||
|
||||
const loadCollections = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/collections')
|
||||
if (res.ok) setCollections(await res.json())
|
||||
} catch { toast.error('Could not load collections') }
|
||||
}, [])
|
||||
|
||||
const loadDetail = useCallback(async (id: string) => {
|
||||
try {
|
||||
const res = await fetch(`/api/collections/${id}`)
|
||||
if (!res.ok) { setDetail(null); return }
|
||||
const data: CollectionDetail = await res.json()
|
||||
setDetail(data)
|
||||
setNameDraft(data.name)
|
||||
setViewId((current) => (current && data.views.some((v) => v.id === current) ? current : data.views[0]?.id ?? null))
|
||||
} catch { toast.error('Could not load collection') }
|
||||
}, [])
|
||||
|
||||
const loadItems = useCallback(async (id: string, view: string | null, append?: string | null) => {
|
||||
try {
|
||||
const params = new URLSearchParams({ limit: '200' })
|
||||
if (view) params.set('viewId', view)
|
||||
if (append) params.set('cursor', append)
|
||||
const res = await fetch(`/api/collections/${id}/items?${params}`)
|
||||
if (!res.ok) return
|
||||
const rows: CollectionItem[] = await res.json()
|
||||
setItems((current) => (append ? [...current, ...rows] : rows))
|
||||
setCursor(res.headers.get('X-Next-Cursor'))
|
||||
} catch { toast.error('Could not load items') }
|
||||
}, [])
|
||||
|
||||
useEffect(() => { const t = setTimeout(loadCollections, 0); return () => clearTimeout(t) }, [loadCollections])
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => {
|
||||
if (!activeId && collections.length) setActiveId(collections[0].id)
|
||||
}, 0)
|
||||
return () => clearTimeout(t)
|
||||
}, [collections, activeId])
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => {
|
||||
if (!activeId) { setDetail(null); return }
|
||||
loadDetail(activeId)
|
||||
}, 0)
|
||||
return () => clearTimeout(t)
|
||||
}, [activeId, loadDetail])
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => {
|
||||
if (!activeId) { setItems([]); return }
|
||||
loadItems(activeId, viewId)
|
||||
}, 0)
|
||||
return () => clearTimeout(t)
|
||||
}, [activeId, viewId, loadItems])
|
||||
|
||||
// Versioned mutation helper: 409 means another session moved first — the
|
||||
// server state wins, we reload, and nothing is clobbered (DATA-05 lesson).
|
||||
const mutate = async (res: Response, reload: () => void, okMessage?: string): Promise<boolean> => {
|
||||
if (res.ok) { if (okMessage) toast.success(okMessage); return true }
|
||||
if (res.status === 409) {
|
||||
const body = await json(res)
|
||||
toast.error(body?.error || 'Changed elsewhere; reloaded the latest version')
|
||||
reload()
|
||||
return false
|
||||
}
|
||||
const body = await json(res)
|
||||
toast.error(body?.error || 'Request failed')
|
||||
return false
|
||||
}
|
||||
const patch = (url: string, body: Record<string, unknown>) =>
|
||||
fetch(url, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
|
||||
|
||||
const activeView = detail?.views.find((v) => v.id === viewId) ?? null
|
||||
const groupProp = activeView?.groupByPropertyId ? detail?.properties.find((p) => p.id === activeView.groupByPropertyId) : null
|
||||
|
||||
const createCollection = async () => {
|
||||
const name = newName.trim()
|
||||
if (!name) return
|
||||
const res = await fetch('/api/collections', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }) })
|
||||
if (!res.ok) { await mutate(res, loadCollections); return }
|
||||
const created = await res.json().catch(() => null)
|
||||
toast.success('Collection created')
|
||||
setNewName('')
|
||||
await loadCollections()
|
||||
if (created?.id) setActiveId(created.id)
|
||||
}
|
||||
|
||||
const saveName = async () => {
|
||||
if (!detail) return
|
||||
const name = nameDraft.trim()
|
||||
if (!name || name === detail.name) { setNameDraft(detail.name); return }
|
||||
const res = await patch(`/api/collections/${detail.id}`, { version: detail.version, name })
|
||||
if (await mutate(res, () => loadDetail(detail.id), 'Renamed')) {
|
||||
setDetail((d) => (d ? { ...d, name, version: d.version + 1 } : d))
|
||||
loadCollections()
|
||||
} else setNameDraft(detail.name)
|
||||
}
|
||||
|
||||
const deleteCollection = async () => {
|
||||
if (!detail || !confirm(`Delete collection "${detail.name}"? Bookmarks are kept.`)) return
|
||||
const res = await fetch(`/api/collections/${detail.id}`, { method: 'DELETE' })
|
||||
if (await mutate(res, loadCollections, 'Collection deleted')) {
|
||||
setActiveId(null)
|
||||
setDetail(null)
|
||||
loadCollections()
|
||||
}
|
||||
}
|
||||
|
||||
const createProperty = async () => {
|
||||
if (!detail) return
|
||||
const name = propName.trim()
|
||||
if (!name) return
|
||||
const config: Record<string, unknown> = {}
|
||||
if (propType === 'SELECT' || propType === 'MULTI_SELECT') {
|
||||
config.options = propOptions.filter((o) => o.label.trim()).map((o) => ({ label: o.label.trim(), ...(o.color ? { color: o.color } : {}) }))
|
||||
}
|
||||
if (propType === 'DATE') config.mode = propMode
|
||||
const res = await fetch(`/api/collections/${detail.id}/properties`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, type: propType, config }),
|
||||
})
|
||||
if (await mutate(res, () => loadDetail(detail.id), 'Property added')) {
|
||||
setPropOpen(false); setPropName(''); setPropOptions([])
|
||||
loadDetail(detail.id)
|
||||
}
|
||||
}
|
||||
|
||||
const deleteProperty = async (prop: CollectionProperty) => {
|
||||
if (!detail || !confirm(`Delete property "${prop.name}"? Its values are removed from every item.`)) return
|
||||
const res = await fetch(`/api/collections/${detail.id}/properties/${prop.id}`, { method: 'DELETE' })
|
||||
if (await mutate(res, () => loadDetail(detail.id), 'Property deleted')) {
|
||||
loadDetail(detail.id)
|
||||
loadItems(detail.id, viewId)
|
||||
}
|
||||
}
|
||||
|
||||
const createView = async () => {
|
||||
if (!detail) return
|
||||
const name = viewName.trim()
|
||||
if (!name) return
|
||||
const res = await fetch(`/api/collections/${detail.id}/views`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, type: viewType }),
|
||||
})
|
||||
if (!res.ok) { await mutate(res, () => loadDetail(detail.id)); return }
|
||||
const created = await res.json().catch(() => null)
|
||||
toast.success('View added')
|
||||
setViewOpen(false); setViewName('')
|
||||
await loadDetail(detail.id)
|
||||
if (created?.id) setViewId(created.id)
|
||||
}
|
||||
|
||||
const deleteView = async () => {
|
||||
if (!detail || !activeView || !confirm(`Delete view "${activeView.name}"?`)) return
|
||||
const res = await fetch(`/api/collections/${detail.id}/views/${activeView.id}`, { method: 'DELETE' })
|
||||
if (await mutate(res, () => loadDetail(detail.id), 'View deleted')) {
|
||||
setViewId(null)
|
||||
loadDetail(detail.id)
|
||||
}
|
||||
}
|
||||
|
||||
const patchView = async (changes: Record<string, unknown>) => {
|
||||
if (!detail || !activeView) return
|
||||
const res = await patch(`/api/collections/${detail.id}/views/${activeView.id}`, { version: activeView.version, ...changes })
|
||||
if (res.ok) {
|
||||
const updated: CollectionView = await res.json()
|
||||
setDetail((d) => (d ? { ...d, views: d.views.map((v) => (v.id === updated.id ? updated : v)) } : d))
|
||||
loadItems(detail.id, updated.id)
|
||||
} else await mutate(res, () => loadDetail(detail.id))
|
||||
}
|
||||
|
||||
const addItem = async (bookmarkId: string) => {
|
||||
if (!detail) return
|
||||
const res = await fetch(`/api/collections/${detail.id}/items`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ bookmarkId }),
|
||||
})
|
||||
if (res.ok) {
|
||||
loadItems(detail.id, viewId)
|
||||
loadCollections()
|
||||
} else if (res.status === 409) toast.error('Already in this collection')
|
||||
else { const body = await json(res); toast.error(body?.error || 'Could not add bookmark') }
|
||||
}
|
||||
|
||||
const removeItem = async (item: CollectionItem) => {
|
||||
if (!detail) return
|
||||
const res = await fetch(`/api/collections/${detail.id}/items/${item.id}`, { method: 'DELETE' })
|
||||
if (await mutate(res, () => loadItems(detail.id, viewId))) {
|
||||
setItems((current) => current.filter((i) => i.id !== item.id))
|
||||
loadCollections()
|
||||
}
|
||||
}
|
||||
|
||||
const saveValue = async (item: CollectionItem, propId: string, value: unknown) => {
|
||||
if (!detail) return
|
||||
const res = await patch(`/api/collections/${detail.id}/items/${item.id}`, {
|
||||
version: item.version, propertyValues: { [propId]: value },
|
||||
})
|
||||
if (res.ok) {
|
||||
const updated: CollectionItem = await res.json()
|
||||
setItems((current) => current.map((i) => (i.id === updated.id ? updated : i)))
|
||||
} else await mutate(res, () => loadItems(detail.id, viewId))
|
||||
}
|
||||
|
||||
// Board drag-and-drop and the per-card select both go through the same
|
||||
// versioned membership PATCH. Update optimistically, then reconcile with the
|
||||
// server; any failure reloads so a rejected value never sticks locally.
|
||||
const moveItemToGroup = async (item: CollectionItem, next: string | null) => {
|
||||
if (!detail || !groupProp) return
|
||||
const current = item.propertyValues?.[groupProp.id]
|
||||
const normalized = current === undefined || current === '' ? null : current
|
||||
if (normalized === next) return
|
||||
setItems((current) => current.map((i) => i.id === item.id ? {
|
||||
...i, propertyValues: { ...i.propertyValues, [groupProp.id]: next },
|
||||
} : i))
|
||||
const res = await patch(`/api/collections/${detail.id}/items/${item.id}`, {
|
||||
version: item.version, propertyValues: { [groupProp.id]: next },
|
||||
})
|
||||
if (res.ok) {
|
||||
const updated: CollectionItem = await res.json()
|
||||
setItems((current) => current.map((i) => (i.id === updated.id ? updated : i)))
|
||||
return
|
||||
}
|
||||
const handled = await mutate(res, () => loadItems(detail.id, viewId))
|
||||
if (!handled && res.status !== 409) await loadItems(detail.id, viewId)
|
||||
}
|
||||
|
||||
// Debounced bookmark search for the add dialog.
|
||||
useEffect(() => {
|
||||
if (!addOpen) return
|
||||
const q = addQuery.trim()
|
||||
const t = setTimeout(async () => {
|
||||
try {
|
||||
const params = new URLSearchParams({ limit: '8' })
|
||||
if (q) params.set('search', q)
|
||||
const res = await fetch(`/api/bookmarks?${params}`)
|
||||
if (res.ok) setAddResults((await res.json()).slice(0, 8))
|
||||
} catch { /* transient; retried on next keystroke */ }
|
||||
}, 250)
|
||||
return () => clearTimeout(t)
|
||||
}, [addQuery, addOpen])
|
||||
|
||||
const filterClauses = activeView?.filters?.clauses ?? []
|
||||
const setClause = (clauses: FilterClause[]) => { void patchView({ filters: { operator: activeView?.filters?.operator ?? 'AND', clauses } }) }
|
||||
const addClause = () => {
|
||||
if (!fProp || !fOp) return
|
||||
const prop = detail?.properties.find((p) => p.id === fProp)
|
||||
if (!prop) return
|
||||
let value: unknown
|
||||
if (!['empty', 'notEmpty'].includes(fOp)) {
|
||||
if (prop.type === 'NUMBER') value = Number(fValue)
|
||||
else if (prop.type === 'CHECKBOX') value = fValue === 'true'
|
||||
else value = fValue
|
||||
if (fValue === '' || (prop.type === 'NUMBER' && Number.isNaN(value))) { toast.error('Filter value required'); return }
|
||||
}
|
||||
setClause([...filterClauses, value === undefined ? { propertyId: fProp, op: fOp } : { propertyId: fProp, op: fOp, value }])
|
||||
setFilterOpen(false); setFValue('')
|
||||
}
|
||||
const clauseLabel = (c: FilterClause) => {
|
||||
const prop = detail?.properties.find((p) => p.id === c.propertyId)
|
||||
if (!prop) return 'unknown'
|
||||
let shown = c.value
|
||||
if (prop.type === 'SELECT' && typeof c.value === 'string') shown = prop.config.options?.find((o) => o.id === c.value)?.label ?? c.value
|
||||
if (prop.type === 'CHECKBOX') shown = c.value === true ? 'checked' : 'unchecked'
|
||||
return `${prop.name} ${OP_LABELS[c.op] ?? c.op}${shown !== undefined ? ` ${shown}` : ''}`
|
||||
}
|
||||
|
||||
const renderTable = () => (
|
||||
<div className="overflow-x-auto rounded-lg border border-border/60">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="min-w-[16rem]">Bookmark</TableHead>
|
||||
{(detail?.properties ?? []).map((p) => (
|
||||
<TableHead key={p.id} className="min-w-[11rem]">
|
||||
<span className="flex items-center gap-1.5">
|
||||
{p.name}
|
||||
<button type="button" aria-label={`Delete property ${p.name}`} className="text-muted-foreground opacity-0 transition-opacity hover:text-destructive group-hover:opacity-100" onClick={() => deleteProperty(p)}>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
</TableHead>
|
||||
))}
|
||||
<TableHead className="w-10" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((item) => (
|
||||
<TableRow key={item.id} className="group">
|
||||
<TableCell><BookmarkCell bookmark={item.bookmark} /></TableCell>
|
||||
{(detail?.properties ?? []).map((p) => (
|
||||
<TableCell key={p.id}><ValueEditor prop={p} value={item.propertyValues?.[p.id]} onSave={(v) => saveValue(item, p.id, v)} /></TableCell>
|
||||
))}
|
||||
<TableCell>
|
||||
<button type="button" aria-label="Remove from collection" className="text-muted-foreground hover:text-destructive" onClick={() => removeItem(item)}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{items.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={(detail?.properties.length ?? 0) + 2} className="py-10 text-center text-sm text-muted-foreground">
|
||||
No items{filterClauses.length ? " match this view's filters" : ''}. Add existing bookmarks — nothing is duplicated.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)
|
||||
|
||||
const renderBoard = () => {
|
||||
if (!groupProp || groupProp.type !== 'SELECT') {
|
||||
return (
|
||||
<div className="rounded-lg border border-dashed border-border p-8 text-center">
|
||||
<Kanban className="mx-auto mb-2 h-8 w-8 text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground">Board views group by a select property.</p>
|
||||
<div className="mx-auto mt-3 w-56">
|
||||
<Select
|
||||
value={NONE}
|
||||
onValueChange={(id) => { if (id !== NONE) patchView({ groupByPropertyId: id }) }}
|
||||
>
|
||||
<SelectTrigger aria-label="Group by property"><SelectValue placeholder="Choose a select property…" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{(detail?.properties ?? []).filter((p) => p.type === 'SELECT').map((p) => (
|
||||
<SelectItem key={p.id} value={p.id}>{p.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const options = groupProp.config.options ?? []
|
||||
const columns = [...options.map((o) => ({ key: o.id, label: o.label, color: o.color })), { key: NONE, label: 'No value', color: undefined }]
|
||||
return (
|
||||
<div className="flex gap-3 overflow-x-auto pb-2">
|
||||
{columns.map((col) => {
|
||||
const colItems = items.filter((i) => {
|
||||
const v = i.propertyValues?.[groupProp.id]
|
||||
return col.key === NONE ? (v === undefined || v === null) : v === col.key
|
||||
})
|
||||
const isDropTarget = !!draggedItemId && boardDropKey === col.key
|
||||
return (
|
||||
<div
|
||||
key={col.key}
|
||||
data-board-group={col.key}
|
||||
onDragOver={(e) => {
|
||||
if (!draggedItemId) return
|
||||
e.preventDefault()
|
||||
e.dataTransfer.dropEffect = 'move'
|
||||
setBoardDropKey(col.key)
|
||||
}}
|
||||
onDragLeave={(e) => {
|
||||
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setBoardDropKey(null)
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault()
|
||||
const item = items.find((i) => i.id === draggedItemId)
|
||||
if (item) void moveItemToGroup(item, col.key === NONE ? null : col.key)
|
||||
setDraggedItemId(null)
|
||||
setBoardDropKey(null)
|
||||
}}
|
||||
className={cn(
|
||||
'w-64 shrink-0 rounded-lg border border-border/60 bg-muted/30 p-2 transition-[box-shadow,background-color,border-color] duration-150',
|
||||
isDropTarget && 'border-primary/40 bg-primary/[0.04] ring-2 ring-primary/40',
|
||||
)}
|
||||
>
|
||||
<div className="mb-2 flex items-center gap-1.5 px-1 text-sm font-medium">
|
||||
{col.color && <span className="h-2 w-2 rounded-full" style={{ backgroundColor: col.color }} />}
|
||||
{col.label}
|
||||
<span className="ml-auto text-xs text-muted-foreground">{colItems.length}</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{colItems.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
data-collection-item-id={item.id}
|
||||
className={cn(
|
||||
'rounded-md border border-border bg-card p-2 shadow-sm transition-[transform,box-shadow,opacity] duration-150 group',
|
||||
draggedItemId === item.id && 'opacity-40',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-1">
|
||||
<span
|
||||
data-drag-handle
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
setDraggedItemId(item.id)
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
e.dataTransfer.setData('text/plain', item.id)
|
||||
}}
|
||||
onDragEnd={() => {
|
||||
setDraggedItemId(null)
|
||||
setBoardDropKey(null)
|
||||
}}
|
||||
className="mt-0.5 cursor-grab text-muted-foreground/50 transition-colors hover:text-muted-foreground active:cursor-grabbing"
|
||||
title="Drag to another group"
|
||||
aria-label={`Drag ${item.bookmark.title ?? 'bookmark'}`}
|
||||
>
|
||||
<GripVertical className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<BookmarkCell bookmark={item.bookmark} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-1.5 flex items-center justify-between gap-1">
|
||||
<Select
|
||||
value={typeof item.propertyValues?.[groupProp.id] === 'string' ? (item.propertyValues[groupProp.id] as string) : NONE}
|
||||
onValueChange={(id) => { void moveItemToGroup(item, id === NONE ? null : id) }}
|
||||
>
|
||||
<SelectTrigger className="h-7 w-28 text-xs" aria-label={`Move ${item.bookmark.title ?? 'card'}`}><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={NONE}>—</SelectItem>
|
||||
{options.map((o) => <SelectItem key={o.id} value={o.id}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<button type="button" aria-label="Remove from collection" className="text-muted-foreground hover:text-destructive" onClick={() => removeItem(item)}>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{colItems.length === 0 && (
|
||||
<p className={cn('px-1 py-3 text-center text-xs text-muted-foreground', isDropTarget && 'text-primary')}>
|
||||
{isDropTarget ? 'Drop here' : 'Empty'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* Collection list */}
|
||||
<div className="flex w-60 shrink-0 flex-col border-r border-border/60 p-3">
|
||||
<h1 className="mb-3 flex items-center gap-2 font-display text-lg font-semibold tracking-tight">
|
||||
<Layers className="h-4 w-4" /> Collections
|
||||
</h1>
|
||||
<div className="mb-3 flex gap-1.5">
|
||||
<Input value={newName} onChange={(e) => setNewName(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') createCollection() }} placeholder="New collection" className="h-8 text-sm" aria-label="New collection name" />
|
||||
<Button size="sm" variant="outline" className="h-8 shrink-0 px-2" onClick={createCollection} aria-label="Create collection"><Plus className="h-4 w-4" /></Button>
|
||||
</div>
|
||||
<div className="flex-1 space-y-1 overflow-y-auto">
|
||||
{collections.map((c) => (
|
||||
<button
|
||||
key={c.id}
|
||||
onClick={() => { setActiveId(c.id); setViewId(null) }}
|
||||
className={cn('flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm transition-colors', c.id === activeId ? 'bg-primary/10 font-medium text-primary' : 'text-muted-foreground hover:bg-muted hover:text-foreground')}
|
||||
>
|
||||
<Layers className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="min-w-0 flex-1 truncate">{c.name}</span>
|
||||
<span className="shrink-0 text-xs text-muted-foreground">{c._count.items}</span>
|
||||
</button>
|
||||
))}
|
||||
{collections.length === 0 && (
|
||||
<p className="px-2 py-6 text-center text-xs text-muted-foreground">
|
||||
Collections hold workflow state per project. The same bookmark can live in many — nothing is duplicated.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Detail */}
|
||||
<div className="flex-1 overflow-y-auto px-6 py-5">
|
||||
{!detail ? (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-3 text-center">
|
||||
<Layers className="h-10 w-10 text-muted-foreground" />
|
||||
<h2 className="font-display text-xl font-semibold">{collections.length ? 'Select a collection' : 'No collections yet'}</h2>
|
||||
<p className="max-w-sm text-sm text-muted-foreground">
|
||||
A collection is a saved workflow over existing bookmarks: per-collection status, priority, dates — independent everywhere the link appears.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="mb-4 flex flex-wrap items-center gap-2">
|
||||
<Input value={nameDraft} onChange={(e) => setNameDraft(e.target.value)} onBlur={saveName} onKeyDown={(e) => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur() }} aria-label="Collection name" className="w-72 bg-transparent font-display text-2xl font-bold tracking-tight" />
|
||||
<span className="text-xs text-muted-foreground">{detail._count?.items ?? 0} item{(detail._count?.items ?? 0) === 1 ? '' : 's'}</span>
|
||||
<div className="ml-auto flex items-center gap-1.5">
|
||||
<Button size="sm" variant="outline" onClick={() => setAddOpen(true)}><Plus className="mr-1 h-4 w-4" />Add bookmarks</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => setPropOpen(true)}><Plus className="mr-1 h-4 w-4" />Property</Button>
|
||||
<Button size="sm" variant="ghost" onClick={deleteCollection} aria-label="Delete collection" title="Delete collection"><Trash2 className="h-4 w-4 text-destructive" /></Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* View tabs */}
|
||||
<div className="mb-3 flex flex-wrap items-center gap-1.5">
|
||||
{detail.views.map((v) => {
|
||||
const Icon = VIEW_ICONS[v.type] ?? Table2
|
||||
return (
|
||||
<button
|
||||
key={v.id}
|
||||
onClick={() => setViewId(v.id)}
|
||||
className={cn('flex items-center gap-1.5 rounded-md px-2.5 py-1 text-sm transition-colors', v.id === viewId ? 'bg-primary/10 font-medium text-primary' : 'text-muted-foreground hover:bg-muted hover:text-foreground')}
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5" /> {v.name}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
<Button size="sm" variant="ghost" className="h-7 px-2" onClick={() => setViewOpen(true)} aria-label="Add view"><Plus className="h-4 w-4" /></Button>
|
||||
{activeView && (
|
||||
<div className="ml-auto flex items-center gap-1.5">
|
||||
<Popover open={filterOpen} onOpenChange={(o) => { setFilterOpen(o); if (o) { setFProp(detail.properties[0]?.id ?? ''); setFOp(FILTER_OPS[detail.properties[0]?.type ?? 'TEXT']?.[0] ?? 'eq') } }}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button size="sm" variant="ghost" className="h-7 gap-1 px-2 text-muted-foreground"><Filter className="h-3.5 w-3.5" />Filter{filterClauses.length > 0 && ` (${filterClauses.length})`}</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-80 space-y-2" align="end">
|
||||
<Label className="text-xs uppercase tracking-wider text-muted-foreground">Add a filter clause</Label>
|
||||
<Select value={fProp || undefined} onValueChange={(id) => { setFProp(id); const p = detail.properties.find((x) => x.id === id); setFOp(FILTER_OPS[p?.type ?? 'TEXT']?.[0] ?? 'eq'); setFValue('') }}>
|
||||
<SelectTrigger aria-label="Filter property"><SelectValue placeholder="Property" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{detail.properties.filter((p) => FILTER_OPS[p.type]).map((p) => <SelectItem key={p.id} value={p.id}>{p.name}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={fOp} onValueChange={setFOp}>
|
||||
<SelectTrigger aria-label="Filter operator"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{(FILTER_OPS[detail.properties.find((p) => p.id === fProp)?.type ?? 'TEXT'] ?? []).map((op) => <SelectItem key={op} value={op}>{OP_LABELS[op] ?? op}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{!['empty', 'notEmpty'].includes(fOp) && (() => {
|
||||
const p = detail.properties.find((x) => x.id === fProp)
|
||||
if (p?.type === 'SELECT') return (
|
||||
<Select value={fValue || undefined} onValueChange={setFValue}>
|
||||
<SelectTrigger aria-label="Filter value"><SelectValue placeholder="Value" /></SelectTrigger>
|
||||
<SelectContent>{(p.config.options ?? []).map((o) => <SelectItem key={o.id} value={o.id}>{o.label}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
if (p?.type === 'CHECKBOX') return (
|
||||
<Select value={fValue || undefined} onValueChange={setFValue}>
|
||||
<SelectTrigger aria-label="Filter value"><SelectValue placeholder="Value" /></SelectTrigger>
|
||||
<SelectContent><SelectItem value="true">checked</SelectItem><SelectItem value="false">unchecked</SelectItem></SelectContent>
|
||||
</Select>
|
||||
)
|
||||
return <Input value={fValue} onChange={(e) => setFValue(e.target.value)} placeholder="Value" aria-label="Filter value" className="h-8 text-sm" />
|
||||
})()}
|
||||
<Button size="sm" className="w-full" onClick={addClause}>Apply filter</Button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<Button size="sm" variant="ghost" className="h-7 px-2 text-muted-foreground" onClick={deleteView} aria-label="Delete view" title="Delete view"><Trash2 className="h-3.5 w-3.5" /></Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Active filter chips */}
|
||||
{filterClauses.length > 0 && (
|
||||
<div className="mb-3 flex flex-wrap items-center gap-1.5">
|
||||
<span className="text-xs text-muted-foreground">{activeView?.filters?.operator ?? 'AND'}:</span>
|
||||
{filterClauses.map((c, i) => (
|
||||
<Badge key={i} variant="secondary" className="gap-1">
|
||||
{clauseLabel(c)}
|
||||
<button type="button" aria-label="Remove filter" onClick={() => setClause(filterClauses.filter((_, j) => j !== i))}><X className="h-3 w-3" /></button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeView?.type === 'BOARD' ? renderBoard() : renderTable()}
|
||||
{activeView && (activeView.type === 'CALENDAR' || activeView.type === 'LIST') && (
|
||||
<p className="mt-2 text-xs text-muted-foreground">Calendar and list layouts render as a table in this phase.</p>
|
||||
)}
|
||||
{cursor && (
|
||||
<Button size="sm" variant="outline" className="mt-3" onClick={() => loadItems(detail.id, viewId, cursor)}>Load more</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Add bookmarks dialog */}
|
||||
<Dialog open={addOpen} onOpenChange={(o) => { setAddOpen(o); if (!o) setAddQuery('') }}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader><DialogTitle>Add existing bookmarks</DialogTitle></DialogHeader>
|
||||
<p className="text-xs text-muted-foreground">Membership is contextual — adding here never duplicates or re-owns the bookmark.</p>
|
||||
<Input autoFocus value={addQuery} onChange={(e) => setAddQuery(e.target.value)} placeholder="Search bookmarks…" aria-label="Search bookmarks" />
|
||||
<div className="max-h-72 space-y-1 overflow-y-auto">
|
||||
{addResults.map((b) => {
|
||||
const member = items.some((i) => i.bookmarkId === b.id)
|
||||
return (
|
||||
<div key={b.id} className="flex items-center gap-2 rounded-md border border-border/60 px-2 py-1.5">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm">{b.title || b.url}</p>
|
||||
<p className="truncate text-xs text-muted-foreground">{b.url}</p>
|
||||
</div>
|
||||
<Button size="sm" variant={member ? 'ghost' : 'outline'} disabled={member} onClick={() => addItem(b.id)}>
|
||||
{member ? 'Added' : 'Add'}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{addResults.length === 0 && <p className="py-6 text-center text-sm text-muted-foreground">No bookmarks found</p>}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* New property dialog */}
|
||||
<Dialog open={propOpen} onOpenChange={setPropOpen}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader><DialogTitle>New property</DialogTitle></DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<Input value={propName} onChange={(e) => setPropName(e.target.value)} placeholder="Name (e.g. Status)" aria-label="Property name" />
|
||||
<Select value={propType} onValueChange={(t) => setPropType(t as (typeof PROPERTY_TYPES)[number])}>
|
||||
<SelectTrigger aria-label="Property type"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{PROPERTY_TYPES.map((t) => <SelectItem key={t} value={t}>{t.replace('_', ' ').toLowerCase().replace(/\b\w/g, (m) => m.toUpperCase())}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">Type is permanent — conversions that could destroy values are refused by design.</p>
|
||||
{(propType === 'SELECT' || propType === 'MULTI_SELECT') && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs uppercase tracking-wider text-muted-foreground">Options</Label>
|
||||
{propOptions.map((o, i) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<Input type="color" aria-label="Option color" value={o.color || '#6366f1'} onChange={(e) => setPropOptions(propOptions.map((x, j) => (j === i ? { ...x, color: e.target.value } : x)))} className="h-8 w-10 shrink-0 p-1" />
|
||||
<Input value={o.label} onChange={(e) => setPropOptions(propOptions.map((x, j) => (j === i ? { ...x, label: e.target.value } : x)))} placeholder="Option label" aria-label="Option label" className="h-8 text-sm" />
|
||||
<Button size="sm" variant="ghost" className="h-8 px-2" aria-label="Remove option" onClick={() => setPropOptions(propOptions.filter((_, j) => j !== i))}><X className="h-4 w-4" /></Button>
|
||||
</div>
|
||||
))}
|
||||
<Button size="sm" variant="outline" onClick={() => setPropOptions([...propOptions, { label: '', color: '#6366f1' }])}><Plus className="mr-1 h-4 w-4" />Option</Button>
|
||||
</div>
|
||||
)}
|
||||
{propType === 'DATE' && (
|
||||
<Select value={propMode} onValueChange={(m) => setPropMode(m as 'date' | 'datetime')}>
|
||||
<SelectTrigger aria-label="Date mode"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="date">Date only (never shifts across timezones)</SelectItem>
|
||||
<SelectItem value="datetime">Date & time (stored UTC)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
<Button className="w-full" onClick={createProperty}>Create property</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* New view dialog */}
|
||||
<Dialog open={viewOpen} onOpenChange={setViewOpen}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader><DialogTitle>New view</DialogTitle></DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<Input autoFocus value={viewName} onChange={(e) => setViewName(e.target.value)} placeholder="View name" aria-label="View name" />
|
||||
<Select value={viewType} onValueChange={(t) => setViewType(t as typeof viewType)}>
|
||||
<SelectTrigger aria-label="View type"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="TABLE">Table</SelectItem>
|
||||
<SelectItem value="BOARD">Board</SelectItem>
|
||||
<SelectItem value="CALENDAR">Calendar (renders as table for now)</SelectItem>
|
||||
<SelectItem value="LIST">List (renders as table for now)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">A view is a saved query — filters and grouping never copy records.</p>
|
||||
<Button className="w-full" onClick={createView}>Create view</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { CollectionsClient } from './client'
|
||||
|
||||
export default function CollectionsPage() {
|
||||
return <CollectionsClient />
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { BookmarkCard } from '@/components/bookmark-card'
|
||||
import { BookmarkDetailSheet } from '@/components/bookmark-detail-sheet'
|
||||
import { Users, RefreshCw, Mail, Phone } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import type { Bookmark } from '@/components/app-shell'
|
||||
import { fetchBookmarks } from '@/lib/bookmarks'
|
||||
|
||||
interface Contact {
|
||||
id: string
|
||||
uid: string
|
||||
displayName: string
|
||||
email: string | null
|
||||
phone: string | null
|
||||
_count?: { bookmarks: number }
|
||||
}
|
||||
|
||||
export function ContactsClient() {
|
||||
const [contacts, setContacts] = useState<Contact[]>([])
|
||||
const [selectedContact, setSelectedContact] = useState<string | null>(null)
|
||||
const [bookmarks, setBookmarks] = useState<Bookmark[]>([])
|
||||
const [selectedBookmark, setSelectedBookmark] = useState<Bookmark | null>(null)
|
||||
const [syncing, setSyncing] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const loadContacts = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetch('/api/contacts')
|
||||
if (res.ok) setContacts(await res.json())
|
||||
} catch { toast.error('Could not load contacts') }
|
||||
finally { setLoading(false) }
|
||||
}, [])
|
||||
|
||||
const loadBookmarks = useCallback(async (contactId: string) => {
|
||||
try { setBookmarks(await fetchBookmarks(new URLSearchParams({ contactId }))) }
|
||||
catch { toast.error('Could not load bookmarks') }
|
||||
}, [])
|
||||
|
||||
useEffect(() => { const timer = setTimeout(loadContacts, 0); return () => clearTimeout(timer) }, [loadContacts])
|
||||
useEffect(() => { const timer = setTimeout(() => { if (selectedContact) loadBookmarks(selectedContact) }, 0); return () => clearTimeout(timer) }, [selectedContact, loadBookmarks])
|
||||
useEffect(() => { const reload = () => { loadContacts(); if (selectedContact) loadBookmarks(selectedContact) }; window.addEventListener('computer:bookmarks-changed', reload); return () => window.removeEventListener('computer:bookmarks-changed', reload) }, [loadContacts, loadBookmarks, selectedContact])
|
||||
|
||||
const syncContacts = async () => {
|
||||
setSyncing(true)
|
||||
toast.info('Configure your Nextcloud settings in Settings → Nextcloud to sync contacts')
|
||||
setSyncing(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex gap-6 h-[calc(100vh-120px)]">
|
||||
<div className="w-72 shrink-0 space-y-4 overflow-y-auto">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="font-display text-2xl font-bold tracking-tight">Contacts</h1>
|
||||
<Button variant="outline" size="sm" onClick={syncContacts} loading={syncing} className="gap-1.5">
|
||||
<RefreshCw className="h-3 w-3" /> Sync
|
||||
</Button>
|
||||
</div>
|
||||
{loading ? (
|
||||
<div className="space-y-2">{Array.from({ length: 5 }).map((_, i) => <div key={i} className="h-14 rounded-lg bg-muted animate-pulse" />)}</div>
|
||||
) : contacts.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<Users className="h-10 w-10 text-muted-foreground/50 mx-auto mb-3" />
|
||||
<p className="text-sm text-muted-foreground">No contacts yet</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">Sync from Nextcloud in Settings</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{contacts.map((c: Contact) => (
|
||||
<div
|
||||
key={c.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-pressed={selectedContact === c.id}
|
||||
onClick={() => setSelectedContact(c.id)}
|
||||
onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setSelectedContact(c.id) } }}
|
||||
className={`flex items-center gap-3 rounded-lg px-3 py-2.5 cursor-pointer transition-colors focus-visible:outline-2 focus-visible:outline-primary ${
|
||||
selectedContact === c.id ? 'bg-accent' : 'hover:bg-muted'
|
||||
}`}
|
||||
>
|
||||
<div className="h-9 w-9 rounded-full bg-primary/20 flex items-center justify-center text-sm font-bold text-primary shrink-0">
|
||||
{(c.displayName ?? '?')[0]?.toUpperCase()}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium truncate">{c.displayName}</p>
|
||||
{c.email && <p className="text-xs text-muted-foreground truncate flex items-center gap-1"><Mail className="h-3 w-3" />{c.email}</p>}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">{c._count?.bookmarks ?? 0}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{selectedContact ? (
|
||||
<div className="space-y-4">
|
||||
<h2 className="font-medium">Bookmarks for {contacts.find(c => c.id === selectedContact)?.displayName}</h2>
|
||||
{bookmarks.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-8 text-center">No bookmarks linked to this contact</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{bookmarks.map((b: Bookmark) => <BookmarkCard key={b.id} bookmark={b} compact onClick={() => setSelectedBookmark(b)} />)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center h-full text-center">
|
||||
<Users className="h-12 w-12 text-muted-foreground/50 mb-4" />
|
||||
<p className="text-muted-foreground">Select a contact to view linked bookmarks</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<BookmarkDetailSheet bookmark={selectedBookmark} onClose={() => setSelectedBookmark(null)} onUpdate={() => { if (selectedContact) loadBookmarks(selectedContact) }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { ContactsClient } from './client'
|
||||
|
||||
export default function ContactsPage() {
|
||||
return <ContactsClient />
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
// The dashboard was folded into the "All Bookmarks" filter view.
|
||||
export default async function DashboardPage({ searchParams }: { searchParams: Promise<{ search?: string }> }) {
|
||||
const { search } = await searchParams
|
||||
redirect(search ? `/bookmarks?search=${encodeURIComponent(search)}` : '/bookmarks')
|
||||
}
|
||||
@@ -0,0 +1,444 @@
|
||||
'use client'
|
||||
|
||||
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useSession } from 'next-auth/react'
|
||||
import { BookmarkDetailSheet } from '@/components/bookmark-detail-sheet'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
||||
import { ExternalLink, Calendar, GripVertical, Plus, MoreHorizontal, Pencil, Trash2, ArrowLeft, ArrowRight, Check, X } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import type { Bookmark } from '@/components/app-shell'
|
||||
import { fetchBookmarks } from '@/lib/bookmarks'
|
||||
import { invalidateReference } from '@/lib/reference-data'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type Column = { id: string; label: string; color: string; position: number; key: string | null }
|
||||
type CardDrop = { columnId: string; index: number }
|
||||
const PALETTE = ['#64748b', '#3b82f6', '#f59e0b', '#22c55e', '#8b5cf6', '#ec4899', '#ef4444', '#14b8a6']
|
||||
|
||||
function columnFor(b: Bookmark, cols: Column[]) {
|
||||
const explicit = (b as any).kanbanColumnId as string | null
|
||||
if (explicit && cols.some(c => c.id === explicit)) return explicit
|
||||
return cols.find(c => c.key === b.kanbanStatus)?.id ?? cols[0]?.id
|
||||
}
|
||||
|
||||
function sortCards(a: Bookmark, b: Bookmark, currentUserId?: string) {
|
||||
// Owned cards keep their persisted board order. Shared cards are read-only
|
||||
// and sort after owned ones so they cannot disturb the owner's positions.
|
||||
if (currentUserId) {
|
||||
const aOwned = a.ownerId === currentUserId
|
||||
const bOwned = b.ownerId === currentUserId
|
||||
if (aOwned !== bOwned) return aOwned ? -1 : 1
|
||||
}
|
||||
const ap = a.kanbanPosition ?? 0
|
||||
const bp = b.kanbanPosition ?? 0
|
||||
if (ap !== bp) return ap - bp
|
||||
return b.updatedAt.localeCompare(a.updatedAt) || b.id.localeCompare(a.id)
|
||||
}
|
||||
|
||||
export function KanbanClient() {
|
||||
const { data: session } = useSession()
|
||||
const currentUserId = session?.user?.id
|
||||
const [bookmarks, setBookmarks] = useState<Bookmark[]>([])
|
||||
const [columns, setColumns] = useState<Column[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [selectedBookmark, setSelectedBookmark] = useState<Bookmark | null>(null)
|
||||
const [draggedId, setDraggedId] = useState<string | null>(null)
|
||||
const [draggedColumnId, setDraggedColumnId] = useState<string | null>(null)
|
||||
const [cardDrop, setCardDrop] = useState<CardDrop | null>(null)
|
||||
const [columnDropId, setColumnDropId] = useState<string | null>(null)
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
const [editLabel, setEditLabel] = useState('')
|
||||
const [adding, setAdding] = useState(false)
|
||||
const [newLabel, setNewLabel] = useState('')
|
||||
const suppressClick = useRef(false)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [bookmarks, cRes] = await Promise.all([fetchBookmarks(), fetch('/api/kanban/columns')])
|
||||
setBookmarks(bookmarks)
|
||||
if (cRes.ok) setColumns(await cRes.json())
|
||||
} catch (e) { console.error('Kanban load failed', e) }
|
||||
setLoading(false)
|
||||
}, [])
|
||||
|
||||
useEffect(() => { const timer = setTimeout(load, 0); window.addEventListener('computer:bookmarks-changed', load); return () => { clearTimeout(timer); window.removeEventListener('computer:bookmarks-changed', load) } }, [load])
|
||||
|
||||
const isOwned = useCallback((b: Bookmark) => !currentUserId || b.ownerId === currentUserId, [currentUserId])
|
||||
|
||||
const cardsByColumn = useMemo(() => {
|
||||
const groups: Record<string, Bookmark[]> = {}
|
||||
for (const col of columns) groups[col.id] = []
|
||||
for (const b of bookmarks) {
|
||||
const columnId = columnFor(b, columns)
|
||||
if (columnId && groups[columnId]) groups[columnId].push(b)
|
||||
}
|
||||
for (const list of Object.values(groups)) list.sort((a, b) => sortCards(a, b, currentUserId))
|
||||
return groups
|
||||
}, [bookmarks, columns, currentUserId])
|
||||
|
||||
const clearDrag = useCallback(() => {
|
||||
setDraggedId(null)
|
||||
setDraggedColumnId(null)
|
||||
setCardDrop(null)
|
||||
setColumnDropId(null)
|
||||
}, [])
|
||||
|
||||
const finishDrag = useCallback(() => {
|
||||
clearDrag()
|
||||
// Drag gestures can still emit a click in some browsers; do not open the
|
||||
// detail sheet when the pointer was dragging a card.
|
||||
setTimeout(() => { suppressClick.current = false }, 0)
|
||||
}, [clearDrag])
|
||||
|
||||
const saveColumnOrder = useCallback(async (next: Column[]) => {
|
||||
setColumns(next)
|
||||
const res = await fetch('/api/kanban/columns', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ order: next.map(c => c.id) }) })
|
||||
if (res.ok) invalidateReference('columns')
|
||||
else { toast.error('Could not reorder columns'); load() }
|
||||
}, [load])
|
||||
|
||||
const reorderColumn = useCallback((sourceId: string, targetId: string) => {
|
||||
if (sourceId === targetId) return
|
||||
const from = columns.findIndex(c => c.id === sourceId)
|
||||
const to = columns.findIndex(c => c.id === targetId)
|
||||
if (from < 0 || to < 0) return
|
||||
const next = [...columns]
|
||||
const [moved] = next.splice(from, 1)
|
||||
// Drop before the target column; this is the least surprising direction
|
||||
// for both leftward and rightward moves.
|
||||
next.splice(from < to ? to - 1 : to, 0, moved)
|
||||
saveColumnOrder(next)
|
||||
}, [columns, saveColumnOrder])
|
||||
|
||||
// Position among the owner's cards, ignoring read-only shared cards and the
|
||||
// card being dragged. The server normalizes the target column in this order.
|
||||
const ownedDropIndex = useCallback((columnId: string, rawIndex: number, movedId: string) => {
|
||||
const list = cardsByColumn[columnId] ?? []
|
||||
let index = 0
|
||||
for (let i = 0; i < Math.min(rawIndex, list.length); i++) {
|
||||
const card = list[i]
|
||||
if (card.id === movedId || !isOwned(card)) continue
|
||||
index++
|
||||
}
|
||||
return index
|
||||
}, [cardsByColumn, isOwned])
|
||||
|
||||
const moveCardTo = useCallback(async (bookmarkId: string, columnId: string, position: number) => {
|
||||
const current = bookmarks.find(b => b.id === bookmarkId)
|
||||
if (!current) return
|
||||
if (currentUserId && current.ownerId !== currentUserId) {
|
||||
toast.error('Only the owner can move a shared bookmark')
|
||||
return
|
||||
}
|
||||
const target = (cardsByColumn[columnId] ?? []).filter(b => b.id !== bookmarkId && isOwned(b))
|
||||
const clamped = Math.max(0, Math.min(position, target.length))
|
||||
const optimistic = [...target]
|
||||
optimistic.splice(clamped, 0, { ...current, kanbanColumnId: columnId })
|
||||
const positions = new Map(optimistic.map((b, i) => [b.id, i]))
|
||||
setBookmarks(prev => prev.map(b => {
|
||||
if (b.id === bookmarkId) return { ...b, kanbanColumnId: columnId, kanbanPosition: clamped }
|
||||
const next = positions.get(b.id)
|
||||
return next === undefined ? b : { ...b, kanbanPosition: next }
|
||||
}))
|
||||
try {
|
||||
const res = await fetch(`/api/kanban/cards/${bookmarkId}`, {
|
||||
method: 'PUT', headers: { 'Content-Type': 'application/json' },
|
||||
// Optimistic concurrency (review CR-04): the move carries the card's
|
||||
// version; a card edited or moved elsewhere is a 409, and the board
|
||||
// reloads the true state instead of silently clobbering it.
|
||||
body: JSON.stringify({ columnId, position: clamped, version: current.version ?? 0 }),
|
||||
})
|
||||
if (res.status === 409) {
|
||||
toast.error('This bookmark was changed elsewhere — the board reloaded without your move')
|
||||
load()
|
||||
return
|
||||
}
|
||||
if (!res.ok) throw new Error(await res.text())
|
||||
const saved = await res.json().catch(() => null)
|
||||
if (saved) setBookmarks(prev => prev.map(b => b.id === bookmarkId ? saved : b))
|
||||
} catch (e) { console.error(e); toast.error('Failed to move'); load() }
|
||||
}, [bookmarks, cardsByColumn, currentUserId, isOwned, load])
|
||||
|
||||
const addColumn = async () => {
|
||||
if (!newLabel.trim()) return
|
||||
const res = await fetch('/api/kanban/columns', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ label: newLabel, color: PALETTE[columns.length % PALETTE.length] }),
|
||||
})
|
||||
if (res.ok) { const col = await res.json(); setColumns(c => [...c, col]); setNewLabel(''); setAdding(false); invalidateReference('columns') }
|
||||
else toast.error('Could not add column')
|
||||
}
|
||||
|
||||
const saveColumn = async (id: string, data: Partial<Column>) => {
|
||||
const res = await fetch(`/api/kanban/columns/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) })
|
||||
if (res.ok) { const col = await res.json(); setColumns(cs => cs.map(c => c.id === id ? col : c)); invalidateReference('columns') }
|
||||
else toast.error('Could not update column')
|
||||
setEditingId(null)
|
||||
}
|
||||
|
||||
const deleteColumn = async (col: Column) => {
|
||||
if (columns.length <= 1) { toast.error('A board needs at least one column'); return }
|
||||
const dest = columns.find(c => c.id !== col.id)
|
||||
if (!confirm(`Delete "${col.label}"? Its bookmarks will move to "${dest?.label}".`)) return
|
||||
const res = await fetch(`/api/kanban/columns/${col.id}`, { method: 'DELETE' })
|
||||
if (res.ok) { toast.success('Column deleted'); invalidateReference('columns'); load() } else toast.error((await res.json())?.error ?? 'Could not delete')
|
||||
}
|
||||
|
||||
const moveColumn = (index: number, dir: -1 | 1) => {
|
||||
const next = [...columns]
|
||||
const j = index + dir
|
||||
if (j < 0 || j >= next.length) return
|
||||
;[next[index], next[j]] = [next[j], next[index]]
|
||||
saveColumnOrder(next)
|
||||
}
|
||||
|
||||
// Keyboard alternative to drag-and-drop: focus a card, Arrow Left/Right
|
||||
// moves it to the adjacent column.
|
||||
const moveCard = (b: Bookmark, dir: -1 | 1) => {
|
||||
if (!isOwned(b)) { toast.error('Only the owner can move a shared bookmark'); return }
|
||||
const current = columnFor(b, columns)
|
||||
const idx = columns.findIndex(c => c.id === current)
|
||||
const j = idx + dir
|
||||
if (idx < 0 || j < 0 || j >= columns.length) return
|
||||
moveCardTo(b.id, columns[j].id, cardsByColumn[columns[j].id]?.filter(c => c.id !== b.id && isOwned(c)).length ?? 0)
|
||||
toast.success(`Moved to ${columns[j].label}`)
|
||||
}
|
||||
|
||||
const handleCardDragOver = (e: React.DragEvent<HTMLElement>, col: Column, index: number) => {
|
||||
if (!draggedId || draggedColumnId) return
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
e.dataTransfer.dropEffect = 'move'
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
const before = e.clientY < rect.top + rect.height / 2
|
||||
setCardDrop({ columnId: col.id, index: before ? index : index + 1 })
|
||||
}
|
||||
|
||||
const handleColumnDragOver = (e: React.DragEvent<HTMLElement>, col: Column) => {
|
||||
if (draggedColumnId) {
|
||||
if (draggedColumnId === col.id) return
|
||||
e.preventDefault()
|
||||
e.dataTransfer.dropEffect = 'move'
|
||||
setColumnDropId(col.id)
|
||||
setCardDrop(null)
|
||||
return
|
||||
}
|
||||
if (!draggedId) return
|
||||
e.preventDefault()
|
||||
e.dataTransfer.dropEffect = 'move'
|
||||
// The pointer is over the column body, not a card: append to the end.
|
||||
setCardDrop(prev => prev?.columnId === col.id ? prev : { columnId: col.id, index: (cardsByColumn[col.id] ?? []).length })
|
||||
}
|
||||
|
||||
const handleColumnDrop = (e: React.DragEvent<HTMLElement>, col: Column) => {
|
||||
e.preventDefault()
|
||||
if (draggedColumnId) {
|
||||
reorderColumn(draggedColumnId, col.id)
|
||||
finishDrag()
|
||||
return
|
||||
}
|
||||
if (draggedId) {
|
||||
const rawIndex = cardDrop?.columnId === col.id ? cardDrop.index : (cardsByColumn[col.id] ?? []).length
|
||||
moveCardTo(draggedId, col.id, ownedDropIndex(col.id, rawIndex, draggedId))
|
||||
finishDrag()
|
||||
}
|
||||
}
|
||||
|
||||
const handleColumnDragLeave = (e: React.DragEvent<HTMLElement>) => {
|
||||
if (e.currentTarget.contains(e.relatedTarget as Node | null)) return
|
||||
if (draggedColumnId) setColumnDropId(null)
|
||||
if (draggedId) setCardDrop(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-end justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="font-display text-2xl font-bold tracking-tight">Kanban Board</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">Drag cards within or between columns, drag a column by its grip to reorder it, or focus a card and press Arrow Left/Right. Touch users can use each card’s menu.</p>
|
||||
</div>
|
||||
{!adding ? (
|
||||
<Button size="sm" variant="outline" className="gap-1.5" onClick={() => setAdding(true)}><Plus className="h-4 w-4" /> Add column</Button>
|
||||
) : (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Input autoFocus value={newLabel} onChange={e => setNewLabel(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') addColumn(); if (e.key === 'Escape') setAdding(false) }} placeholder="Column name" className="h-8 w-40" />
|
||||
<Button size="icon-sm" onClick={addColumn}><Check className="h-4 w-4" /></Button>
|
||||
<Button size="icon-sm" variant="ghost" onClick={() => setAdding(false)}><X className="h-4 w-4" /></Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4 overflow-x-auto pb-2 h-[calc(100vh-180px)]">
|
||||
{columns.map((col, idx) => {
|
||||
const colBookmarks = cardsByColumn[col.id] ?? []
|
||||
const isCardTarget = !!draggedId && cardDrop?.columnId === col.id
|
||||
const isColumnTarget = !!draggedColumnId && columnDropId === col.id && draggedColumnId !== col.id
|
||||
return (
|
||||
<div
|
||||
key={col.id}
|
||||
data-column-id={col.id}
|
||||
className={cn(
|
||||
'rounded-lg border bg-muted/30 flex flex-col w-72 shrink-0 transition-[box-shadow,background-color,border-color] duration-150',
|
||||
isCardTarget && 'bg-primary/[0.04] border-primary/30 shadow-sm',
|
||||
isColumnTarget && 'ring-2 ring-primary/50 border-primary/40',
|
||||
)}
|
||||
style={{ borderTopColor: col.color, borderTopWidth: 3 }}
|
||||
onDragOver={e => handleColumnDragOver(e, col)}
|
||||
onDragLeave={handleColumnDragLeave}
|
||||
onDrop={e => handleColumnDrop(e, col)}
|
||||
>
|
||||
<div className="px-3 py-2 border-b flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<span
|
||||
draggable
|
||||
onDragStart={e => {
|
||||
setDraggedColumnId(col.id)
|
||||
setDraggedId(null)
|
||||
setCardDrop(null)
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
e.dataTransfer.setData('text/plain', col.id)
|
||||
}}
|
||||
onDragEnd={finishDrag}
|
||||
className="cursor-grab active:cursor-grabbing text-muted-foreground/40 hover:text-muted-foreground transition-colors"
|
||||
title="Drag to reorder column"
|
||||
aria-label={`Drag ${col.label} column`}
|
||||
>
|
||||
<GripVertical className="h-4 w-4" />
|
||||
</span>
|
||||
{editingId === col.id ? (
|
||||
<Input autoFocus value={editLabel} onChange={e => setEditLabel(e.target.value)} onBlur={() => saveColumn(col.id, { label: editLabel })}
|
||||
onKeyDown={e => { if (e.key === 'Enter') saveColumn(col.id, { label: editLabel }); if (e.key === 'Escape') setEditingId(null) }} className="h-7 text-sm" />
|
||||
) : (
|
||||
<h3 className="text-sm font-medium truncate flex items-center gap-2">
|
||||
<span className="h-2 w-2 rounded-full shrink-0" style={{ backgroundColor: col.color }} />{col.label}
|
||||
</h3>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<span className="text-xs text-muted-foreground bg-muted rounded-full px-2 py-0.5">{colBookmarks.length}</span>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button size="icon-sm" variant="ghost" aria-label={`Column ${col.label} options`}><MoreHorizontal className="h-4 w-4" /></Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => { setEditingId(col.id); setEditLabel(col.label) }}><Pencil className="h-3.5 w-3.5 mr-2" />Rename</DropdownMenuItem>
|
||||
<DropdownMenuItem disabled={idx === 0} onClick={() => moveColumn(idx, -1)}><ArrowLeft className="h-3.5 w-3.5 mr-2" />Move left</DropdownMenuItem>
|
||||
<DropdownMenuItem disabled={idx === columns.length - 1} onClick={() => moveColumn(idx, 1)}><ArrowRight className="h-3.5 w-3.5 mr-2" />Move right</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<div className="flex gap-1.5 px-2 py-1.5">
|
||||
{PALETTE.map(c => (
|
||||
<button key={c} aria-label={`Color ${c}`} onClick={() => saveColumn(col.id, { color: c })} className="h-4 w-4 rounded-full ring-offset-1 hover:ring-2 ring-ring" style={{ backgroundColor: c }} />
|
||||
))}
|
||||
</div>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem className="text-destructive" onClick={() => deleteColumn(col)}><Trash2 className="h-3.5 w-3.5 mr-2" />Delete column</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-2 space-y-2">
|
||||
{loading ? (
|
||||
Array.from({ length: 3 }).map((_, i) => <div key={i} className="h-20 rounded-lg bg-muted/50 animate-pulse" />)
|
||||
) : colBookmarks.length === 0 ? (
|
||||
<div className={cn('flex h-24 items-center justify-center rounded-lg border border-dashed text-xs text-muted-foreground transition-colors', isCardTarget && 'border-primary/50 bg-primary/[0.03] text-primary')}>
|
||||
{draggedId ? 'Drop card here' : 'No cards yet'}
|
||||
</div>
|
||||
) : colBookmarks.map((b, index) => {
|
||||
const canMove = isOwned(b)
|
||||
const showLine = isCardTarget && cardDrop?.index === index
|
||||
const currentColumn = columnFor(b, columns)
|
||||
return (
|
||||
<Fragment key={b.id}>
|
||||
{showLine && <div className="mx-1 h-1 rounded-full bg-primary/70 shadow-sm" />}
|
||||
<div
|
||||
draggable={canMove}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
data-bookmark-id={b.id}
|
||||
aria-label={canMove
|
||||
? `${b.title ?? 'Untitled'}. Enter opens details; Arrow Left or Right moves to the adjacent column.`
|
||||
: `${b.title ?? 'Untitled'} (shared). Enter opens details.`}
|
||||
onDragStart={e => {
|
||||
if (!canMove) { e.preventDefault(); return }
|
||||
suppressClick.current = true
|
||||
setDraggedId(b.id)
|
||||
setDraggedColumnId(null)
|
||||
setColumnDropId(null)
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
e.dataTransfer.setData('text/plain', b.id)
|
||||
}}
|
||||
onDragEnd={finishDrag}
|
||||
onDragOver={e => handleCardDragOver(e, col, index)}
|
||||
onClick={() => { if (!suppressClick.current) setSelectedBookmark(b) }}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setSelectedBookmark(b) }
|
||||
else if (canMove && e.key === 'ArrowLeft') { e.preventDefault(); moveCard(b, -1) }
|
||||
else if (canMove && e.key === 'ArrowRight') { e.preventDefault(); moveCard(b, 1) }
|
||||
}}
|
||||
className={cn(
|
||||
'rounded-lg border bg-card p-3 shadow-sm transition-[transform,box-shadow,opacity] duration-150 group focus-visible:outline-2 focus-visible:outline-primary',
|
||||
canMove ? 'cursor-grab active:cursor-grabbing hover:-translate-y-0.5 hover:shadow-md' : 'cursor-default',
|
||||
draggedId === b.id && 'opacity-40 scale-[0.98]',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<GripVertical className={cn('h-4 w-4 text-muted-foreground/50 mt-0.5 shrink-0', !canMove && 'opacity-30')} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{b.favicon && <img src={b.favicon} alt="" referrerPolicy="no-referrer" className="h-3.5 w-3.5 rounded" onError={(e: any) => { e.target.style.display = 'none' }} />}
|
||||
<p className="text-sm font-medium truncate">{b.title ?? 'Untitled'}</p>
|
||||
{!canMove && <Badge variant="outline" className="text-[9px] px-1 py-0 shrink-0">Shared</Badge>}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1 mt-1.5">
|
||||
{(b.tags ?? []).slice(0, 2).map((bt: any) => (
|
||||
<Badge key={bt?.tag?.id} variant="secondary" className="text-[9px] px-1 py-0" style={{ backgroundColor: `${bt?.tag?.color}20`, color: bt?.tag?.color }}>{bt?.tag?.name}</Badge>
|
||||
))}
|
||||
</div>
|
||||
{b.dueDate && (
|
||||
<p className="text-[10px] text-muted-foreground mt-1 flex items-center gap-1">
|
||||
<Calendar className="h-3 w-3" />
|
||||
{new Date(b.dueDate).toLocaleDateString('en-US', { month: 'short', day: 'numeric', timeZone: 'UTC' })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-0.5 opacity-60 group-hover:opacity-100 focus-within:opacity-100 shrink-0" onDragStart={e => { e.preventDefault(); e.stopPropagation() }}>
|
||||
<a href={b.url} target="_blank" rel="noopener noreferrer" onClick={e => e.stopPropagation()} className="rounded p-0.5" aria-label="Open link">
|
||||
<ExternalLink className="h-3.5 w-3.5 text-muted-foreground hover:text-primary" />
|
||||
</a>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button size="icon-sm" variant="ghost" className="h-6 w-6" onClick={e => e.stopPropagation()} aria-label={`${b.title ?? 'Bookmark'} options`}><MoreHorizontal className="h-3.5 w-3.5" /></Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => setSelectedBookmark(b)}><ExternalLink className="h-3.5 w-3.5 mr-2" />Open details</DropdownMenuItem>
|
||||
{canMove && columns.length > 1 && <DropdownMenuSeparator />}
|
||||
{canMove && columns.filter(c => c.id !== currentColumn).map(c => (
|
||||
<DropdownMenuItem key={c.id} onClick={() => moveCardTo(b.id, c.id, cardsByColumn[c.id]?.filter(x => x.id !== b.id && isOwned(x)).length ?? 0)}>
|
||||
<span className="h-2 w-2 rounded-full mr-2 shrink-0" style={{ backgroundColor: c.color }} />Move to {c.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Fragment>
|
||||
)
|
||||
})}
|
||||
{!loading && isCardTarget && cardDrop?.index === colBookmarks.length && colBookmarks.length > 0 && (
|
||||
<div className="mx-1 h-1 rounded-full bg-primary/70 shadow-sm" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<BookmarkDetailSheet bookmark={selectedBookmark} onClose={() => setSelectedBookmark(null)} onUpdate={load} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { KanbanClient } from './client'
|
||||
|
||||
export default function KanbanPage() {
|
||||
return <KanbanClient />
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { auth } from '@/auth'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { AppShellWrapper } from '@/components/app-shell-wrapper'
|
||||
|
||||
export default async function AppLayout({ children }: { children: React.ReactNode }) {
|
||||
const session = await auth()
|
||||
if (!session?.user) redirect('/login')
|
||||
// Pass the server session down so the shell and screens render during SSR
|
||||
// instead of being discarded and refetched on the client.
|
||||
return <AppShellWrapper session={session}>{children}</AppShellWrapper>
|
||||
}
|
||||
@@ -0,0 +1,699 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useRef, useState, type ClipboardEvent } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
ChevronLeft, ChevronRight, Code, Download, Eye, FileText, GripVertical, Heading1, Heading2, Heading3,
|
||||
Link2, List, ListOrdered, ListTodo, Minus, Pencil, Pin, PinOff, Plus, Quote, Search, Trash2, Type, X,
|
||||
} from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { PageAutosave, PageConflictError, type PageSaveState } from '@/lib/page-autosave'
|
||||
import { PAGES_CHANGED, nextUntitledTitle, notifyActivePage, notifyPagesChanged, type PageSummary } from '@/lib/pages'
|
||||
import { reorderPages } from '@/lib/page-order'
|
||||
import { fetchLinkPreview } from '@/lib/link-preview'
|
||||
import { LinkPreviewCard } from '@/components/link-preview-card'
|
||||
import type { Element, ElementContent } from 'hast'
|
||||
|
||||
export { PAGES_CHANGED }
|
||||
|
||||
// Notion-style slash menu: typing "/" at the start of a line offers blocks
|
||||
// that insert their Markdown equivalent. The textarea stays the source of
|
||||
// truth, so autosave and conflict handling are untouched.
|
||||
const SLASH_BLOCKS = [
|
||||
{ key: 'text', label: 'Text', desc: 'Plain paragraph', icon: Type, prefix: '' },
|
||||
{ key: 'h1', label: 'Heading 1', desc: 'Large section heading', icon: Heading1, prefix: '# ' },
|
||||
{ key: 'h2', label: 'Heading 2', desc: 'Medium section heading', icon: Heading2, prefix: '## ' },
|
||||
{ key: 'h3', label: 'Heading 3', desc: 'Small section heading', icon: Heading3, prefix: '### ' },
|
||||
{ key: 'todo', label: 'To-do list', desc: 'Track tasks with a checkbox', icon: ListTodo, prefix: '- [ ] ' },
|
||||
{ key: 'bullet', label: 'Bulleted list', desc: 'Simple bulleted list', icon: List, prefix: '- ' },
|
||||
{ key: 'num', label: 'Numbered list', desc: 'List with numbering', icon: ListOrdered, prefix: '1. ' },
|
||||
{ key: 'quote', label: 'Quote', desc: 'Capture a quote', icon: Quote, prefix: '> ' },
|
||||
{ key: 'div', label: 'Divider', desc: 'Visual section break', icon: Minus, prefix: '---\n' },
|
||||
{ key: 'code', label: 'Code', desc: 'Code snippet block', icon: Code, prefix: '```\n' },
|
||||
] as const
|
||||
|
||||
type LinkedBookmark = { id: string; url: string; title: string | null; favicon: string | null; ogImage: string | null; ogTitle: string | null; ogDescription: string | null; description: string | null }
|
||||
type PageDetail = PageSummary & { content: string; version: number; bookmarks: { bookmark: LinkedBookmark }[] }
|
||||
|
||||
const notify = notifyPagesChanged
|
||||
const host = (u: string) => { try { return new URL(u).hostname.replace(/^www\./, '') } catch { return u } }
|
||||
|
||||
const textOf = (nodes: ElementContent[]): string =>
|
||||
nodes.map((n) => (n.type === 'text' ? n.value : n.type === 'element' ? textOf(n.children) : '')).join('')
|
||||
|
||||
// A paragraph whose only content is a single external link — a bare URL
|
||||
// autolinked by GFM or a lone [label](url) — renders as a Notion-style
|
||||
// preview card instead of a plain <p><a>. Links woven into sentences stay
|
||||
// inline text; the Markdown source remains the source of truth either way.
|
||||
function standaloneLink(node: Element | undefined): { href: string; label: string } | null {
|
||||
if (!node) return null
|
||||
const kids = node.children.filter((c) => !(c.type === 'text' && c.value.trim() === ''))
|
||||
if (kids.length !== 1) return null
|
||||
const a = kids[0]
|
||||
if (a.type !== 'element' || a.tagName !== 'a') return null
|
||||
const href = a.properties?.href
|
||||
if (typeof href !== 'string' || !/^https?:\/\//i.test(href)) return null
|
||||
return { href, label: textOf(a.children) }
|
||||
}
|
||||
|
||||
export function PagesClient({ initialId }: { initialId: string | null }) {
|
||||
const router = useRouter()
|
||||
const [pages, setPages] = useState<PageSummary[]>([])
|
||||
const [activeId, setActiveId] = useState<string | null>(initialId)
|
||||
const [page, setPage] = useState<PageDetail | null>(null)
|
||||
const [filter, setFilter] = useState('')
|
||||
const [mode, setMode] = useState<'edit' | 'preview'>('preview')
|
||||
const [title, setTitle] = useState('')
|
||||
const [content, setContent] = useState('')
|
||||
const [saveStates, setSaveStates] = useState<Record<string, PageSaveState>>({})
|
||||
const [linkQuery, setLinkQuery] = useState('')
|
||||
const [results, setResults] = useState<LinkedBookmark[]>([])
|
||||
const [linkOpen, setLinkOpen] = useState(false)
|
||||
const [slash, setSlash] = useState<{ query: string; start: number } | null>(null)
|
||||
const [slashIndex, setSlashIndex] = useState(0)
|
||||
const [tabDrop, setTabDrop] = useState<{ id: string; after: boolean } | null>(null)
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const pendingCaret = useRef<number | null>(null)
|
||||
const suppressTabClick = useRef(false)
|
||||
const draggedTabRef = useRef<string | null>(null)
|
||||
const activePage = useRef(initialId)
|
||||
const loadSequence = useRef(0)
|
||||
const selectionSequence = useRef(0)
|
||||
// Optimistic concurrency (review DATA-01): PageAutosave tracks the server
|
||||
// revision per page and sends it with content saves, so a newer save from
|
||||
// another tab or device answers 409 instead of being silently overwritten.
|
||||
const persistContent = useCallback(async (id: string, content: string, version: number | undefined) => {
|
||||
const body = JSON.stringify(typeof version === 'number' ? { content, version } : { content })
|
||||
let res: Response
|
||||
try {
|
||||
res = await fetch(`/api/pages/${id}`, {
|
||||
method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body,
|
||||
keepalive: new TextEncoder().encode(body).length < 60000,
|
||||
})
|
||||
} catch (error) {
|
||||
toast.error('Could not save page. Your draft is kept in this tab; retry saving.')
|
||||
throw error
|
||||
}
|
||||
if (res.status === 409) {
|
||||
// Another session saved first. Review DATA-04: the queue enters a
|
||||
// paused conflict state instead of adopting the remote version, so
|
||||
// nothing overwrites the newer content until the user explicitly
|
||||
// chooses to overwrite, discard, or copy the draft.
|
||||
let serverVersion: number | undefined
|
||||
try {
|
||||
const latest = await fetch(`/api/pages/${id}`, { cache: 'no-store' }).then(r => (r.ok ? r.json() : null))
|
||||
if (latest && typeof latest.version === 'number') serverVersion = latest.version
|
||||
} catch { /* keep the stale base; the conflict state pauses autosave either way */ }
|
||||
toast.error('This page was saved in another tab or device. Autosave is paused so nothing is overwritten until you resolve the conflict.')
|
||||
throw new PageConflictError(serverVersion)
|
||||
}
|
||||
if (!res.ok) {
|
||||
toast.error('Could not save page. Your draft is kept in this tab; retry saving.')
|
||||
throw new Error('Save failed')
|
||||
}
|
||||
const saved = await res.json().catch(() => null)
|
||||
return saved && typeof saved.version === 'number' ? saved.version : undefined
|
||||
}, [])
|
||||
const [autosave] = useState(() => new PageAutosave(persistContent, (id, state) => setSaveStates(previous => ({ ...previous, [id]: state }))))
|
||||
const saveState = activeId ? saveStates[activeId] ?? 'saved' : 'saved'
|
||||
|
||||
useEffect(() => {
|
||||
const beforeUnload = (event: BeforeUnloadEvent) => {
|
||||
if (autosave.hasUnsaved()) { event.preventDefault(); event.returnValue = '' }
|
||||
}
|
||||
const flush = () => { void autosave.flushAll() }
|
||||
window.addEventListener('beforeunload', beforeUnload)
|
||||
window.addEventListener('pagehide', flush)
|
||||
return () => {
|
||||
window.removeEventListener('beforeunload', beforeUnload)
|
||||
window.removeEventListener('pagehide', flush)
|
||||
flush()
|
||||
}
|
||||
}, [autosave])
|
||||
|
||||
const loadPages = useCallback(async () => {
|
||||
const res = await fetch('/api/pages')
|
||||
if (!res.ok) return [] as PageSummary[]
|
||||
const data: PageSummary[] = await res.json()
|
||||
setPages(data)
|
||||
return data
|
||||
}, [])
|
||||
|
||||
const loadPage = useCallback(async (id: string) => {
|
||||
const sequence = ++loadSequence.current
|
||||
const res = await fetch(`/api/pages/${id}`)
|
||||
if (sequence !== loadSequence.current || activePage.current !== id) return
|
||||
if (!res.ok) { setPage(null); notifyActivePage(null); return }
|
||||
const data: PageDetail = await res.json()
|
||||
if (sequence !== loadSequence.current || activePage.current !== id) return
|
||||
if (typeof data.version === 'number') autosave.setVersion(data.id, data.version)
|
||||
// Review DATA-03: recovery compares the draft's original base version
|
||||
// with the freshly fetched server version; a mismatch pauses autosave in
|
||||
// the conflict state instead of silently rebasing onto the newer save.
|
||||
const draft = autosave.restore(id, data.version)
|
||||
setPage(data)
|
||||
setTitle(data.title)
|
||||
setContent(draft ?? data.content)
|
||||
notifyActivePage(data.title)
|
||||
if (draft !== undefined || !data.content) setMode('edit')
|
||||
}, [autosave])
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => loadPages().then((data) => {
|
||||
if (!activePage.current && data.length) { activePage.current = data[0].id; setActiveId(data[0].id) }
|
||||
}).catch((e) => console.error('Failed to load pages', e)), 0)
|
||||
return () => clearTimeout(timer)
|
||||
}, [loadPages])
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
if (activeId) loadPage(activeId).catch((e) => console.error('Failed to load page', e))
|
||||
else setPage(null)
|
||||
}, 0)
|
||||
return () => clearTimeout(timer)
|
||||
}, [activeId, loadPage])
|
||||
|
||||
const select = async (id: string) => {
|
||||
const sequence = ++selectionSequence.current
|
||||
if (activePage.current && !await autosave.flush(activePage.current)) return
|
||||
if (sequence !== selectionSequence.current) return
|
||||
activePage.current = id
|
||||
setActiveId(id)
|
||||
router.replace(`/pages?id=${id}`)
|
||||
}
|
||||
|
||||
// The sidebar can create or open pages while this workspace stays mounted.
|
||||
useEffect(() => {
|
||||
if (!initialId || initialId === activePage.current) return
|
||||
void loadPages()
|
||||
void select(initialId)
|
||||
}, [initialId])
|
||||
|
||||
// Sidebar mutations (rename, pin, delete) announce themselves; keep the
|
||||
// page list in step and drop the detail view when the open page is gone.
|
||||
useEffect(() => {
|
||||
const reload = () => {
|
||||
void loadPages().then((data) => {
|
||||
if (activePage.current && !data.some((p) => p.id === activePage.current)) {
|
||||
activePage.current = null
|
||||
setActiveId(null)
|
||||
setPage(null)
|
||||
notifyActivePage(null)
|
||||
router.replace('/pages')
|
||||
}
|
||||
})
|
||||
}
|
||||
window.addEventListener(PAGES_CHANGED, reload)
|
||||
return () => window.removeEventListener(PAGES_CHANGED, reload)
|
||||
}, [loadPages, router])
|
||||
|
||||
// Leaving the pages workspace clears the breadcrumb title.
|
||||
useEffect(() => () => { notifyActivePage(null) }, [])
|
||||
|
||||
const patch = async (body: Record<string, unknown>) => {
|
||||
if (!activeId || page?.id !== activeId) return null
|
||||
const res = await fetch(`/api/pages/${page.id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
|
||||
if (!res.ok) { toast.error('Could not save page'); return null }
|
||||
// Review CR-17: metadata responses (rename/pin/link changes) carry the
|
||||
// CURRENT content revision but never require or acknowledge a content
|
||||
// base — adopting it here would silently rebase an in-flight stale draft
|
||||
// onto another editor's save and authorize overwriting it. Only content
|
||||
// saves (persistContent) and fresh page loads may move the version map.
|
||||
return res
|
||||
}
|
||||
|
||||
const createPage = async () => {
|
||||
if (activePage.current && !await autosave.flush(activePage.current)) return
|
||||
const res = await fetch('/api/pages', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: nextUntitledTitle(pages.map((p) => p.title)) }) })
|
||||
if (!res.ok) { toast.error('Could not create page'); return }
|
||||
const p = await res.json()
|
||||
await loadPages()
|
||||
notify()
|
||||
setMode('edit')
|
||||
select(p.id)
|
||||
}
|
||||
|
||||
const onContentChange = (v: string) => {
|
||||
if (!page || page.id !== activeId) return
|
||||
setContent(v)
|
||||
autosave.edit(page.id, v)
|
||||
}
|
||||
|
||||
// Show the block menu only while the caret sits on a bare "/token" line.
|
||||
const updateSlash = (text: string, caret: number) => {
|
||||
const lineStart = text.lastIndexOf('\n', caret - 1) + 1
|
||||
const line = text.slice(lineStart, caret)
|
||||
const m = /^\/([\w-]*)$/.exec(line)
|
||||
if (m) { setSlash({ query: m[1].toLowerCase(), start: lineStart }); setSlashIndex(0) }
|
||||
else setSlash(null)
|
||||
}
|
||||
|
||||
const slashFiltered = SLASH_BLOCKS.filter((b) => !slash?.query || b.key.includes(slash.query) || b.label.toLowerCase().includes(slash.query))
|
||||
|
||||
const applyBlock = (block: (typeof SLASH_BLOCKS)[number]) => {
|
||||
const ta = textareaRef.current
|
||||
if (!slash || !ta) return
|
||||
const caret = ta.selectionStart
|
||||
const next = content.slice(0, slash.start) + block.prefix + content.slice(caret)
|
||||
pendingCaret.current = slash.start + block.prefix.length
|
||||
onContentChange(next)
|
||||
setSlash(null)
|
||||
}
|
||||
|
||||
// Restore the caret after a programmatic content rewrite (slash insert,
|
||||
// "[] " shortcut). Runs once the new content has reached the textarea.
|
||||
useEffect(() => {
|
||||
if (pendingCaret.current === null || mode !== 'edit') return
|
||||
const ta = textareaRef.current
|
||||
if (!ta) return
|
||||
const c = pendingCaret.current
|
||||
pendingCaret.current = null
|
||||
ta.focus()
|
||||
ta.setSelectionRange(c, c)
|
||||
}, [content, mode])
|
||||
|
||||
const saveTitle = async () => {
|
||||
if (!page) return
|
||||
const t = title.trim()
|
||||
// Keep the generated "Untitled N" name until the user gives a real one.
|
||||
if (!t) { setTitle(page.title); return }
|
||||
if (t === page.title) return
|
||||
if (await patch({ title: t })) {
|
||||
setPage(current => current?.id === page.id ? { ...current, title: t } : current)
|
||||
notifyActivePage(t)
|
||||
await loadPages(); notify()
|
||||
}
|
||||
}
|
||||
|
||||
const togglePin = async () => {
|
||||
if (!page) return
|
||||
if (await patch({ pinned: !page.pinned })) {
|
||||
setPage(current => current?.id === page.id ? { ...current, pinned: !page.pinned } : current)
|
||||
await loadPages(); notify()
|
||||
}
|
||||
}
|
||||
|
||||
const move = async (dir: -1 | 1) => {
|
||||
if (!activeId) return
|
||||
const idx = pages.findIndex((p) => p.id === activeId)
|
||||
const target = pages[idx + dir]
|
||||
if (idx < 0 || !target) return
|
||||
const next = reorderPages(pages, activeId, target.id, dir > 0)
|
||||
if (next === 'blocked') { toast.error('Pinned and unpinned pages keep separate groups'); return }
|
||||
if (next) await savePageOrder(next)
|
||||
}
|
||||
|
||||
const savePageOrder = async (next: PageSummary[]) => {
|
||||
setPages(next)
|
||||
const res = await fetch('/api/pages', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ order: next.map((p) => p.id) }) })
|
||||
if (!res.ok) { toast.error('Could not reorder pages'); await loadPages(); return }
|
||||
await loadPages()
|
||||
notify()
|
||||
}
|
||||
|
||||
const dropTab = (target: PageSummary, after: boolean) => {
|
||||
const source = draggedTabRef.current
|
||||
draggedTabRef.current = null
|
||||
setTabDrop(null)
|
||||
if (!source) return
|
||||
const next = reorderPages(pages, source, target.id, after)
|
||||
if (next === 'blocked') { toast.error('Pinned and unpinned pages keep separate groups'); return }
|
||||
if (next) void savePageOrder(next)
|
||||
}
|
||||
|
||||
const download = async () => {
|
||||
if (!activeId) return
|
||||
// A paused conflict can never flush; exporting the server copy is still
|
||||
// useful, so only ordinary save failures block the download.
|
||||
if (autosave.conflictVersion(activeId) === null && !await autosave.flush(activeId)) return
|
||||
const a = document.createElement('a')
|
||||
a.href = `/api/pages/${activeId}/markdown`
|
||||
a.download = ''
|
||||
document.body.appendChild(a); a.click(); a.remove()
|
||||
}
|
||||
|
||||
// Explicit conflict resolutions (review DATA-04). Typing is never consent;
|
||||
// each path is a deliberate click.
|
||||
const overwriteRemote = async () => {
|
||||
if (!activeId) return
|
||||
if (await autosave.resolveOverwrite(activeId)) toast.success('Your draft replaced the newer version')
|
||||
else toast.error('Could not save page. Your draft is kept in this tab; retry saving.')
|
||||
}
|
||||
const discardDraft = async () => {
|
||||
if (!activeId) return
|
||||
autosave.discard(activeId)
|
||||
await loadPage(activeId)
|
||||
}
|
||||
const copyDraft = async () => {
|
||||
const res = await fetch('/api/pages', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title: `${page?.title ?? 'Untitled'} (draft copy)`, content }),
|
||||
})
|
||||
if (!res.ok) { toast.error('Could not create the copy'); return }
|
||||
toast.success('Draft copied to a new page')
|
||||
await loadPages(); notify()
|
||||
}
|
||||
|
||||
const remove = async () => {
|
||||
if (!page || !confirm(`Delete page "${page.title}"? Bookmarks are kept.`)) return
|
||||
if (!await autosave.flush(page.id)) return
|
||||
const res = await fetch(`/api/pages/${page.id}`, { method: 'DELETE' })
|
||||
if (!res.ok) { toast.error('Could not delete page'); return }
|
||||
autosave.forget(page.id)
|
||||
const data = await loadPages(); notify()
|
||||
if (data.length) select(data[0].id)
|
||||
else { activePage.current = null; setActiveId(null); router.replace('/pages') }
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!linkOpen) return
|
||||
const q = linkQuery.trim()
|
||||
const t = setTimeout(async () => {
|
||||
try {
|
||||
const params = new URLSearchParams({ limit: '8' })
|
||||
if (q) params.set('search', q)
|
||||
const res = await fetch(`/api/bookmarks?${params}`)
|
||||
if (res.ok) setResults((await res.json()).slice(0, 8))
|
||||
} catch { /* transient; retried on next keystroke */ }
|
||||
}, 250)
|
||||
return () => clearTimeout(t)
|
||||
}, [linkQuery, linkOpen])
|
||||
|
||||
const addBookmark = async (bookmarkId: string) => {
|
||||
if (activeId && !await autosave.flush(activeId)) return
|
||||
if (await patch({ addBookmarkId: bookmarkId })) {
|
||||
setLinkOpen(false); setLinkQuery('')
|
||||
if (activeId) await loadPage(activeId)
|
||||
await loadPages()
|
||||
}
|
||||
}
|
||||
|
||||
const addUrl = async () => {
|
||||
let url = linkQuery.trim()
|
||||
if (!url) return
|
||||
if (!/^https?:\/\//i.test(url)) url = `https://${url}`
|
||||
const res = await fetch('/api/bookmarks', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url }) })
|
||||
if (!res.ok) { toast.error('Could not save URL'); return }
|
||||
const b = await res.json()
|
||||
await addBookmark(b.id)
|
||||
toast.success('Link added')
|
||||
}
|
||||
|
||||
const removeBookmark = async (bookmarkId: string) => {
|
||||
if (await patch({ removeBookmarkId: bookmarkId })) {
|
||||
if (page) setPage({ ...page, bookmarks: page.bookmarks.filter((x) => x.bookmark.id !== bookmarkId) })
|
||||
await loadPages()
|
||||
}
|
||||
}
|
||||
|
||||
// Notion-style paste: a bare URL lands in the textarea untouched, and once
|
||||
// its preview resolves we offer to upgrade it to a titled Markdown link.
|
||||
// Nothing is rewritten without an explicit click, so undo stays intact.
|
||||
const offerTitleForPastedUrl = (url: string) => {
|
||||
void fetchLinkPreview(url).then((p) => {
|
||||
const raw = p?.ogTitle || p?.title
|
||||
if (!raw) return
|
||||
const title = raw.replace(/[\[\]\n]/g, ' ').trim()
|
||||
if (!title || title === url) return
|
||||
toast.info('Paste as titled link?', {
|
||||
description: title,
|
||||
duration: 10000,
|
||||
action: {
|
||||
label: 'Use title',
|
||||
onClick: () => {
|
||||
const ta = textareaRef.current
|
||||
if (!ta) return
|
||||
const idx = ta.value.indexOf(url)
|
||||
if (idx < 0) return
|
||||
const markdown = `[${title}](${url})`
|
||||
pendingCaret.current = idx + markdown.length
|
||||
onContentChange(ta.value.slice(0, idx) + markdown + ta.value.slice(idx + url.length))
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const onPaste = (e: ClipboardEvent<HTMLTextAreaElement>) => {
|
||||
const text = e.clipboardData.getData('text/plain').trim()
|
||||
if (/^https?:\/\/\S+$/i.test(text)) offerTitleForPastedUrl(text)
|
||||
}
|
||||
|
||||
const visible = pages.filter((p) => p.title.toLowerCase().includes(filter.toLowerCase()))
|
||||
const looksLikeUrl = /^(https?:\/\/)?[\w-]+(\.[\w-]+)+/i.test(linkQuery.trim())
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex items-center gap-2 border-b border-border/60 px-4 py-2">
|
||||
<div className="relative w-40 shrink-0">
|
||||
<Search className="absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input value={filter} onChange={(e) => setFilter(e.target.value)} placeholder="Filter pages" className="h-8 pl-7 text-sm" />
|
||||
</div>
|
||||
<div className="flex flex-1 items-center gap-1 overflow-x-auto">
|
||||
{visible.map((p) => (
|
||||
<div
|
||||
key={p.id}
|
||||
data-page-tab={p.id}
|
||||
onDragOver={(e) => {
|
||||
if (!draggedTabRef.current || draggedTabRef.current === p.id) return
|
||||
e.preventDefault()
|
||||
e.dataTransfer.dropEffect = 'move'
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
setTabDrop({ id: p.id, after: e.clientX > rect.left + rect.width / 2 })
|
||||
}}
|
||||
onDragLeave={(e) => {
|
||||
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setTabDrop(null)
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault()
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
dropTab(p, e.clientX > rect.left + rect.width / 2)
|
||||
}}
|
||||
title={filter.trim() ? 'Clear the filter to reorder pages' : 'Drag the grip to reorder page'}
|
||||
className={cn(
|
||||
'group/tab flex shrink-0 items-center gap-1 rounded-md px-2 py-1.5 text-sm transition-colors',
|
||||
p.id === activeId ? 'bg-primary/10 font-medium text-primary' : 'text-muted-foreground hover:bg-muted hover:text-foreground',
|
||||
tabDrop?.id === p.id && 'ring-1 ring-primary/50',
|
||||
)}
|
||||
>
|
||||
{!filter.trim() && (
|
||||
<span
|
||||
data-page-tab-drag
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
suppressTabClick.current = true
|
||||
draggedTabRef.current = p.id
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
e.dataTransfer.setData('text/plain', p.id)
|
||||
}}
|
||||
onDragEnd={() => {
|
||||
draggedTabRef.current = null
|
||||
setTabDrop(null)
|
||||
setTimeout(() => { suppressTabClick.current = false }, 0)
|
||||
}}
|
||||
className="cursor-grab text-muted-foreground/40 opacity-0 transition-opacity hover:text-muted-foreground active:cursor-grabbing group-hover/tab:opacity-100"
|
||||
aria-label={`Drag page ${p.title}`}
|
||||
>
|
||||
<GripVertical className="h-3 w-3" />
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { if (!suppressTabClick.current) select(p.id) }}
|
||||
aria-label={p.title}
|
||||
className="flex min-w-0 items-center gap-1.5"
|
||||
>
|
||||
{p.pinned ? <Pin className="h-3 w-3" /> : <FileText className="h-3 w-3" />}
|
||||
<span className="max-w-[10rem] truncate">{p.title}</span>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Button size="sm" onClick={createPage}><Plus className="mr-1 h-4 w-4" />New page</Button>
|
||||
</div>
|
||||
|
||||
{!page || page.id !== activeId ? (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-3 p-10 text-center">
|
||||
<FileText className="h-10 w-10 text-muted-foreground" />
|
||||
<h2 className="font-display text-xl font-semibold">{pages.length ? 'Select a page' : 'No pages yet'}</h2>
|
||||
<p className="max-w-sm text-sm text-muted-foreground">Pages are Markdown notes that collect links into a workspace. A bookmark can live on many pages.</p>
|
||||
<Button onClick={createPage}><Plus className="mr-1 h-4 w-4" />Create a page</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mx-auto w-full max-w-4xl flex-1 overflow-y-auto px-6 py-8">
|
||||
<div className="mb-6 flex flex-wrap items-center gap-2">
|
||||
<input value={title} onChange={(e) => setTitle(e.target.value)} onBlur={saveTitle} onKeyDown={(e) => e.key === 'Enter' && (e.target as HTMLInputElement).blur()} className="min-w-0 flex-1 bg-transparent font-display text-3xl font-bold outline-none" aria-label="Page title" />
|
||||
<span className="text-xs text-muted-foreground">{saveState === 'saving' ? 'Saving…' : saveState === 'dirty' ? 'Unsaved' : saveState === 'conflict' ? 'Conflict' : 'Saved'}</span>
|
||||
{saveState === 'dirty' && <Button size="sm" variant="outline" onClick={() => autosave.flush(page.id)}>Save now</Button>}
|
||||
<Button variant="ghost" size="icon-sm" title={mode === 'edit' ? 'Preview' : 'Edit'} onClick={() => { setMode(mode === 'edit' ? 'preview' : 'edit'); setSlash(null) }}>{mode === 'edit' ? <Eye className="h-4 w-4" /> : <Pencil className="h-4 w-4" />}</Button>
|
||||
<Button variant="ghost" size="icon-sm" title={page.pinned ? 'Unpin' : 'Pin to sidebar'} onClick={togglePin}>{page.pinned ? <PinOff className="h-4 w-4" /> : <Pin className="h-4 w-4" />}</Button>
|
||||
<Button variant="ghost" size="icon-sm" title="Move tab left" onClick={() => move(-1)}><ChevronLeft className="h-4 w-4" /></Button>
|
||||
<Button variant="ghost" size="icon-sm" title="Move tab right" onClick={() => move(1)}><ChevronRight className="h-4 w-4" /></Button>
|
||||
<Button variant="ghost" size="icon-sm" title="Download .md" onClick={download}><Download className="h-4 w-4" /></Button>
|
||||
<Button variant="ghost" size="icon-sm" title="Delete page" onClick={remove}><Trash2 className="h-4 w-4 text-destructive" /></Button>
|
||||
</div>
|
||||
|
||||
{saveState === 'conflict' && (
|
||||
<div role="alert" className="mb-4 rounded-lg border border-destructive/40 bg-destructive/5 p-4 text-sm">
|
||||
<p className="font-medium text-foreground">This page was changed elsewhere</p>
|
||||
<p className="mt-1 text-muted-foreground">
|
||||
Your local draft is based on an older version — or its base version is unknown —
|
||||
so autosave is paused and nothing is overwritten until you choose. Your draft
|
||||
stays in the editor below.
|
||||
</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<Button size="sm" variant="destructive" onClick={overwriteRemote}>Overwrite with my draft</Button>
|
||||
<Button size="sm" variant="outline" onClick={copyDraft}>Save my draft as a new page</Button>
|
||||
<Button size="sm" variant="outline" onClick={discardDraft}>Discard my draft and load latest</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mode === 'edit' ? (
|
||||
<div className="relative">
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
value={content}
|
||||
onChange={(e) => { onContentChange(e.target.value); updateSlash(e.target.value, e.target.selectionStart) }}
|
||||
onClick={(e) => updateSlash(e.currentTarget.value, e.currentTarget.selectionStart)}
|
||||
onPaste={onPaste}
|
||||
onBlur={() => setSlash(null)}
|
||||
onKeyDown={(e) => {
|
||||
// While the slash menu is open, arrows/Enter/Tab/Escape drive it.
|
||||
if (slash && slashFiltered.length > 0) {
|
||||
if (e.key === 'ArrowDown') { e.preventDefault(); setSlashIndex((i) => (i + 1) % slashFiltered.length); return }
|
||||
if (e.key === 'ArrowUp') { e.preventDefault(); setSlashIndex((i) => (i - 1 + slashFiltered.length) % slashFiltered.length); return }
|
||||
if (e.key === 'Enter' || e.key === 'Tab') { e.preventDefault(); applyBlock(slashFiltered[Math.min(slashIndex, slashFiltered.length - 1)]); return }
|
||||
if (e.key === 'Escape') { e.preventDefault(); setSlash(null); return }
|
||||
}
|
||||
// Notion Markdown shortcut: "[]" + space becomes a to-do item.
|
||||
if (e.key === ' ') {
|
||||
const ta = e.currentTarget
|
||||
const caret = ta.selectionStart
|
||||
const lineStart = ta.value.lastIndexOf('\n', caret - 1) + 1
|
||||
if (ta.value.slice(lineStart, caret) === '[]') {
|
||||
e.preventDefault()
|
||||
const next = ta.value.slice(0, lineStart) + '- [ ] ' + ta.value.slice(caret)
|
||||
pendingCaret.current = lineStart + '- [ ] '.length
|
||||
onContentChange(next)
|
||||
}
|
||||
}
|
||||
}}
|
||||
placeholder="Write Markdown notes… type / for blocks, paste links, lists, thoughts."
|
||||
className="min-h-[260px] font-mono text-sm"
|
||||
/>
|
||||
{slash && slashFiltered.length > 0 && (
|
||||
<div
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
role="listbox"
|
||||
aria-label="Blocks to insert"
|
||||
className="absolute left-2 top-full z-20 mt-1 w-72 rounded-lg border bg-popover p-1 shadow-lg"
|
||||
>
|
||||
<p className="px-2 py-1 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">Basic blocks</p>
|
||||
<div className="max-h-64 overflow-y-auto">
|
||||
{slashFiltered.map((b, i) => (
|
||||
<button
|
||||
key={b.key}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={i === slashIndex}
|
||||
onMouseEnter={() => setSlashIndex(i)}
|
||||
onClick={() => applyBlock(b)}
|
||||
className={cn('flex w-full items-center gap-2.5 rounded-md px-2 py-1.5 text-left', i === slashIndex ? 'bg-accent text-accent-foreground' : 'hover:bg-accent/50')}
|
||||
>
|
||||
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded border bg-card">
|
||||
<b.icon className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block text-sm font-medium">{b.label}</span>
|
||||
<span className="block truncate text-xs text-muted-foreground">{b.desc}</span>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="prose prose-sm max-w-none dark:prose-invert min-h-[80px] cursor-text" onDoubleClick={() => setMode('edit')}>
|
||||
{content ? (
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
p: ({ node, ...props }) => {
|
||||
const link = standaloneLink(node)
|
||||
return link ? (
|
||||
<LinkPreviewCard url={link.href} label={link.label !== link.href ? link.label : undefined} />
|
||||
) : (
|
||||
<p {...props} />
|
||||
)
|
||||
},
|
||||
a: ({ node, ...props }) => <a {...props} target="_blank" rel="noopener noreferrer" />,
|
||||
}}
|
||||
>{content}</ReactMarkdown>
|
||||
) : (
|
||||
<p className="text-muted-foreground">No notes yet. Double-click or press the pencil to write.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-10 mb-3 flex items-center justify-between">
|
||||
<h3 className="font-display text-lg font-semibold">Links on this page <span className="text-sm font-normal text-muted-foreground">({page.bookmarks.length})</span></h3>
|
||||
<Popover open={linkOpen} onOpenChange={setLinkOpen}>
|
||||
<PopoverTrigger asChild><Button size="sm" variant="outline"><Link2 className="mr-1 h-4 w-4" />Add link</Button></PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-80 p-2">
|
||||
<Input autoFocus value={linkQuery} onChange={(e) => setLinkQuery(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && looksLikeUrl && addUrl()} placeholder="Search bookmarks or paste a URL" className="h-8 text-sm" />
|
||||
<div className="mt-2 max-h-64 overflow-y-auto">
|
||||
{looksLikeUrl && (
|
||||
<button onClick={addUrl} className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-sm hover:bg-muted"><Plus className="h-3.5 w-3.5" />Save & add “{linkQuery.trim()}”</button>
|
||||
)}
|
||||
{results.filter((r) => !page.bookmarks.some((x) => x.bookmark.id === r.id)).map((r) => (
|
||||
<button key={r.id} onClick={() => addBookmark(r.id)} className="flex w-full flex-col rounded px-2 py-1.5 text-left hover:bg-muted">
|
||||
<span className="truncate text-sm">{r.title || r.ogTitle || r.url}</span>
|
||||
<span className="truncate text-xs text-muted-foreground">{host(r.url)}</span>
|
||||
</button>
|
||||
))}
|
||||
{!looksLikeUrl && results.length === 0 && <p className="px-2 py-3 text-center text-xs text-muted-foreground">No matching bookmarks</p>}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
{page.bookmarks.length === 0 ? (
|
||||
<div className="rounded-lg bg-muted/40 p-8 text-center text-sm text-muted-foreground">No links yet. Add existing bookmarks or paste a new URL.</div>
|
||||
) : (
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{page.bookmarks.map(({ bookmark: b }) => (
|
||||
<div key={b.id} className="group relative flex gap-3 rounded-lg bg-card p-3 shadow-sm ring-1 ring-border/50 transition-shadow hover:shadow-md">
|
||||
{b.ogImage ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={b.ogImage} alt={`Preview of ${b.title || host(b.url)}`} referrerPolicy="no-referrer" className="h-16 w-24 shrink-0 rounded object-cover bg-muted" onError={(e) => { (e.target as HTMLImageElement).style.display = 'none' }} />
|
||||
) : null}
|
||||
<a href={b.url} target="_blank" rel="noopener noreferrer" className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{b.favicon && (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={b.favicon} alt="" referrerPolicy="no-referrer" className="h-3.5 w-3.5" onError={(e) => { (e.target as HTMLImageElement).style.display = 'none' }} />
|
||||
)}
|
||||
<span className="truncate text-sm font-medium">{b.title || b.ogTitle || b.url}</span>
|
||||
</div>
|
||||
<p className="mt-0.5 line-clamp-2 text-xs text-muted-foreground">{b.description || b.ogDescription || ''}</p>
|
||||
<p className="mt-1 text-[11px] text-muted-foreground/80">{host(b.url)}</p>
|
||||
</a>
|
||||
<button onClick={() => removeBookmark(b.id)} title="Remove from page" className="absolute right-1.5 top-1.5 rounded p-1 text-muted-foreground opacity-0 transition-opacity hover:bg-muted group-hover:opacity-100"><X className="h-3.5 w-3.5" /></button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { PagesClient } from './client'
|
||||
|
||||
export default async function PagesPage({ searchParams }: { searchParams: Promise<{ id?: string }> }) {
|
||||
const { id } = await searchParams
|
||||
return <PagesClient initialId={id ?? null} />
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useSession } from 'next-auth/react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
|
||||
import { User, Mail, Server, Cloud, Shield, Check, X, AlertTriangle } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { invalidateReference } from '@/lib/reference-data'
|
||||
import type { OidcStatus } from '@/lib/oidc-config'
|
||||
|
||||
export function SettingsClient({ oidc, callbackUrl }: { oidc: OidcStatus; callbackUrl: string }) {
|
||||
const { data: session } = useSession()
|
||||
const [imapConfig, setImapConfig] = useState({ host: '', port: 993, tls: true, username: '', password: '', folder: 'INBOX' })
|
||||
const [ncConfig, setNcConfig] = useState({ nextcloudUrl: '', cardDavPath: '', username: '', password: '' })
|
||||
const [savingImap, setSavingImap] = useState(false)
|
||||
const [testingImap, setTestingImap] = useState(false)
|
||||
const [syncingContacts, setSyncingContacts] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/settings/imap').then(r => r.json()).then(data => {
|
||||
if (data) setImapConfig(prev => ({ ...prev, host: data.host ?? '', port: data.port ?? 993, tls: data.tls ?? true, username: data.username ?? '', folder: data.folder ?? 'INBOX' }))
|
||||
}).catch(() => {})
|
||||
}, [])
|
||||
|
||||
// Every action clears its spinner in finally and tolerates non-JSON or
|
||||
// failed network responses, so no state can stick after an outage.
|
||||
const saveImap = async () => {
|
||||
setSavingImap(true)
|
||||
try {
|
||||
const res = await fetch('/api/settings/imap', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
// tls is forced on: legacy stored configs with tls=false migrate to
|
||||
// verified TLS on the next save (review NET-02).
|
||||
body: JSON.stringify({ ...imapConfig, tls: true }),
|
||||
})
|
||||
if (res.ok) toast.success('IMAP settings saved')
|
||||
else toast.error((await res.json().catch(() => null))?.error ?? 'Failed to save')
|
||||
} catch { toast.error('Failed to save') }
|
||||
finally { setSavingImap(false) }
|
||||
}
|
||||
|
||||
const testImap = async () => {
|
||||
setTestingImap(true)
|
||||
try {
|
||||
const res = await fetch('/api/imap/test', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ...imapConfig, tls: true }),
|
||||
})
|
||||
const data = await res.json().catch(() => null)
|
||||
if (res.ok && data?.ok) toast.success('Connection successful!')
|
||||
else toast.error(data?.error ?? 'Connection failed')
|
||||
} catch { toast.error('Connection failed') }
|
||||
finally { setTestingImap(false) }
|
||||
}
|
||||
|
||||
const syncContacts = async () => {
|
||||
if (!ncConfig.nextcloudUrl || !ncConfig.username || !ncConfig.password) {
|
||||
toast.error('Fill in Nextcloud URL, username, and password')
|
||||
return
|
||||
}
|
||||
setSyncingContacts(true)
|
||||
try {
|
||||
const res = await fetch('/api/contacts/sync', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(ncConfig),
|
||||
})
|
||||
const data = await res.json().catch(() => null)
|
||||
if (res.ok && data?.synced !== undefined) {
|
||||
// Review CR-07: the sync mirrors the address book — remotely deleted
|
||||
// contacts are removed too (scoped to this source, never from partial
|
||||
// responses). Surface both counts so the policy is visible.
|
||||
const removed = typeof data.removed === 'number' && data.removed > 0 ? `, removed ${data.removed} deleted on the server` : ''
|
||||
toast.success(`Synced ${data.synced} contacts${removed}`)
|
||||
invalidateReference('contacts')
|
||||
}
|
||||
else toast.error(data?.error ?? 'Sync failed')
|
||||
} catch { toast.error('Sync failed') }
|
||||
finally { setSyncingContacts(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl space-y-6">
|
||||
<div>
|
||||
<h1 className="font-display text-2xl font-bold tracking-tight">Settings</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">Configure your integrations and preferences</p>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="profile" className="w-full">
|
||||
<TabsList className="w-full justify-start">
|
||||
<TabsTrigger value="profile" className="gap-1.5"><User className="h-3.5 w-3.5" /> Profile</TabsTrigger>
|
||||
<TabsTrigger value="imap" className="gap-1.5"><Mail className="h-3.5 w-3.5" /> IMAP</TabsTrigger>
|
||||
<TabsTrigger value="nextcloud" className="gap-1.5"><Cloud className="h-3.5 w-3.5" /> Nextcloud</TabsTrigger>
|
||||
<TabsTrigger value="oidc" className="gap-1.5"><Shield className="h-3.5 w-3.5" /> OIDC</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="profile" className="space-y-4 mt-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="h-16 w-16 rounded-full bg-primary/20 flex items-center justify-center text-2xl font-bold text-primary">
|
||||
{(session?.user?.name ?? session?.user?.email ?? '?')[0]?.toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">{session?.user?.name ?? 'User'}</p>
|
||||
<p className="text-sm text-muted-foreground" suppressHydrationWarning>{session?.user?.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="imap" className="space-y-4 mt-4">
|
||||
<div className="rounded-lg border p-4 space-y-3">
|
||||
<h3 className="font-medium flex items-center gap-2"><Server className="h-4 w-4" /> IMAP Configuration</h3>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-xs font-medium text-muted-foreground">Host</label>
|
||||
<Input value={imapConfig.host} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setImapConfig(p => ({ ...p, host: e.target.value }))} placeholder="imap.example.com" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium text-muted-foreground">Port</label>
|
||||
<Input type="number" value={imapConfig.port} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setImapConfig(p => ({ ...p, port: parseInt(e.target.value) || 993 }))} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium text-muted-foreground">Username</label>
|
||||
<Input value={imapConfig.username} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setImapConfig(p => ({ ...p, username: e.target.value }))} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium text-muted-foreground">Password</label>
|
||||
<Input type="password" value={imapConfig.password} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setImapConfig(p => ({ ...p, password: e.target.value }))} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium text-muted-foreground">Folder</label>
|
||||
<Input value={imapConfig.folder} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setImapConfig(p => ({ ...p, folder: e.target.value }))} />
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
{/* Review NET-02: mailbox logins always run over verified TLS;
|
||||
the backend rejects insecure settings, so there is no toggle. */}
|
||||
<p className="text-sm text-muted-foreground">TLS is required (implicit TLS, port 993 by default)</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={testImap} variant="outline" loading={testingImap}>Test Connection</Button>
|
||||
<Button onClick={saveImap} loading={savingImap}>Save IMAP Settings</Button>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="nextcloud" className="space-y-4 mt-4">
|
||||
<div className="rounded-lg border p-4 space-y-3">
|
||||
<h3 className="font-medium flex items-center gap-2"><Cloud className="h-4 w-4" /> Nextcloud / CardDAV</h3>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="col-span-2">
|
||||
<label className="text-xs font-medium text-muted-foreground">Nextcloud URL</label>
|
||||
<Input value={ncConfig.nextcloudUrl} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setNcConfig(p => ({ ...p, nextcloudUrl: e.target.value }))} placeholder="https://nextcloud.example.com" />
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label className="text-xs font-medium text-muted-foreground">CardDAV Path (optional)</label>
|
||||
<Input value={ncConfig.cardDavPath} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setNcConfig(p => ({ ...p, cardDavPath: e.target.value }))} placeholder="/remote.php/dav/addressbooks/users/USER/contacts/" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium text-muted-foreground">Username</label>
|
||||
<Input value={ncConfig.username} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setNcConfig(p => ({ ...p, username: e.target.value }))} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium text-muted-foreground">App Password</label>
|
||||
<Input type="password" value={ncConfig.password} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setNcConfig(p => ({ ...p, password: e.target.value }))} />
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={syncContacts} loading={syncingContacts}>Sync Contacts Now</Button>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="oidc" className="space-y-4 mt-4">
|
||||
<div className="rounded-lg border p-4 space-y-3">
|
||||
<h3 className="font-medium flex items-center gap-2"><Shield className="h-4 w-4" /> OIDC Configuration</h3>
|
||||
<p className="text-sm text-muted-foreground">Authelia single sign-on, configured via environment variables (read-only)</p>
|
||||
<div className={`flex items-start gap-2 rounded-md p-3 text-sm ${oidc.enabled ? 'bg-green-500/10 text-green-700 dark:text-green-400' : 'bg-amber-500/10 text-amber-800 dark:text-amber-300'}`}>
|
||||
{oidc.enabled ? <Check className="h-4 w-4 mt-0.5 shrink-0" /> : <AlertTriangle className="h-4 w-4 mt-0.5 shrink-0" />}
|
||||
<span>
|
||||
{oidc.enabled
|
||||
? <>Configured for <span className="font-mono break-all">{oidc.issuer}</span>. Client integration is still pending.</>
|
||||
: <>Not connected. Authelia sign-in is disabled until all three variables below hold real values. Users can sign in with email and password meanwhile. Setup guide: <span className="font-mono">docs/AUTHELIA.md</span></>}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{([['OIDC_ISSUER', oidc.issuerSet], ['OIDC_CLIENT_ID', oidc.clientIdSet], ['OIDC_CLIENT_SECRET', oidc.clientSecretSet]] as const).map(([name, set]) => (
|
||||
<div key={name} className="flex items-center gap-2">
|
||||
<span className="text-xs font-mono bg-muted px-2 py-1 rounded">{name}</span>
|
||||
<span className="text-sm text-muted-foreground">{set ? 'Set' : 'Not set'}</span>
|
||||
{set ? <Check className="h-4 w-4 text-green-500" /> : <X className="h-4 w-4 text-muted-foreground" />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="space-y-1 pt-2 border-t">
|
||||
<p className="text-xs font-medium text-muted-foreground">Redirect URI to register in Authelia</p>
|
||||
<p className="text-xs font-mono bg-muted px-2 py-1.5 rounded break-all" suppressHydrationWarning>{callbackUrl}</p>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { SettingsClient } from './client'
|
||||
import { getOidcStatus } from '@/lib/oidc-config'
|
||||
import { headers } from 'next/headers'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export default async function SettingsPage() {
|
||||
// The redirect URI shown for Authelia registration derives from the same
|
||||
// configured origin auth.ts trusts — never from client-controllable request
|
||||
// headers in production (header poisoning could hand an admin an
|
||||
// attacker-hosted callback URL to register).
|
||||
const envUrl = process.env.NEXTAUTH_URL || process.env.AUTH_URL || ''
|
||||
let callbackUrl = '/api/auth/callback/authelia'
|
||||
try {
|
||||
if (envUrl) callbackUrl = `${new URL(envUrl).origin}/api/auth/callback/authelia`
|
||||
} catch { /* keep the relative fallback */ }
|
||||
if (!envUrl && process.env.NODE_ENV !== 'production') {
|
||||
const h = await headers()
|
||||
const host = h.get('x-forwarded-host') ?? h.get('host') ?? ''
|
||||
const proto = h.get('x-forwarded-proto') ?? 'http'
|
||||
if (host) callbackUrl = `${proto}://${host}/api/auth/callback/authelia`
|
||||
}
|
||||
return <SettingsClient oidc={getOidcStatus()} callbackUrl={callbackUrl} />
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { BookmarkCard } from '@/components/bookmark-card'
|
||||
import { BookmarkDetailSheet } from '@/components/bookmark-detail-sheet'
|
||||
import { Plus, Tags, Trash2, Edit, Check, X } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import type { Bookmark, TagWithCount } from '@/components/app-shell'
|
||||
import { fetchBookmarks } from '@/lib/bookmarks'
|
||||
import { invalidateReference } from '@/lib/reference-data'
|
||||
|
||||
export function TagsClient({ initialTag = null }: { initialTag?: string | null }) {
|
||||
const [tags, setTags] = useState<TagWithCount[]>([])
|
||||
const [selectedTag, setSelectedTag] = useState<string | null>(null)
|
||||
const [bookmarks, setBookmarks] = useState<Bookmark[]>([])
|
||||
const [selectedBookmark, setSelectedBookmark] = useState<Bookmark | null>(null)
|
||||
const [newName, setNewName] = useState('')
|
||||
const [newColor, setNewColor] = useState('#6366f1')
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
const [editName, setEditName] = useState('')
|
||||
const [editColor, setEditColor] = useState('#6366f1')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [effectiveId, setEffectiveId] = useState('')
|
||||
const [shareQuery, setShareQuery] = useState('')
|
||||
const [shareUsers, setShareUsers] = useState<any[]>([])
|
||||
useEffect(() => { fetch('/api/me').then(r => r.json()).then(me => setEffectiveId(me.effectiveUser?.id || '')).catch(() => {}) }, [])
|
||||
|
||||
// Debounced user search; failures keep the previous results silently.
|
||||
useEffect(() => {
|
||||
const q = shareQuery.trim()
|
||||
const t = setTimeout(async () => {
|
||||
if (q.length < 2) { setShareUsers([]); return }
|
||||
try {
|
||||
const res = await fetch(`/api/users/search?q=${encodeURIComponent(q)}`)
|
||||
if (res.ok) setShareUsers(await res.json())
|
||||
} catch { /* transient; retried on next keystroke */ }
|
||||
}, q.length < 2 ? 0 : 250)
|
||||
return () => clearTimeout(t)
|
||||
}, [shareQuery])
|
||||
|
||||
const loadTags = useCallback(async () => {
|
||||
// Every tag mutation reloads through here; keep the detail sheet's cache
|
||||
// in step so new/renamed tags appear in its picker immediately.
|
||||
invalidateReference('tags')
|
||||
try {
|
||||
const res = await fetch('/api/tags')
|
||||
if (res.ok) setTags(await res.json())
|
||||
} catch { toast.error('Could not load tags') }
|
||||
}, [])
|
||||
|
||||
const loadBookmarks = useCallback(async (tagId: string) => {
|
||||
setLoading(true)
|
||||
try { setBookmarks(await fetchBookmarks(new URLSearchParams({ tagId }))) }
|
||||
catch { toast.error('Could not load bookmarks') }
|
||||
finally { setLoading(false) }
|
||||
}, [])
|
||||
|
||||
useEffect(() => { const timer = setTimeout(loadTags, 0); return () => clearTimeout(timer) }, [loadTags])
|
||||
// Quick Find deep-links to a tag via /tags?tag=<id>.
|
||||
useEffect(() => {
|
||||
if (!initialTag) return
|
||||
const timer = setTimeout(() => setSelectedTag(initialTag), 0)
|
||||
return () => clearTimeout(timer)
|
||||
}, [initialTag])
|
||||
useEffect(() => { const timer = setTimeout(() => { if (selectedTag) loadBookmarks(selectedTag) }, 0); return () => clearTimeout(timer) }, [selectedTag, loadBookmarks])
|
||||
useEffect(() => { const reload = () => { loadTags(); if (selectedTag) loadBookmarks(selectedTag) }; window.addEventListener('computer:bookmarks-changed', reload); return () => window.removeEventListener('computer:bookmarks-changed', reload) }, [loadTags, loadBookmarks, selectedTag])
|
||||
|
||||
const createTag = async () => {
|
||||
if (!newName) return
|
||||
const res = await fetch('/api/tags', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: newName, color: newColor }) })
|
||||
if (res.ok) { toast.success('Tag created'); setNewName(''); loadTags() }
|
||||
else toast.error('Failed')
|
||||
}
|
||||
|
||||
const updateTag = async (id: string) => {
|
||||
const response = await fetch(`/api/tags/${id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: editName, color: editColor }) })
|
||||
if (!response.ok) { toast.error('Could not update tag'); return }
|
||||
setEditingId(null)
|
||||
loadTags()
|
||||
}
|
||||
|
||||
const deleteTag = async (id: string) => {
|
||||
if (!confirm('Delete this tag?')) return
|
||||
const response = await fetch(`/api/tags/${id}`, { method: 'DELETE' })
|
||||
if (!response.ok) { toast.error('Could not delete tag'); return }
|
||||
toast.success('Tag deleted')
|
||||
if (selectedTag === id) setSelectedTag(null)
|
||||
loadTags()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex gap-6 h-[calc(100vh-120px)]">
|
||||
<div className="w-64 shrink-0 space-y-4 overflow-y-auto">
|
||||
<h1 className="font-display text-2xl font-bold tracking-tight">Tags</h1>
|
||||
<div className="flex gap-1.5">
|
||||
<Input placeholder="New tag" value={newName} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setNewName(e.target.value)} className="flex-1" />
|
||||
<input type="color" value={newColor} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setNewColor(e.target.value)} className="h-9 w-9 rounded border cursor-pointer" />
|
||||
<Button size="icon-sm" onClick={createTag}><Plus className="h-4 w-4" /></Button>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{tags.map((t: TagWithCount) => (
|
||||
<div
|
||||
key={t.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-pressed={selectedTag === t.id}
|
||||
className={`flex items-center gap-2 rounded-lg px-3 py-2 cursor-pointer transition-colors focus-visible:outline-2 focus-visible:outline-primary ${
|
||||
selectedTag === t.id ? 'bg-accent' : 'hover:bg-muted'
|
||||
}`}
|
||||
onClick={() => setSelectedTag(t.id)}
|
||||
onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setSelectedTag(t.id) } }}
|
||||
>
|
||||
{editingId === t.id ? (
|
||||
<>
|
||||
<input type="color" value={editColor} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setEditColor(e.target.value)} className="h-5 w-5 rounded cursor-pointer" />
|
||||
<Input value={editName} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setEditName(e.target.value)} className="h-7 text-xs flex-1" />
|
||||
<button onClick={() => updateTag(t.id)}><Check className="h-3 w-3 text-green-500" /></button>
|
||||
<button onClick={() => setEditingId(null)}><X className="h-3 w-3" /></button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="h-3 w-3 rounded-full shrink-0" style={{ backgroundColor: t.color }} />
|
||||
<span className="text-sm font-medium flex-1 truncate">{t.name}</span>
|
||||
<span className="text-xs text-muted-foreground">{t._count?.bookmarks ?? 0}</span>
|
||||
{t.ownerId === effectiveId ? <><button aria-label="Edit tag" onClick={(e: React.MouseEvent) => { e.stopPropagation(); setEditingId(t.id); setEditName(t.name); setEditColor(t.color) }}><Edit className="h-3 w-3 text-muted-foreground" /></button>
|
||||
<button aria-label="Delete tag" onClick={(e: React.MouseEvent) => { e.stopPropagation(); deleteTag(t.id) }}><Trash2 className="h-3 w-3 text-muted-foreground hover:text-destructive" /></button></> : <span className="text-xs text-muted-foreground">Shared</span>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{selectedTag ? (
|
||||
<div className="space-y-4">
|
||||
<h2 className="font-medium">Bookmarks tagged: <Badge style={{ backgroundColor: `${tags.find(t => t.id === selectedTag)?.color ?? '#6366f1'}20`, color: tags.find(t => t.id === selectedTag)?.color }}>{tags.find(t => t.id === selectedTag)?.name}</Badge></h2>
|
||||
{tags.find(t => t.id === selectedTag)?.ownerId === effectiveId && <div className="space-y-2 rounded border p-3">
|
||||
<p className="text-xs text-muted-foreground">Sharing is group-wide: every recipient can see all bookmarks under this tag — including ones other recipients add.</p>
|
||||
<Input placeholder="Share this tag with a user…" value={shareQuery} onChange={e => setShareQuery(e.target.value)} />
|
||||
{shareUsers.map(u => <Button key={u.id} size="sm" variant="outline" onClick={async () => { const res = await fetch(`/api/tags/${selectedTag}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ shareUserIds: [u.id] }) }); if (!res.ok) { toast.error('Could not share tag'); return } toast.success('Tag shared'); setShareUsers([]); setShareQuery(''); loadTags() }}>Share with {u.name || u.email}</Button>)}
|
||||
{(tags.find(t => t.id === selectedTag)?.sharedWith || []).map(share => <Button key={share.userId} size="xs" variant="ghost" onClick={async () => { const res = await fetch(`/api/tags/${selectedTag}/share/${share.userId}`, { method: 'DELETE' }); if (!res.ok) { toast.error('Could not stop sharing'); return } loadTags() }}>Stop sharing with {share.user?.name || share.user?.email || `${share.userId.slice(0, 8)}…`}</Button>)}
|
||||
</div>}
|
||||
{loading ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{Array.from({ length: 6 }).map((_, i) => <div key={i} className="h-32 rounded-lg bg-muted animate-pulse" />)}
|
||||
</div>
|
||||
) : bookmarks.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-8 text-center">No bookmarks with this tag</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{bookmarks.map((b: Bookmark) => <BookmarkCard key={b.id} bookmark={b} compact onClick={() => setSelectedBookmark(b)} />)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center h-full text-center">
|
||||
<Tags className="h-12 w-12 text-muted-foreground/50 mb-4" />
|
||||
<p className="text-muted-foreground">Select a tag to view its bookmarks</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<BookmarkDetailSheet bookmark={selectedBookmark} onClose={() => setSelectedBookmark(null)} onUpdate={() => { if (selectedTag) loadBookmarks(selectedTag) }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { TagsClient } from './client'
|
||||
|
||||
// Quick Find deep-links to a tag via /tags?tag=<id>.
|
||||
export default async function TagsPage({ searchParams }: { searchParams: Promise<{ tag?: string }> }) {
|
||||
const { tag } = await searchParams
|
||||
return <TagsClient initialTag={tag ?? null} />
|
||||
}
|
||||
Reference in New Issue
Block a user