165 lines
5.9 KiB
TypeScript
165 lines
5.9 KiB
TypeScript
'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'
|
|
|
|
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<any[]>([])
|
|
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 */ }
|
|
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<Set<string>>(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])
|
|
|
|
// Request notification permission
|
|
useEffect(() => {
|
|
if (typeof Notification !== 'undefined' && Notification.permission === 'default') {
|
|
Notification.requestPermission()
|
|
}
|
|
}, [])
|
|
|
|
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 (
|
|
<div className="flex h-screen overflow-hidden bg-background">
|
|
<Sidebar open={sidebarOpen} onToggle={() => setSidebarOpen(!sidebarOpen)} onQuickFind={() => setQuickFindOpen(true)} pathname={pathname} isAdmin={!!me?.isAdmin} />
|
|
<div className="flex flex-1 flex-col overflow-hidden">
|
|
{me?.viewingAs && (
|
|
<div className="flex items-center justify-center gap-3 bg-amber-500 px-4 py-1.5 text-sm font-medium text-amber-950">
|
|
<span>Admin view — viewing as {me.viewingAs.name || me.viewingAs.email}</span>
|
|
<button onClick={exitViewAs} className="rounded bg-amber-950/15 px-2 py-0.5 text-xs font-semibold hover:bg-amber-950/25">Exit</button>
|
|
</div>
|
|
)}
|
|
<TopBar
|
|
user={session.user as any}
|
|
alerts={alerts}
|
|
onDismissAlert={dismissAlert}
|
|
onAddBookmark={() => setAddDialogOpen(true)}
|
|
onToggleSidebar={() => setSidebarOpen(!sidebarOpen)}
|
|
onQuickFind={() => setQuickFindOpen(true)}
|
|
/>
|
|
<main className="flex-1 overflow-auto p-6">
|
|
{children}
|
|
</main>
|
|
</div>
|
|
<AddBookmarkDialog
|
|
open={addDialogOpen}
|
|
onClose={() => setAddDialogOpen(false)}
|
|
onCreated={() => { setAddDialogOpen(false); refreshPage() }}
|
|
/>
|
|
<QuickFind
|
|
open={quickFindOpen}
|
|
onOpenChange={setQuickFindOpen}
|
|
isAdmin={!!me?.isAdmin}
|
|
onAddBookmark={() => setAddDialogOpen(true)}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|