Wire BookNest to real bookmarks API, removing mock data

Bookmarks, categories, favorites, quick access, recently added, link
health, and category breakdown are now all derived from real backend
data instead of hardcoded arrays. Adds an Add Bookmark modal (with
inline new-category creation) and a working favorite toggle, both
backed by the existing /api/bookmarks endpoints. Adds
createBookmarkCategory/updateBookmark to the API client.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BbJV5nm8KPVH1oNJYKpnoF
This commit is contained in:
Claude 2026-06-18 19:33:26 +00:00
parent 5c1fc911c9
commit b49f8ac8f5
No known key found for this signature in database
2 changed files with 384 additions and 211 deletions

View file

@ -60,8 +60,12 @@ export const api = {
listBookmarks: () => apiFetch<{ bookmarks: Bookmark[] }>('/bookmarks'), listBookmarks: () => apiFetch<{ bookmarks: Bookmark[] }>('/bookmarks'),
listBookmarkCategories: () => apiFetch<{ categories: BookmarkCategory[] }>('/bookmarks/categories'), listBookmarkCategories: () => apiFetch<{ categories: BookmarkCategory[] }>('/bookmarks/categories'),
createBookmarkCategory: (data: { name: string; icon?: string; sortOrder?: number }) =>
apiFetch<{ id: number }>('/bookmarks/categories', { method: 'POST', body: JSON.stringify(data) }),
createBookmark: (data: { categoryId?: number | null; title: string; url: string; icon?: string; favorite?: boolean }) => createBookmark: (data: { categoryId?: number | null; title: string; url: string; icon?: string; favorite?: boolean }) =>
apiFetch<{ id: number }>('/bookmarks', { method: 'POST', body: JSON.stringify(data) }), apiFetch<{ id: number }>('/bookmarks', { method: 'POST', body: JSON.stringify(data) }),
updateBookmark: (id: number, data: Partial<{ categoryId: number | null; title: string; url: string; icon: string; favorite: boolean }>) =>
apiFetch<{ ok: boolean }>(`/bookmarks/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
deleteBookmark: (id: number) => apiFetch<void>(`/bookmarks/${id}`, { method: 'DELETE' }), deleteBookmark: (id: number) => apiFetch<void>(`/bookmarks/${id}`, { method: 'DELETE' }),
} }

View file

@ -1,4 +1,4 @@
import { useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { PieChart, Pie, Cell, ResponsiveContainer } from 'recharts' import { PieChart, Pie, Cell, ResponsiveContainer } from 'recharts'
import { import {
Link2, Link2,
@ -38,149 +38,61 @@ import {
Zap, Zap,
Globe2, Globe2,
Container, Container,
X,
type LucideIcon,
} from 'lucide-react' } from 'lucide-react'
import { api, ApiError, type Bookmark, type BookmarkCategory } from '../lib/api'
const quickAccess = [ const ICONS: Record<string, LucideIcon> = {
{ label: 'Infrastructure', icons: [Server, Cloud, Box, Shield, Container], count: 5 }, server: Server,
{ label: 'Development', icons: [GitBranch, GitFork, FileCode, TerminalIcon, Container], count: 5 }, bot: Bot,
{ label: 'AI Tools', icons: [Bot, Sparkles, MessageSquare, Zap, Bot], count: 5 }, cloud: Cloud,
{ label: 'AWS', icons: [Cloud, Database, Server, Shield, Globe2], count: 5 }, network: Network,
{ label: 'Networking', icons: [Network, Wifi, Router, Shield, Globe2], count: 5 }, gitbranch: GitBranch,
] gitfork: GitFork,
box: Box,
terminal: TerminalIcon,
database: Database,
shield: Shield,
workflow: Workflow,
filecode: FileCode,
router: Router,
wifi: Wifi,
bookopen: BookOpen,
graduationcap: GraduationCap,
squareplay: SquarePlay,
briefcase: Briefcase,
wallet: Wallet,
creditcard: CreditCard,
piggybank: PiggyBank,
trendingup: TrendingUp,
calendar: Calendar,
mail: Mail,
image: Image,
harddrive: HardDrive,
filetext: FileText,
plane: Plane,
sparkles: Sparkles,
messagesquare: MessageSquare,
zap: Zap,
globe2: Globe2,
container: Container,
link2: Link2,
}
type Link = { name: string; icon: typeof Link2; favorited?: boolean } function resolveIcon(name: string | null | undefined): LucideIcon {
if (!name) return Link2
return ICONS[name.toLowerCase()] ?? Link2
}
const groups: { title: string; links: Link[] }[] = [ const statusColors: Record<string, string> = {
{ online: '#2ECC71',
title: 'Infrastructure & Self Hosted', warning: '#E67E22',
links: [ offline: '#E74C3C',
{ name: 'CasaOS', icon: Server }, unknown: '#7A7D85',
{ name: 'Proxmox (pve1)', icon: Box, favorited: true }, }
{ name: 'Proxmox (mtr)', icon: Box },
{ name: 'Portainer', icon: Container },
{ name: 'Cockpit', icon: Server },
{ name: 'Cloudflare', icon: Cloud },
{ name: 'NPM', icon: Network },
{ name: 'NetBird', icon: Wifi, favorited: true },
{ name: 'Linode', icon: Cloud },
{ name: 'RackNerd', icon: Server },
],
},
{
title: 'Development & Code',
links: [
{ name: 'GitHub', icon: GitBranch, favorited: true },
{ name: 'Gitea', icon: GitFork },
{ name: 'Trilium Notes', icon: FileText },
{ name: 'VSCode Web', icon: FileCode },
{ name: 'Docker Hub', icon: Container },
{ name: 'GitLab', icon: GitFork },
{ name: 'Terraform Registry', icon: Workflow },
{ name: 'Ansible Galaxy', icon: Workflow },
],
},
{
title: 'Lab & Networking',
links: [
{ name: 'GNS3', icon: Network },
{ name: 'EVE-NG', icon: Network },
{ name: 'OpenClaw', icon: Shield },
{ name: 'NetBird', icon: Wifi, favorited: true },
{ name: 'Cisco Docs', icon: BookOpen },
{ name: 'Juniper Docs', icon: BookOpen },
{ name: 'Wireshark', icon: Database },
{ name: 'iPerf3', icon: Zap },
],
},
{
title: 'AWS',
links: [
{ name: 'AWS Console', icon: Cloud, favorited: true },
{ name: 'IAM', icon: Shield },
{ name: 'EC2', icon: Server },
{ name: 'S3', icon: Database },
{ name: 'CloudFormation', icon: Workflow },
{ name: 'Route 53', icon: Globe2 },
{ name: 'VPC', icon: Network },
{ name: 'Billing', icon: CreditCard },
],
},
{
title: 'AI Tools',
links: [
{ name: 'ChatGPT', icon: Bot, favorited: true },
{ name: 'Claude', icon: Sparkles },
{ name: 'Gemini', icon: Sparkles },
{ name: 'PartyRock', icon: Zap },
{ name: 'Perplexity', icon: MessageSquare },
{ name: 'OpenWebUI', icon: Bot },
{ name: 'Ollama', icon: Bot },
],
},
{
title: 'Learning',
links: [
{ name: 'WGU', icon: GraduationCap },
{ name: 'Udemy', icon: BookOpen },
{ name: 'AWS Skill Builder', icon: GraduationCap },
{ name: 'YouTube', icon: SquarePlay },
{ name: 'LinkedIn Learning', icon: Briefcase },
{ name: 'Coursera', icon: GraduationCap },
],
},
{
title: 'Finance',
links: [
{ name: 'Bank', icon: Wallet },
{ name: 'Budget', icon: PiggyBank },
{ name: 'Investments', icon: TrendingUp },
{ name: 'Retirement', icon: PiggyBank },
{ name: 'Credit Cards', icon: CreditCard },
],
},
{
title: 'Life',
links: [
{ name: 'Calendar', icon: Calendar },
{ name: 'Email', icon: Mail },
{ name: 'Photos', icon: Image },
{ name: 'Drive', icon: HardDrive },
{ name: 'Notes', icon: FileText },
{ name: 'Travel', icon: Plane },
],
},
]
const favorites = [ const categoryPalette = ['#C8A434', '#3B82F6', '#2ECC71', '#E67E22', '#7A7D85', '#8B5E3C', '#9B59B6', '#E74C3C']
{ name: 'Proxmox (pve1)', icon: Box },
{ name: 'GitHub', icon: GitBranch },
{ name: 'AWS Console', icon: Cloud },
{ name: 'ChatGPT', icon: Bot },
{ name: 'NetBird', icon: Wifi },
]
const recentlyUsed = [
{ name: 'Proxmox', time: '5m ago' },
{ name: 'GitHub', time: '15m ago' },
{ name: 'AWS Console', time: '1h ago' },
{ name: 'Trilium', time: '2h ago' },
{ name: 'GNS3', time: '3h ago' },
]
const linkHealthData = [
{ name: 'Online', value: 304, color: '#2ECC71' },
{ name: 'Warning', value: 6, color: '#E67E22' },
{ name: 'Offline', value: 2, color: '#E74C3C' },
]
const categoryBreakdownData = [
{ name: 'Infrastructure', value: 32, color: '#C8A434' },
{ name: 'Development', value: 24, color: '#3B82F6' },
{ name: 'AI Tools', value: 18, color: '#2ECC71' },
{ name: 'Learning', value: 10, color: '#E67E22' },
{ name: 'Finance', value: 8, color: '#7A7D85' },
{ name: 'Life', value: 8, color: '#8B5E3C' },
]
const cardBase: React.CSSProperties = { const cardBase: React.CSSProperties = {
backgroundColor: 'rgba(10, 10, 12, 0.92)', backgroundColor: 'rgba(10, 10, 12, 0.92)',
@ -204,19 +116,33 @@ const sectionTitle: React.CSSProperties = {
marginBottom: '14px', marginBottom: '14px',
} }
const inputStyle: React.CSSProperties = {
backgroundColor: 'rgba(255,255,255,0.03)',
border: '1px solid rgba(200,164,52,0.12)',
borderRadius: '8px',
padding: '9px 12px',
fontSize: '13px',
color: '#E8E6E0',
width: '100%',
outline: 'none',
}
function Donut({ data, centerLabel }: { data: { name: string; value: number; color: string }[]; centerLabel?: string }) { function Donut({ data, centerLabel }: { data: { name: string; value: number; color: string }[]; centerLabel?: string }) {
const hasData = data.some((d) => d.value > 0)
return ( return (
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="relative" style={{ width: '88px', height: '88px', flexShrink: 0 }}> <div className="relative" style={{ width: '88px', height: '88px', flexShrink: 0 }}>
<ResponsiveContainer width="100%" height="100%"> {hasData && (
<PieChart> <ResponsiveContainer width="100%" height="100%">
<Pie data={data} dataKey="value" innerRadius={28} outerRadius={42} paddingAngle={2} isAnimationActive animationDuration={1000}> <PieChart>
{data.map((entry) => ( <Pie data={data} dataKey="value" innerRadius={28} outerRadius={42} paddingAngle={2} isAnimationActive animationDuration={1000}>
<Cell key={entry.name} fill={entry.color} stroke="none" /> {data.map((entry) => (
))} <Cell key={entry.name} fill={entry.color} stroke="none" />
</Pie> ))}
</PieChart> </Pie>
</ResponsiveContainer> </PieChart>
</ResponsiveContainer>
)}
{centerLabel && ( {centerLabel && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none"> <div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<span style={{ fontSize: '14px', fontWeight: 700, color: '#E8E6E0' }}>{centerLabel}</span> <span style={{ fontSize: '14px', fontWeight: 700, color: '#E8E6E0' }}>{centerLabel}</span>
@ -236,48 +162,276 @@ function Donut({ data, centerLabel }: { data: { name: string; value: number; col
) )
} }
function LinkRow({ link, favorited, onToggle }: { link: Link; favorited: boolean; onToggle: () => void }) { function LinkRow({ bookmark, onToggleFavorite }: { bookmark: Bookmark; onToggleFavorite: () => void }) {
const Icon = link.icon const Icon = resolveIcon(bookmark.icon)
return ( return (
<div className="flex items-center justify-between group" style={{ padding: '6px 0' }}> <div className="flex items-center justify-between group" style={{ padding: '6px 0' }}>
<div className="flex items-center gap-2.5 cursor-pointer" style={{ minWidth: 0 }}> <a
href={bookmark.url}
target="_blank"
rel="noreferrer"
className="flex items-center gap-2.5 cursor-pointer"
style={{ minWidth: 0, textDecoration: 'none' }}
>
<Icon size={16} style={{ color: '#C8A434', flexShrink: 0 }} /> <Icon size={16} style={{ color: '#C8A434', flexShrink: 0 }} />
<span style={{ fontSize: '13px', color: '#E8E6E0', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{link.name}</span> <span style={{ fontSize: '13px', color: '#E8E6E0', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{bookmark.title}</span>
</div> </a>
<button <button
onClick={onToggle} onClick={onToggleFavorite}
className="cursor-pointer bg-transparent border-none p-0 flex items-center" className="cursor-pointer bg-transparent border-none p-0 flex items-center"
style={{ flexShrink: 0 }} style={{ flexShrink: 0 }}
> >
<Star size={14} fill={favorited ? '#C8A434' : 'none'} style={{ color: favorited ? '#C8A434' : '#4A4D55' }} /> <Star size={14} fill={bookmark.favorite ? '#C8A434' : 'none'} style={{ color: bookmark.favorite ? '#C8A434' : '#4A4D55' }} />
</button> </button>
</div> </div>
) )
} }
function AddBookmarkModal({
categories,
onClose,
onCreated,
onCategoryCreated,
}: {
categories: BookmarkCategory[]
onClose: () => void
onCreated: (bookmark: Bookmark) => void
onCategoryCreated: (category: BookmarkCategory) => void
}) {
const [title, setTitle] = useState('')
const [url, setUrl] = useState('')
const [icon, setIcon] = useState('link2')
const [categoryId, setCategoryId] = useState<number | 'new' | ''>('')
const [newCategoryName, setNewCategoryName] = useState('')
const [error, setError] = useState('')
const [busy, setBusy] = useState(false)
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setError('')
if (!title.trim() || !url.trim()) {
setError('Title and URL are required')
return
}
setBusy(true)
try {
let resolvedCategoryId: number | null = null
if (categoryId === 'new') {
if (!newCategoryName.trim()) {
setError('Enter a name for the new category')
setBusy(false)
return
}
const { id } = await api.createBookmarkCategory({ name: newCategoryName.trim() })
resolvedCategoryId = id
onCategoryCreated({ id, name: newCategoryName.trim(), icon: null, sort_order: 0 })
} else if (categoryId !== '') {
resolvedCategoryId = categoryId
}
const { id } = await api.createBookmark({
title: title.trim(),
url: url.trim(),
icon,
categoryId: resolvedCategoryId,
})
onCreated({
id,
category_id: resolvedCategoryId,
title: title.trim(),
url: url.trim(),
icon,
favorite: 0,
status: 'unknown',
last_checked_at: null,
created_at: new Date().toISOString(),
})
onClose()
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Failed to create bookmark')
} finally {
setBusy(false)
}
}
return (
<div
className="fixed inset-0 flex items-center justify-center"
style={{ backgroundColor: 'rgba(0,0,0,0.6)', zIndex: 50 }}
onClick={onClose}
>
<form
onClick={(e) => e.stopPropagation()}
onSubmit={handleSubmit}
style={{ ...cardBase, width: '420px', padding: '24px', gap: '14px' }}
>
<div className="flex items-center justify-between" style={{ marginBottom: '4px' }}>
<h3 style={{ fontSize: '14px', color: '#E8E6E0', fontWeight: 600 }}>Add Bookmark</h3>
<button type="button" onClick={onClose} className="cursor-pointer bg-transparent border-none p-0">
<X size={16} style={{ color: '#7A7D85' }} />
</button>
</div>
<div className="flex flex-col gap-1.5">
<label style={{ fontSize: '11px', color: '#7A7D85' }}>Title</label>
<input style={inputStyle} value={title} onChange={(e) => setTitle(e.target.value)} placeholder="e.g. Proxmox" />
</div>
<div className="flex flex-col gap-1.5">
<label style={{ fontSize: '11px', color: '#7A7D85' }}>URL</label>
<input style={inputStyle} value={url} onChange={(e) => setUrl(e.target.value)} placeholder="https://..." />
</div>
<div className="flex flex-col gap-1.5">
<label style={{ fontSize: '11px', color: '#7A7D85' }}>Icon</label>
<select style={inputStyle} value={icon} onChange={(e) => setIcon(e.target.value)}>
{Object.keys(ICONS).map((key) => (
<option key={key} value={key}>
{key}
</option>
))}
</select>
</div>
<div className="flex flex-col gap-1.5">
<label style={{ fontSize: '11px', color: '#7A7D85' }}>Category</label>
<select
style={inputStyle}
value={categoryId}
onChange={(e) => setCategoryId(e.target.value === 'new' ? 'new' : e.target.value === '' ? '' : Number(e.target.value))}
>
<option value="">Uncategorized</option>
{categories.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
<option value="new">+ New category</option>
</select>
</div>
{categoryId === 'new' && (
<div className="flex flex-col gap-1.5">
<label style={{ fontSize: '11px', color: '#7A7D85' }}>New category name</label>
<input style={inputStyle} value={newCategoryName} onChange={(e) => setNewCategoryName(e.target.value)} placeholder="e.g. Monitoring" />
</div>
)}
{error && <p style={{ fontSize: '12px', color: '#E74C3C' }}>{error}</p>}
<button
type="submit"
disabled={busy}
className="cursor-pointer transition-colors"
style={{
fontSize: '12px',
fontWeight: 600,
color: '#0A0B0D',
backgroundColor: '#C8A434',
border: 'none',
borderRadius: '8px',
padding: '10px 14px',
opacity: busy ? 0.6 : 1,
marginTop: '4px',
}}
>
{busy ? 'Saving…' : 'Add Bookmark'}
</button>
</form>
</div>
)
}
export default function BookNest() { export default function BookNest() {
const [favoritedSet, setFavoritedSet] = useState<Set<string>>( const [bookmarks, setBookmarks] = useState<Bookmark[] | null>(null)
() => new Set(groups.flatMap((g) => g.links.filter((l) => l.favorited).map((l) => l.name))) const [categories, setCategories] = useState<BookmarkCategory[]>([])
const [showAddModal, setShowAddModal] = useState(false)
useEffect(() => {
Promise.all([api.listBookmarks(), api.listBookmarkCategories()]).then(([b, c]) => {
setBookmarks(b.bookmarks)
setCategories(c.categories)
})
}, [])
async function toggleFavorite(bookmark: Bookmark) {
const next = bookmark.favorite ? false : true
setBookmarks((prev) => prev?.map((b) => (b.id === bookmark.id ? { ...b, favorite: next ? 1 : 0 } : b)) ?? prev)
try {
await api.updateBookmark(bookmark.id, { favorite: next })
} catch {
setBookmarks((prev) => prev?.map((b) => (b.id === bookmark.id ? { ...b, favorite: bookmark.favorite } : b)) ?? prev)
}
}
const groups = useMemo(() => {
if (!bookmarks) return []
const byCategory = new Map<number | null, Bookmark[]>()
for (const b of bookmarks) {
const key = b.category_id
if (!byCategory.has(key)) byCategory.set(key, [])
byCategory.get(key)!.push(b)
}
const named = categories
.map((c) => ({ title: c.name, links: byCategory.get(c.id) ?? [] }))
.filter((g) => g.links.length > 0)
const uncategorized = byCategory.get(null) ?? []
return uncategorized.length > 0 ? [...named, { title: 'Uncategorized', links: uncategorized }] : named
}, [bookmarks, categories])
const favorites = useMemo(() => (bookmarks ?? []).filter((b) => b.favorite), [bookmarks])
const recentlyAdded = useMemo(
() => [...(bookmarks ?? [])].sort((a, b) => (a.created_at < b.created_at ? 1 : -1)).slice(0, 5),
[bookmarks]
) )
function toggleFavorite(name: string) { const quickAccess = useMemo(
setFavoritedSet((prev) => { () =>
const next = new Set(prev) [...groups]
if (next.has(name)) next.delete(name) .sort((a, b) => b.links.length - a.links.length)
else next.add(name) .slice(0, 5)
return next .map((g) => ({ label: g.title, icons: g.links.slice(0, 5).map((l) => resolveIcon(l.icon)), count: g.links.length })),
}) [groups]
)
const linkHealthData = useMemo(() => {
const counts: Record<string, number> = { online: 0, warning: 0, offline: 0, unknown: 0 }
for (const b of bookmarks ?? []) counts[b.status in counts ? b.status : 'unknown']++
return Object.entries(counts)
.filter(([, value]) => value > 0)
.map(([name, value]) => ({ name: name.charAt(0).toUpperCase() + name.slice(1), value, color: statusColors[name] }))
}, [bookmarks])
const categoryBreakdownData = useMemo(
() => groups.map((g, i) => ({ name: g.title, value: g.links.length, color: categoryPalette[i % categoryPalette.length] })),
[groups]
)
if (!bookmarks) {
return (
<div style={cardBase}>
<p style={{ fontSize: '13px', color: '#7A7D85' }}>Loading bookmarks</p>
</div>
)
} }
return ( return (
<div className="flex h-full w-full flex-col overflow-y-auto" style={{ scrollbarWidth: 'none' }}> <div className="flex h-full w-full flex-col overflow-y-auto" style={{ scrollbarWidth: 'none' }}>
{showAddModal && (
<AddBookmarkModal
categories={categories}
onClose={() => setShowAddModal(false)}
onCreated={(b) => setBookmarks((prev) => [b, ...(prev ?? [])])}
onCategoryCreated={(c) => setCategories((prev) => [...prev, c])}
/>
)}
<div className="grid w-full gap-5" style={{ gridTemplateColumns: '3fr 1fr', gridTemplateRows: 'auto 1fr' }}> <div className="grid w-full gap-5" style={{ gridTemplateColumns: '3fr 1fr', gridTemplateRows: 'auto 1fr' }}>
{/* Page stats — sits directly under the hero title/subtitle, like the blueprint */} {/* Page stats — sits directly under the hero title/subtitle, like the blueprint */}
<div className="flex items-center shrink-0" style={{ gridColumn: 1, gridRow: 1, marginTop: '8px' }}> <div className="flex items-center shrink-0" style={{ gridColumn: 1, gridRow: 1, marginTop: '8px' }}>
<div className="flex items-center gap-5" style={{ fontSize: '12px', color: '#7A7D85' }}> <div className="flex items-center gap-5" style={{ fontSize: '12px', color: '#7A7D85' }}>
<span className="flex items-center gap-1.5"><Link2 size={13} style={{ color: '#C8A434' }} /> <strong style={{ color: '#E8E6E0' }}>312</strong> Links</span> <span className="flex items-center gap-1.5"><Link2 size={13} style={{ color: '#C8A434' }} /> <strong style={{ color: '#E8E6E0' }}>{bookmarks.length}</strong> Links</span>
<span className="flex items-center gap-1.5"><FolderOpen size={13} style={{ color: '#C8A434' }} /> <strong style={{ color: '#E8E6E0' }}>18</strong> Categories</span> <span className="flex items-center gap-1.5"><FolderOpen size={13} style={{ color: '#C8A434' }} /> <strong style={{ color: '#E8E6E0' }}>{categories.length}</strong> Categories</span>
<span className="flex items-center gap-1.5"><Star size={13} style={{ color: '#C8A434' }} /> <strong style={{ color: '#E8E6E0' }}>12</strong> Favorites</span> <span className="flex items-center gap-1.5"><Star size={13} style={{ color: '#C8A434' }} /> <strong style={{ color: '#E8E6E0' }}>{favorites.length}</strong> Favorites</span>
</div> </div>
</div> </div>
@ -287,6 +441,7 @@ export default function BookNest() {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h3 style={{ ...sectionTitle, marginBottom: 0, color: '#C8A434' }}>Quick Access</h3> <h3 style={{ ...sectionTitle, marginBottom: 0, color: '#C8A434' }}>Quick Access</h3>
<button <button
onClick={() => setShowAddModal(true)}
className="flex items-center gap-2 cursor-pointer transition-colors whitespace-nowrap" className="flex items-center gap-2 cursor-pointer transition-colors whitespace-nowrap"
style={{ style={{
fontSize: '12px', fontSize: '12px',
@ -303,40 +458,43 @@ export default function BookNest() {
Add Bookmark Add Bookmark
</button> </button>
</div> </div>
<div className="grid grid-cols-5 gap-4"> {quickAccess.length > 0 ? (
{quickAccess.map((qa) => ( <div className="grid grid-cols-5 gap-4">
<div key={qa.label} style={{ ...cardBase, padding: '14px' }} className="hover:!border-gold/20"> {quickAccess.map((qa) => (
<span style={{ fontSize: '11px', color: '#E8E6E0', fontWeight: 600, marginBottom: '10px' }}>{qa.label}</span> <div key={qa.label} style={{ ...cardBase, padding: '14px' }} className="hover:!border-gold/20">
<div className="flex items-center gap-1.5" style={{ marginBottom: '8px' }}> <span style={{ fontSize: '11px', color: '#E8E6E0', fontWeight: 600, marginBottom: '10px' }}>{qa.label}</span>
{qa.icons.map((Icon, i) => ( <div className="flex items-center gap-1.5" style={{ marginBottom: '8px' }}>
<div key={i} style={{ width: '22px', height: '22px', borderRadius: '6px', backgroundColor: 'rgba(200,164,52,0.08)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}> {qa.icons.map((Icon, i) => (
<Icon size={12} style={{ color: '#C8A434' }} /> <div key={i} style={{ width: '22px', height: '22px', borderRadius: '6px', backgroundColor: 'rgba(200,164,52,0.08)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
</div> <Icon size={12} style={{ color: '#C8A434' }} />
))} </div>
))}
</div>
<span style={{ fontSize: '10px', color: '#7A7D85' }}>{qa.count} links</span>
</div> </div>
<span style={{ fontSize: '10px', color: '#7A7D85' }}>{qa.count} links</span> ))}
</div> </div>
))} ) : (
</div> <div style={cardBase}>
<p style={{ fontSize: '13px', color: '#7A7D85' }}>No bookmarks yet add your first one to get started.</p>
</div>
)}
{/* Bookmark groups grid */} {/* Bookmark groups grid */}
<div className="grid grid-cols-4 gap-4"> {groups.length > 0 && (
{groups.map((group) => ( <div className="grid grid-cols-4 gap-4">
<div key={group.title} style={cardBase} className="hover:!border-gold/15"> {groups.map((group) => (
<h3 style={sectionTitle}>{group.title}</h3> <div key={group.title} style={cardBase} className="hover:!border-gold/15">
<div className="flex flex-col" style={{ gap: '2px' }}> <h3 style={sectionTitle}>{group.title}</h3>
{group.links.map((link) => ( <div className="flex flex-col" style={{ gap: '2px' }}>
<LinkRow {group.links.map((link) => (
key={link.name} <LinkRow key={link.id} bookmark={link} onToggleFavorite={() => toggleFavorite(link)} />
link={link} ))}
favorited={favoritedSet.has(link.name)} </div>
onToggle={() => toggleFavorite(link.name)}
/>
))}
</div> </div>
</div> ))}
))} </div>
</div> )}
</div> </div>
{/* Right sidebar — spans both rows so Favorites reaches up near the hero, and stretches to match the main column's full height */} {/* Right sidebar — spans both rows so Favorites reaches up near the hero, and stretches to match the main column's full height */}
@ -344,30 +502,41 @@ export default function BookNest() {
<div style={{ ...cardBase, padding: '22px', flex: '1.4 0 auto' }}> <div style={{ ...cardBase, padding: '22px', flex: '1.4 0 auto' }}>
<h3 style={{ ...sectionTitle, fontSize: '12px', marginBottom: '18px' }}>Favorites</h3> <h3 style={{ ...sectionTitle, fontSize: '12px', marginBottom: '18px' }}>Favorites</h3>
<div className="flex flex-col" style={{ gap: '4px' }}> <div className="flex flex-col" style={{ gap: '4px' }}>
{favorites.map((f) => ( {favorites.length > 0 ? (
<div key={f.name} className="flex items-center gap-3" style={{ padding: '8px 0' }}> favorites.map((f) => {
<f.icon size={17} style={{ color: '#C8A434' }} /> const Icon = resolveIcon(f.icon)
<span style={{ fontSize: '13px', color: '#E8E6E0' }}>{f.name}</span> return (
</div> <a key={f.id} href={f.url} target="_blank" rel="noreferrer" className="flex items-center gap-3" style={{ padding: '8px 0', textDecoration: 'none' }}>
))} <Icon size={17} style={{ color: '#C8A434' }} />
<span style={{ fontSize: '13px', color: '#E8E6E0' }}>{f.title}</span>
</a>
)
})
) : (
<p style={{ fontSize: '12px', color: '#7A7D85' }}>No favorites yet</p>
)}
</div> </div>
</div> </div>
<div style={{ ...cardBase, flex: '1 0 auto' }}> <div style={{ ...cardBase, flex: '1 0 auto' }}>
<h3 style={sectionTitle}>Recently Used</h3> <h3 style={sectionTitle}>Recently Added</h3>
<div className="flex flex-col" style={{ gap: '8px' }}> <div className="flex flex-col" style={{ gap: '8px' }}>
{recentlyUsed.map((r) => ( {recentlyAdded.length > 0 ? (
<div key={r.name} className="flex items-center justify-between"> recentlyAdded.map((r) => (
<span style={{ fontSize: '12px', color: '#E8E6E0' }}>{r.name}</span> <div key={r.id} className="flex items-center justify-between">
<span style={{ fontSize: '10px', color: '#7A7D85' }}>{r.time}</span> <span style={{ fontSize: '12px', color: '#E8E6E0' }}>{r.title}</span>
</div> <span style={{ fontSize: '10px', color: '#7A7D85' }}>{new Date(r.created_at).toLocaleDateString()}</span>
))} </div>
))
) : (
<p style={{ fontSize: '12px', color: '#7A7D85' }}>Nothing yet</p>
)}
</div> </div>
</div> </div>
<div style={{ ...cardBase, flex: '1 0 auto' }}> <div style={{ ...cardBase, flex: '1 0 auto' }}>
<h3 style={sectionTitle}>Link Health</h3> <h3 style={sectionTitle}>Link Health</h3>
<Donut data={linkHealthData} centerLabel="312" /> <Donut data={linkHealthData} centerLabel={String(bookmarks.length)} />
</div> </div>
<div className="justify-center" style={{ ...cardBase, flex: '1 0 auto', display: 'flex' }}> <div className="justify-center" style={{ ...cardBase, flex: '1 0 auto', display: 'flex' }}>