dev_arc_aws/src/pages/BookNest.tsx
Samuel James d9d9f3f610
Add bulk delete-all for bookmarks (#20)
* Add editable display-name field to generic integrations

Lets users set a custom name for Proxmox, Docker, AWS, Remote Desktop,
Netbird, Cloudflare, Uptime Kuma, and Weather integrations, separate
from the host/IP field, mirroring the SSH host rename pattern.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4

* Surface the new-integration name field as a labeled input

The name field for new generic integrations was a faint header input
with only placeholder text, easy to miss. Move it into the form grid
as a proper labeled "Name" field next to the other connection fields.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4

* Add file upload for SSH private key and certificate fields

Lets users pick a key file from disk (e.g. ~/.ssh) instead of pasting
its contents into the Private Key / OPKSSH Certificate fields.

* Fix SSH private key paste corrupting multi-line PEM format

Private Key and Certificate fields were single-line <input> elements,
which strip newlines on paste and corrupt PEM-formatted keys (causing
'Unsupported key format' errors). Render them as multi-line textareas
instead so pasted keys keep their line breaks.

* Add JSON-converted bookmark import file for Archnest data import

Converts homarr-bookmarks.md into the format expected by /api/data/import.

* Auto-populate bookmark icons via favicon service in import JSON

Each bookmark now points to Google's favicon endpoint for its domain
instead of having no icon at all.

* Add bulk delete-all for bookmarks

Adds DELETE /api/bookmarks to clear every bookmark in one request, and a
"Delete All" button (with confirmation) on the BookNest page so re-imports
don't require deleting dozens of entries one at a time.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-20 09:09:44 -04:00

702 lines
26 KiB
TypeScript

import { useEffect, useMemo, useState } from 'react'
import { PieChart, Pie, Cell, ResponsiveContainer } from 'recharts'
import {
Link2,
FolderOpen,
Star,
Plus,
Server,
Bot,
Cloud,
Network,
GitBranch,
GitFork,
Box,
Terminal as TerminalIcon,
Database,
Shield,
Workflow,
FileCode,
Router,
Wifi,
BookOpen,
GraduationCap,
SquarePlay,
Briefcase,
Wallet,
CreditCard,
PiggyBank,
TrendingUp,
Calendar,
Mail,
Image,
HardDrive,
FileText,
Plane,
Sparkles,
MessageSquare,
Zap,
Globe2,
Container,
X,
Pencil,
Trash2,
type LucideIcon,
} from 'lucide-react'
import { api, ApiError, type Bookmark, type BookmarkCategory } from '../lib/api'
import { guessServiceIconUrl } from '../lib/serviceIcons'
const ICONS: Record<string, LucideIcon> = {
server: Server,
bot: Bot,
cloud: Cloud,
network: Network,
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,
}
function resolveIcon(name: string | null | undefined): LucideIcon {
if (!name) return Link2
return ICONS[name.toLowerCase()] ?? Link2
}
function isIconUrl(icon: string | null | undefined): boolean {
return !!icon && /^https?:\/\//.test(icon)
}
/** Renders a bookmark's icon — a real brand logo if `icon` is a CDN URL, falling
back to the generic lucide set (and to a plain link icon if the image 404s). */
function BookmarkIcon({ icon, size = 16, style }: { icon: string | null | undefined; size?: number; style?: React.CSSProperties }) {
const [imgFailed, setImgFailed] = useState(false)
if (isIconUrl(icon) && !imgFailed) {
return (
<img
src={icon!}
alt=""
width={size}
height={size}
style={{ flexShrink: 0, objectFit: 'contain', ...style }}
onError={() => setImgFailed(true)}
/>
)
}
const Icon = resolveIcon(imgFailed ? null : icon)
return <Icon size={size} style={{ flexShrink: 0, ...style }} />
}
const statusColors: Record<string, string> = {
online: '#2ECC71',
warning: '#E67E22',
offline: '#E74C3C',
unknown: '#7A7D85',
}
const categoryPalette = ['#C8A434', '#3B82F6', '#2ECC71', '#E67E22', '#7A7D85', '#8B5E3C', '#9B59B6', '#E74C3C']
const cardBase: React.CSSProperties = {
backgroundColor: 'rgba(10, 10, 12, 0.92)',
border: '1px solid rgba(200, 164, 52, 0.08)',
borderRadius: '12px',
padding: '18px',
boxShadow: '0 0 20px rgba(200, 164, 52, 0.03)',
transition: 'border-color 0.2s ease',
position: 'relative',
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
}
const sectionTitle: React.CSSProperties = {
fontSize: '11px',
textTransform: 'uppercase',
letterSpacing: '1.5px',
color: '#7A7D85',
fontWeight: 500,
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 }) {
const hasData = data.some((d) => d.value > 0)
return (
<div className="flex items-center gap-3">
<div className="relative" style={{ width: '88px', height: '88px', flexShrink: 0 }}>
{hasData && (
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie data={data} dataKey="value" innerRadius={28} outerRadius={42} paddingAngle={2} isAnimationActive animationDuration={1000}>
{data.map((entry) => (
<Cell key={entry.name} fill={entry.color} stroke="none" />
))}
</Pie>
</PieChart>
</ResponsiveContainer>
)}
{centerLabel && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<span style={{ fontSize: '14px', fontWeight: 700, color: '#E8E6E0' }}>{centerLabel}</span>
</div>
)}
</div>
<div className="flex flex-col gap-1.5">
{data.map((entry) => (
<div key={entry.name} className="flex items-center gap-1.5">
<span style={{ width: '7px', height: '7px', borderRadius: '50%', backgroundColor: entry.color, flexShrink: 0 }} />
<span style={{ fontSize: '11px', color: '#E8E6E0' }}>{entry.name}</span>
<span style={{ fontSize: '10px', color: '#7A7D85' }}>{entry.value}</span>
</div>
))}
</div>
</div>
)
}
function LinkRow({
bookmark,
onToggleFavorite,
onEdit,
onDelete,
}: {
bookmark: Bookmark
onToggleFavorite: () => void
onEdit: () => void
onDelete: () => void
}) {
return (
<div className="flex items-center justify-between group" style={{ padding: '6px 0' }}>
<a
href={bookmark.url}
target="_blank"
rel="noreferrer"
className="flex items-center gap-2.5 cursor-pointer"
style={{ minWidth: 0, textDecoration: 'none', flex: 1 }}
>
<BookmarkIcon icon={bookmark.icon} size={16} style={{ color: '#C8A434' }} />
<span style={{ fontSize: '13px', color: '#E8E6E0', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{bookmark.title}</span>
</a>
<div className="flex items-center gap-1.5" style={{ flexShrink: 0 }}>
<button
onClick={(e) => { e.preventDefault(); onEdit() }}
className="cursor-pointer bg-transparent border-none p-0 flex items-center opacity-0 group-hover:opacity-100 transition-opacity"
>
<Pencil size={12} style={{ color: '#7A7D85' }} />
</button>
<button
onClick={(e) => { e.preventDefault(); onDelete() }}
className="cursor-pointer bg-transparent border-none p-0 flex items-center opacity-0 group-hover:opacity-100 transition-opacity"
>
<Trash2 size={12} style={{ color: '#7A7D85' }} />
</button>
<button
onClick={onToggleFavorite}
className="cursor-pointer bg-transparent border-none p-0 flex items-center"
>
<Star size={14} fill={bookmark.favorite ? '#C8A434' : 'none'} style={{ color: bookmark.favorite ? '#C8A434' : '#4A4D55' }} />
</button>
</div>
</div>
)
}
function BookmarkModal({
categories,
bookmark,
onClose,
onCreated,
onUpdated,
onCategoryCreated,
}: {
categories: BookmarkCategory[]
bookmark?: Bookmark
onClose: () => void
onCreated: (bookmark: Bookmark) => void
onUpdated: (bookmark: Bookmark) => void
onCategoryCreated: (category: BookmarkCategory) => void
}) {
const isEdit = !!bookmark
const [title, setTitle] = useState(bookmark?.title ?? '')
const [url, setUrl] = useState(bookmark?.url ?? '')
const [icon, setIcon] = useState(bookmark?.icon ?? 'link2')
const [iconMode, setIconMode] = useState<'auto' | 'manual'>(isIconUrl(bookmark?.icon) || !bookmark ? 'auto' : 'manual')
const [categoryId, setCategoryId] = useState<number | 'new' | ''>(bookmark?.category_id ?? '')
const [newCategoryName, setNewCategoryName] = useState('')
const [error, setError] = useState('')
const [busy, setBusy] = useState(false)
useEffect(() => {
if (iconMode !== 'auto') return
setIcon(guessServiceIconUrl(title, url) ?? 'link2')
}, [title, url, iconMode])
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
}
if (isEdit && bookmark) {
await api.updateBookmark(bookmark.id, {
title: title.trim(),
url: url.trim(),
icon,
categoryId: resolvedCategoryId,
})
onUpdated({ ...bookmark, title: title.trim(), url: url.trim(), icon, category_id: resolvedCategoryId })
} else {
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 ${isEdit ? 'update' : '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 }}>{isEdit ? 'Edit Bookmark' : '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">
<div className="flex items-center justify-between">
<label style={{ fontSize: '11px', color: '#7A7D85' }}>Icon</label>
{iconMode === 'manual' && (
<button
type="button"
onClick={() => setIconMode('auto')}
className="cursor-pointer bg-transparent border-none p-0"
style={{ fontSize: '11px', color: '#C8A434' }}
>
Auto-detect again
</button>
)}
</div>
{iconMode === 'auto' ? (
<div className="flex items-center gap-2.5" style={{ ...inputStyle, justifyContent: 'space-between' }}>
<div className="flex items-center gap-2.5">
<BookmarkIcon icon={icon} size={16} style={{ color: '#C8A434' }} />
<span style={{ fontSize: '12px', color: isIconUrl(icon) ? '#E8E6E0' : '#7A7D85' }}>
{isIconUrl(icon) ? 'Detected from title/URL' : 'No match yet — type a title or pick one'}
</span>
</div>
<button
type="button"
onClick={() => setIconMode('manual')}
className="cursor-pointer bg-transparent border-none p-0"
style={{ fontSize: '11px', color: '#7A7D85', whiteSpace: 'nowrap' }}
>
Choose manually
</button>
</div>
) : (
<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…' : isEdit ? 'Save Changes' : 'Add Bookmark'}
</button>
</form>
</div>
)
}
export default function BookNest() {
const [bookmarks, setBookmarks] = useState<Bookmark[] | null>(null)
const [categories, setCategories] = useState<BookmarkCategory[]>([])
const [showAddModal, setShowAddModal] = useState(false)
const [editingBookmark, setEditingBookmark] = useState<Bookmark | null>(null)
useEffect(() => {
Promise.all([api.listBookmarks(), api.listBookmarkCategories()]).then(([b, c]) => {
setBookmarks(b.bookmarks)
setCategories(c.categories)
})
}, [])
async function deleteBookmark(bookmark: Bookmark) {
if (!window.confirm(`Delete "${bookmark.title}"?`)) return
setBookmarks((prev) => prev?.filter((b) => b.id !== bookmark.id) ?? prev)
try {
await api.deleteBookmark(bookmark.id)
} catch {
setBookmarks((prev) => (prev ? [...prev, bookmark] : prev))
}
}
async function deleteAllBookmarks() {
const count = bookmarks?.length ?? 0
if (count === 0) return
if (!window.confirm(`Delete all ${count} bookmark${count === 1 ? '' : 's'}? This cannot be undone.`)) return
const prevBookmarks = bookmarks
setBookmarks([])
try {
await api.deleteAllBookmarks()
} catch {
setBookmarks(prevBookmarks)
}
}
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]
)
const quickAccess = useMemo(
() =>
[...groups]
.sort((a, b) => b.links.length - a.links.length)
.slice(0, 5)
.map((g) => ({ label: g.title, icons: g.links.slice(0, 5).map((l) => 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 (
<div className="flex h-full w-full flex-col overflow-y-auto" style={{ scrollbarWidth: 'none' }}>
{(showAddModal || editingBookmark) && (
<BookmarkModal
categories={categories}
bookmark={editingBookmark ?? undefined}
onClose={() => { setShowAddModal(false); setEditingBookmark(null) }}
onCreated={(b) => setBookmarks((prev) => [b, ...(prev ?? [])])}
onUpdated={(b) => setBookmarks((prev) => prev?.map((x) => (x.id === b.id ? b : x)) ?? prev)}
onCategoryCreated={(c) => setCategories((prev) => [...prev, c])}
/>
)}
<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 */}
<div className="flex items-center justify-between shrink-0" style={{ gridColumn: 1, gridRow: 1, marginTop: '8px' }}>
<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' }}>{bookmarks.length}</strong> Links</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' }}>{favorites.length}</strong> Favorites</span>
</div>
{bookmarks.length > 0 && (
<button
onClick={deleteAllBookmarks}
className="flex items-center gap-1.5 cursor-pointer transition-colors whitespace-nowrap"
style={{
fontSize: '11px',
fontWeight: 500,
color: '#7A7D85',
backgroundColor: 'transparent',
border: '1px solid rgba(231, 76, 60, 0.2)',
borderRadius: '8px',
padding: '6px 10px',
}}
onMouseEnter={(e) => { e.currentTarget.style.color = '#E74C3C'; e.currentTarget.style.borderColor = 'rgba(231, 76, 60, 0.5)' }}
onMouseLeave={(e) => { e.currentTarget.style.color = '#7A7D85'; e.currentTarget.style.borderColor = 'rgba(231, 76, 60, 0.2)' }}
>
<Trash2 size={12} />
Delete All
</button>
)}
</div>
{/* Main column */}
<div className="flex flex-col gap-5" style={{ gridColumn: 1, gridRow: 2, marginTop: '14px' }}>
{/* Quick Access */}
<div className="flex items-center justify-between">
<h3 style={{ ...sectionTitle, marginBottom: 0, color: '#C8A434' }}>Quick Access</h3>
<button
onClick={() => setShowAddModal(true)}
className="flex items-center gap-2 cursor-pointer transition-colors whitespace-nowrap"
style={{
fontSize: '12px',
fontWeight: 600,
color: '#0A0B0D',
backgroundColor: '#C8A434',
border: 'none',
borderRadius: '8px',
padding: '8px 14px',
boxShadow: '0 0 14px rgba(200,164,52,0.2)',
}}
>
<Plus size={14} />
Add Bookmark
</button>
</div>
{quickAccess.length > 0 ? (
<div className="grid grid-cols-5 gap-4">
{quickAccess.map((qa) => (
<div key={qa.label} style={{ ...cardBase, padding: '14px' }} className="hover:!border-gold/20">
<span style={{ fontSize: '11px', color: '#E8E6E0', fontWeight: 600, marginBottom: '10px' }}>{qa.label}</span>
<div className="flex items-center gap-1.5" style={{ marginBottom: '8px' }}>
{qa.icons.map((icon, i) => (
<div key={i} style={{ width: '22px', height: '22px', borderRadius: '6px', backgroundColor: 'rgba(200,164,52,0.08)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<BookmarkIcon icon={icon} size={12} style={{ color: '#C8A434' }} />
</div>
))}
</div>
<span style={{ fontSize: '10px', color: '#7A7D85' }}>{qa.count} links</span>
</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 */}
{groups.length > 0 && (
<div className="grid grid-cols-4 gap-4">
{groups.map((group) => (
<div key={group.title} style={cardBase} className="hover:!border-gold/15">
<h3 style={sectionTitle}>{group.title}</h3>
<div className="flex flex-col" style={{ gap: '2px' }}>
{group.links.map((link) => (
<LinkRow
key={link.id}
bookmark={link}
onToggleFavorite={() => toggleFavorite(link)}
onEdit={() => setEditingBookmark(link)}
onDelete={() => deleteBookmark(link)}
/>
))}
</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 */}
<div className="flex h-full flex-col gap-5" style={{ gridColumn: 2, gridRow: '1 / span 2' }}>
<div style={{ ...cardBase, padding: '22px', flex: '1.4 0 auto' }}>
<h3 style={{ ...sectionTitle, fontSize: '12px', marginBottom: '18px' }}>Favorites</h3>
<div className="flex flex-col" style={{ gap: '4px' }}>
{favorites.length > 0 ? (
favorites.map((f) => (
<a key={f.id} href={f.url} target="_blank" rel="noreferrer" className="flex items-center gap-3" style={{ padding: '8px 0', textDecoration: 'none' }}>
<BookmarkIcon icon={f.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 style={{ ...cardBase, flex: '1 0 auto' }}>
<h3 style={sectionTitle}>Recently Added</h3>
<div className="flex flex-col" style={{ gap: '8px' }}>
{recentlyAdded.length > 0 ? (
recentlyAdded.map((r) => (
<div key={r.id} className="flex items-center justify-between">
<span style={{ fontSize: '12px', color: '#E8E6E0' }}>{r.title}</span>
<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 style={{ ...cardBase, flex: '1 0 auto' }}>
<h3 style={sectionTitle}>Link Health</h3>
<Donut data={linkHealthData} centerLabel={String(bookmarks.length)} />
</div>
<div className="justify-center" style={{ ...cardBase, flex: '1 0 auto', display: 'flex' }}>
<div className="flex flex-col" style={{ width: '100%' }}>
<h3 style={sectionTitle}>Category Breakdown</h3>
<Donut data={categoryBreakdownData} />
</div>
</div>
</div>
</div>
</div>
)
}