'use client' import { useState, useEffect, useCallback, useRef } from 'react' import { useSession } from 'next-auth/react' import { usePathname, useRouter } from 'next/navigation' import { Sidebar } from '@/components/sidebar' import { TopBar } from '@/components/top-bar' import { AddBookmarkDialog } from '@/components/add-bookmark-dialog' import { QuickFind } from '@/components/quick-find' import { resetReferenceCache } from '@/lib/reference-data' export type Bookmark = { id: string url: string title: string | null description: string | null favicon: string | null ogImage: string | null ogTitle: string | null ogDescription: string | null notes: string | null dueDate: string | null // Owner-only fields (review PRIV-02): shared readers receive a narrowed // projection without contacts, recipients, alerts or mailbox watches. alertAt?: string | null alertSent?: boolean kanbanStatus: string kanbanColumnId?: string | null // Owner-only board order within a column. Shared-reader projections omit it. kanbanPosition?: number imapWatchEnabled?: boolean imapQuery?: string | null isPublic: boolean ownerId: string contactId?: string | null // Optimistic concurrency revision (review CR-04): every owner-driven PUT // must send the version it edited from; 409 means a newer edit exists. version?: number contact?: any | null tags: { tag: { id: string; name: string; color: string; ownerId?: string } }[] sharedWith?: any[] owner?: any createdAt: string updatedAt: string } export type TagWithCount = { id: string name: string color: string ownerId: string _count: { bookmarks: number } sharedWith?: any[] } export function AppShell({ children }: { children: React.ReactNode }) { const { data: session } = useSession() const router = useRouter() const pathname = usePathname() const [sidebarOpen, setSidebarOpen] = useState(true) const [addDialogOpen, setAddDialogOpen] = useState(false) const [quickFindOpen, setQuickFindOpen] = useState(false) const [alerts, setAlerts] = useState([]) const [me, setMe] = useState<{ isAdmin: boolean; viewingAs: { name: string | null; email: string } | null } | null>(null) useEffect(() => { if (!session?.user) return fetch('/api/me') .then((r) => (r.ok ? r.json() : null)) .then(setMe) .catch((e) => console.error('Failed to load account info', e)) }, [session?.user]) const exitViewAs = useCallback(async () => { try { await fetch('/api/admin/view-as', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ userId: null }) }) } catch { /* navigating to /admin surfaces any persistent failure */ } // Review L-6: leaving view-as restores the admin identity. resetReferenceCache() window.location.href = '/admin' }, []) // Poll alerts. The API returns every un-dismissed alert on each poll, so // track already-notified IDs per tab to avoid re-firing every 5 minutes. const notifiedAlerts = useRef>(new Set()) useEffect(() => { if (!session?.user) return const check = async () => { try { const res = await fetch('/api/alerts/check') if (res.ok) { const data = await res.json() setAlerts(data ?? []) if (data?.length && typeof Notification !== 'undefined' && Notification.permission === 'granted') { for (const a of data) { const id = String(a?.id ?? a?.bookmarkId ?? '') if (!id || notifiedAlerts.current.has(id)) continue notifiedAlerts.current.add(id) new Notification('computer alert', { body: `${a?.title ?? 'Bookmark'} is due!`, icon: '/favicon.svg' }) } } } } catch { /* ignore */ } } check() const interval = setInterval(check, 5 * 60 * 1000) return () => clearInterval(interval) }, [session?.user]) // Review L-5: notification permission is requested from the alert bell on // an explicit user action (top-bar.tsx), not on every app mount. const refreshPage = useCallback(() => { window.dispatchEvent(new Event('computer:bookmarks-changed')) router.refresh() }, [router]) const dismissAlert = useCallback(async (bookmarkId: string) => { try { const res = await fetch(`/api/alerts/${bookmarkId}`, { method: 'DELETE' }) if (!res.ok) return } catch { return } setAlerts((prev: any[]) => (prev ?? []).filter((a: any) => a?.id !== bookmarkId)) }, []) if (!session?.user) return null return (
setSidebarOpen(!sidebarOpen)} onQuickFind={() => setQuickFindOpen(true)} pathname={pathname} isAdmin={!!me?.isAdmin} />
{me?.viewingAs && (
Admin view — viewing as {me.viewingAs.name || me.viewingAs.email}
)} setAddDialogOpen(true)} onToggleSidebar={() => setSidebarOpen(!sidebarOpen)} onQuickFind={() => setQuickFindOpen(true)} />
{children}
setAddDialogOpen(false)} onCreated={() => { setAddDialogOpen(false); refreshPage() }} /> setAddDialogOpen(true)} />
) }