From b49f8ac8f5f6466271fd531fcd87fe360e56f1cb Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 18 Jun 2026 19:33:26 +0000 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01BbJV5nm8KPVH1oNJYKpnoF --- src/lib/api.ts | 4 + src/pages/BookNest.tsx | 591 ++++++++++++++++++++++++++--------------- 2 files changed, 384 insertions(+), 211 deletions(-) diff --git a/src/lib/api.ts b/src/lib/api.ts index f4847f4..5657a34 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -60,8 +60,12 @@ export const api = { listBookmarks: () => apiFetch<{ bookmarks: Bookmark[] }>('/bookmarks'), 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 }) => 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(`/bookmarks/${id}`, { method: 'DELETE' }), } diff --git a/src/pages/BookNest.tsx b/src/pages/BookNest.tsx index 341c719..68a1dca 100644 --- a/src/pages/BookNest.tsx +++ b/src/pages/BookNest.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import { PieChart, Pie, Cell, ResponsiveContainer } from 'recharts' import { Link2, @@ -38,149 +38,61 @@ import { Zap, Globe2, Container, + X, + type LucideIcon, } from 'lucide-react' +import { api, ApiError, type Bookmark, type BookmarkCategory } from '../lib/api' -const quickAccess = [ - { label: 'Infrastructure', icons: [Server, Cloud, Box, Shield, Container], count: 5 }, - { label: 'Development', icons: [GitBranch, GitFork, FileCode, TerminalIcon, Container], count: 5 }, - { label: 'AI Tools', icons: [Bot, Sparkles, MessageSquare, Zap, Bot], count: 5 }, - { label: 'AWS', icons: [Cloud, Database, Server, Shield, Globe2], count: 5 }, - { label: 'Networking', icons: [Network, Wifi, Router, Shield, Globe2], count: 5 }, -] +const ICONS: Record = { + 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, +} -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[] }[] = [ - { - title: 'Infrastructure & Self Hosted', - links: [ - { name: 'CasaOS', icon: Server }, - { 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 statusColors: Record = { + online: '#2ECC71', + warning: '#E67E22', + offline: '#E74C3C', + unknown: '#7A7D85', +} -const favorites = [ - { 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 categoryPalette = ['#C8A434', '#3B82F6', '#2ECC71', '#E67E22', '#7A7D85', '#8B5E3C', '#9B59B6', '#E74C3C'] const cardBase: React.CSSProperties = { backgroundColor: 'rgba(10, 10, 12, 0.92)', @@ -204,19 +116,33 @@ const sectionTitle: React.CSSProperties = { 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 (
- - - - {data.map((entry) => ( - - ))} - - - + {hasData && ( + + + + {data.map((entry) => ( + + ))} + + + + )} {centerLabel && (
{centerLabel} @@ -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 }) { - const Icon = link.icon +function LinkRow({ bookmark, onToggleFavorite }: { bookmark: Bookmark; onToggleFavorite: () => void }) { + const Icon = resolveIcon(bookmark.icon) return ( ) } +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('') + 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 ( +
+
e.stopPropagation()} + onSubmit={handleSubmit} + style={{ ...cardBase, width: '420px', padding: '24px', gap: '14px' }} + > +
+

Add Bookmark

+ +
+ +
+ + setTitle(e.target.value)} placeholder="e.g. Proxmox" /> +
+ +
+ + setUrl(e.target.value)} placeholder="https://..." /> +
+ +
+ + +
+ +
+ + +
+ + {categoryId === 'new' && ( +
+ + setNewCategoryName(e.target.value)} placeholder="e.g. Monitoring" /> +
+ )} + + {error &&

{error}

} + + +
+
+ ) +} + export default function BookNest() { - const [favoritedSet, setFavoritedSet] = useState>( - () => new Set(groups.flatMap((g) => g.links.filter((l) => l.favorited).map((l) => l.name))) + const [bookmarks, setBookmarks] = useState(null) + const [categories, setCategories] = useState([]) + 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() + 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) { - setFavoritedSet((prev) => { - const next = new Set(prev) - if (next.has(name)) next.delete(name) - else next.add(name) - return next - }) + 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) => resolveIcon(l.icon)), count: g.links.length })), + [groups] + ) + + const linkHealthData = useMemo(() => { + const counts: Record = { 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 ( +
+

Loading bookmarks…

+
+ ) } return (
+ {showAddModal && ( + setShowAddModal(false)} + onCreated={(b) => setBookmarks((prev) => [b, ...(prev ?? [])])} + onCategoryCreated={(c) => setCategories((prev) => [...prev, c])} + /> + )}
{/* Page stats — sits directly under the hero title/subtitle, like the blueprint */}
- 312 Links - 18 Categories - 12 Favorites + {bookmarks.length} Links + {categories.length} Categories + {favorites.length} Favorites
@@ -287,6 +441,7 @@ export default function BookNest() {

Quick Access

-
- {quickAccess.map((qa) => ( -
- {qa.label} -
- {qa.icons.map((Icon, i) => ( -
- -
- ))} + {quickAccess.length > 0 ? ( +
+ {quickAccess.map((qa) => ( +
+ {qa.label} +
+ {qa.icons.map((Icon, i) => ( +
+ +
+ ))} +
+ {qa.count} links
- {qa.count} links -
- ))} -
+ ))} +
+ ) : ( +
+

No bookmarks yet — add your first one to get started.

+
+ )} {/* Bookmark groups grid */} -
- {groups.map((group) => ( -
-

{group.title}

-
- {group.links.map((link) => ( - toggleFavorite(link.name)} - /> - ))} + {groups.length > 0 && ( +
+ {groups.map((group) => ( +
+

{group.title}

+
+ {group.links.map((link) => ( + toggleFavorite(link)} /> + ))} +
-
- ))} -
+ ))} +
+ )}
{/* 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() {

Favorites

- {favorites.map((f) => ( -
- - {f.name} -
- ))} + {favorites.length > 0 ? ( + favorites.map((f) => { + const Icon = resolveIcon(f.icon) + return ( + + + {f.title} + + ) + }) + ) : ( +

No favorites yet

+ )}
-

Recently Used

+

Recently Added

- {recentlyUsed.map((r) => ( -
- {r.name} - {r.time} -
- ))} + {recentlyAdded.length > 0 ? ( + recentlyAdded.map((r) => ( +
+ {r.title} + {new Date(r.created_at).toLocaleDateString()} +
+ )) + ) : ( +

Nothing yet

+ )}

Link Health

- +