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

- +