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:
parent
5c1fc911c9
commit
b49f8ac8f5
2 changed files with 384 additions and 211 deletions
|
|
@ -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<void>(`/bookmarks/${id}`, { method: 'DELETE' }),
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<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,
|
||||
}
|
||||
|
||||
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<string, string> = {
|
||||
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,10 +116,23 @@ 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 (
|
||||
<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}>
|
||||
|
|
@ -217,6 +142,7 @@ function Donut({ data, centerLabel }: { data: { name: string; value: number; col
|
|||
</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>
|
||||
|
|
@ -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 (
|
||||
<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 }} />
|
||||
<span style={{ fontSize: '13px', color: '#E8E6E0', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{link.name}</span>
|
||||
</div>
|
||||
<span style={{ fontSize: '13px', color: '#E8E6E0', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{bookmark.title}</span>
|
||||
</a>
|
||||
<button
|
||||
onClick={onToggle}
|
||||
onClick={onToggleFavorite}
|
||||
className="cursor-pointer bg-transparent border-none p-0 flex items-center"
|
||||
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>
|
||||
</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() {
|
||||
const [favoritedSet, setFavoritedSet] = useState<Set<string>>(
|
||||
() => new Set(groups.flatMap((g) => g.links.filter((l) => l.favorited).map((l) => l.name)))
|
||||
const [bookmarks, setBookmarks] = useState<Bookmark[] | null>(null)
|
||||
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) {
|
||||
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<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 && (
|
||||
<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' }}>
|
||||
{/* 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 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"><FolderOpen size={13} style={{ color: '#C8A434' }} /> <strong style={{ color: '#E8E6E0' }}>18</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"><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>
|
||||
</div>
|
||||
|
||||
|
|
@ -287,6 +441,7 @@ export default function BookNest() {
|
|||
<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',
|
||||
|
|
@ -303,6 +458,7 @@ export default function BookNest() {
|
|||
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">
|
||||
|
|
@ -318,25 +474,27 @@ export default function BookNest() {
|
|||
</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.name}
|
||||
link={link}
|
||||
favorited={favoritedSet.has(link.name)}
|
||||
onToggle={() => toggleFavorite(link.name)}
|
||||
/>
|
||||
<LinkRow key={link.id} bookmark={link} onToggleFavorite={() => toggleFavorite(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 */}
|
||||
|
|
@ -344,30 +502,41 @@ export default function BookNest() {
|
|||
<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.map((f) => (
|
||||
<div key={f.name} className="flex items-center gap-3" style={{ padding: '8px 0' }}>
|
||||
<f.icon size={17} style={{ color: '#C8A434' }} />
|
||||
<span style={{ fontSize: '13px', color: '#E8E6E0' }}>{f.name}</span>
|
||||
</div>
|
||||
))}
|
||||
{favorites.length > 0 ? (
|
||||
favorites.map((f) => {
|
||||
const Icon = resolveIcon(f.icon)
|
||||
return (
|
||||
<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 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' }}>
|
||||
{recentlyUsed.map((r) => (
|
||||
<div key={r.name} className="flex items-center justify-between">
|
||||
<span style={{ fontSize: '12px', color: '#E8E6E0' }}>{r.name}</span>
|
||||
<span style={{ fontSize: '10px', color: '#7A7D85' }}>{r.time}</span>
|
||||
{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="312" />
|
||||
<Donut data={linkHealthData} centerLabel={String(bookmarks.length)} />
|
||||
</div>
|
||||
|
||||
<div className="justify-center" style={{ ...cardBase, flex: '1 0 auto', display: 'flex' }}>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue