2026-06-18 19:33:26 +00:00
|
|
|
import { useEffect, useMemo, useState } from 'react'
|
2026-06-18 18:10:16 +00:00
|
|
|
import { PieChart, Pie, Cell, ResponsiveContainer } from 'recharts'
|
|
|
|
|
import {
|
|
|
|
|
Link2,
|
|
|
|
|
FolderOpen,
|
|
|
|
|
Star,
|
2026-06-18 18:25:19 +00:00
|
|
|
Plus,
|
2026-06-18 18:10:16 +00:00
|
|
|
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,
|
2026-06-18 19:33:26 +00:00
|
|
|
X,
|
|
|
|
|
type LucideIcon,
|
2026-06-18 18:10:16 +00:00
|
|
|
} from 'lucide-react'
|
2026-06-18 19:33:26 +00:00
|
|
|
import { api, ApiError, type Bookmark, type BookmarkCategory } from '../lib/api'
|
2026-06-18 18:10:16 +00:00
|
|
|
|
2026-06-18 19:33:26 +00:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const statusColors: Record<string, string> = {
|
|
|
|
|
online: '#2ECC71',
|
|
|
|
|
warning: '#E67E22',
|
|
|
|
|
offline: '#E74C3C',
|
|
|
|
|
unknown: '#7A7D85',
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const categoryPalette = ['#C8A434', '#3B82F6', '#2ECC71', '#E67E22', '#7A7D85', '#8B5E3C', '#9B59B6', '#E74C3C']
|
2026-06-18 18:10:16 +00:00
|
|
|
|
|
|
|
|
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',
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-18 19:33:26 +00:00
|
|
|
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',
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-18 18:10:16 +00:00
|
|
|
function Donut({ data, centerLabel }: { data: { name: string; value: number; color: string }[]; centerLabel?: string }) {
|
2026-06-18 19:33:26 +00:00
|
|
|
const hasData = data.some((d) => d.value > 0)
|
2026-06-18 18:10:16 +00:00
|
|
|
return (
|
|
|
|
|
<div className="flex items-center gap-3">
|
|
|
|
|
<div className="relative" style={{ width: '88px', height: '88px', flexShrink: 0 }}>
|
2026-06-18 19:33:26 +00:00
|
|
|
{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>
|
|
|
|
|
)}
|
2026-06-18 18:10:16 +00:00
|
|
|
{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>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-18 19:33:26 +00:00
|
|
|
function LinkRow({ bookmark, onToggleFavorite }: { bookmark: Bookmark; onToggleFavorite: () => void }) {
|
|
|
|
|
const Icon = resolveIcon(bookmark.icon)
|
2026-06-18 18:10:16 +00:00
|
|
|
return (
|
|
|
|
|
<div className="flex items-center justify-between group" style={{ padding: '6px 0' }}>
|
2026-06-18 19:33:26 +00:00
|
|
|
<a
|
|
|
|
|
href={bookmark.url}
|
|
|
|
|
target="_blank"
|
|
|
|
|
rel="noreferrer"
|
|
|
|
|
className="flex items-center gap-2.5 cursor-pointer"
|
|
|
|
|
style={{ minWidth: 0, textDecoration: 'none' }}
|
|
|
|
|
>
|
2026-06-18 18:10:16 +00:00
|
|
|
<Icon size={16} style={{ color: '#C8A434', flexShrink: 0 }} />
|
2026-06-18 19:33:26 +00:00
|
|
|
<span style={{ fontSize: '13px', color: '#E8E6E0', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{bookmark.title}</span>
|
|
|
|
|
</a>
|
2026-06-18 18:10:16 +00:00
|
|
|
<button
|
2026-06-18 19:33:26 +00:00
|
|
|
onClick={onToggleFavorite}
|
2026-06-18 18:10:16 +00:00
|
|
|
className="cursor-pointer bg-transparent border-none p-0 flex items-center"
|
|
|
|
|
style={{ flexShrink: 0 }}
|
|
|
|
|
>
|
2026-06-18 19:33:26 +00:00
|
|
|
<Star size={14} fill={bookmark.favorite ? '#C8A434' : 'none'} style={{ color: bookmark.favorite ? '#C8A434' : '#4A4D55' }} />
|
2026-06-18 18:10:16 +00:00
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-18 19:33:26 +00:00
|
|
|
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>
|
2026-06-18 18:10:16 +00:00
|
|
|
)
|
2026-06-18 19:33:26 +00:00
|
|
|
}
|
2026-06-18 18:10:16 +00:00
|
|
|
|
2026-06-18 19:33:26 +00:00
|
|
|
export default function BookNest() {
|
|
|
|
|
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)
|
2026-06-18 18:10:16 +00:00
|
|
|
})
|
2026-06-18 19:33:26 +00:00
|
|
|
}, [])
|
|
|
|
|
|
|
|
|
|
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) => 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>
|
|
|
|
|
)
|
2026-06-18 18:10:16 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
2026-06-18 18:25:19 +00:00
|
|
|
<div className="flex h-full w-full flex-col overflow-y-auto" style={{ scrollbarWidth: 'none' }}>
|
2026-06-18 19:33:26 +00:00
|
|
|
{showAddModal && (
|
|
|
|
|
<AddBookmarkModal
|
|
|
|
|
categories={categories}
|
|
|
|
|
onClose={() => setShowAddModal(false)}
|
|
|
|
|
onCreated={(b) => setBookmarks((prev) => [b, ...(prev ?? [])])}
|
|
|
|
|
onCategoryCreated={(c) => setCategories((prev) => [...prev, c])}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
2026-06-18 18:25:19 +00:00
|
|
|
<div className="grid w-full gap-5" style={{ gridTemplateColumns: '3fr 1fr', gridTemplateRows: 'auto 1fr' }}>
|
2026-06-18 18:27:37 +00:00
|
|
|
{/* 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' }}>
|
2026-06-18 18:25:19 +00:00
|
|
|
<div className="flex items-center gap-5" style={{ fontSize: '12px', color: '#7A7D85' }}>
|
2026-06-18 19:33:26 +00:00
|
|
|
<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>
|
2026-06-18 18:25:19 +00:00
|
|
|
</div>
|
|
|
|
|
</div>
|
2026-06-18 18:10:16 +00:00
|
|
|
|
|
|
|
|
{/* Main column */}
|
2026-06-18 18:27:37 +00:00
|
|
|
<div className="flex flex-col gap-5" style={{ gridColumn: 1, gridRow: 2, marginTop: '14px' }}>
|
2026-06-18 18:10:16 +00:00
|
|
|
{/* Quick Access */}
|
2026-06-18 18:27:37 +00:00
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
<h3 style={{ ...sectionTitle, marginBottom: 0, color: '#C8A434' }}>Quick Access</h3>
|
|
|
|
|
<button
|
2026-06-18 19:33:26 +00:00
|
|
|
onClick={() => setShowAddModal(true)}
|
2026-06-18 18:27:37 +00:00
|
|
|
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>
|
2026-06-18 19:33:26 +00:00
|
|
|
{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' }}>
|
|
|
|
|
<Icon size={12} style={{ color: '#C8A434' }} />
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
<span style={{ fontSize: '10px', color: '#7A7D85' }}>{qa.count} links</span>
|
2026-06-18 18:10:16 +00:00
|
|
|
</div>
|
2026-06-18 19:33:26 +00:00
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<div style={cardBase}>
|
|
|
|
|
<p style={{ fontSize: '13px', color: '#7A7D85' }}>No bookmarks yet — add your first one to get started.</p>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2026-06-18 18:10:16 +00:00
|
|
|
|
|
|
|
|
{/* Bookmark groups grid */}
|
2026-06-18 19:33:26 +00:00
|
|
|
{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)} />
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
2026-06-18 18:10:16 +00:00
|
|
|
</div>
|
2026-06-18 19:33:26 +00:00
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2026-06-18 18:10:16 +00:00
|
|
|
</div>
|
|
|
|
|
|
2026-06-18 18:31:22 +00:00
|
|
|
{/* 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' }}>
|
2026-06-18 18:25:19 +00:00
|
|
|
<h3 style={{ ...sectionTitle, fontSize: '12px', marginBottom: '18px' }}>Favorites</h3>
|
|
|
|
|
<div className="flex flex-col" style={{ gap: '4px' }}>
|
2026-06-18 19:33:26 +00:00
|
|
|
{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>
|
|
|
|
|
)}
|
2026-06-18 18:10:16 +00:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-06-18 18:31:22 +00:00
|
|
|
<div style={{ ...cardBase, flex: '1 0 auto' }}>
|
2026-06-18 19:33:26 +00:00
|
|
|
<h3 style={sectionTitle}>Recently Added</h3>
|
2026-06-18 18:10:16 +00:00
|
|
|
<div className="flex flex-col" style={{ gap: '8px' }}>
|
2026-06-18 19:33:26 +00:00
|
|
|
{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>
|
|
|
|
|
)}
|
2026-06-18 18:10:16 +00:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-06-18 18:31:22 +00:00
|
|
|
<div style={{ ...cardBase, flex: '1 0 auto' }}>
|
2026-06-18 18:10:16 +00:00
|
|
|
<h3 style={sectionTitle}>Link Health</h3>
|
2026-06-18 19:33:26 +00:00
|
|
|
<Donut data={linkHealthData} centerLabel={String(bookmarks.length)} />
|
2026-06-18 18:10:16 +00:00
|
|
|
</div>
|
|
|
|
|
|
2026-06-18 18:31:22 +00:00
|
|
|
<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>
|
2026-06-18 18:10:16 +00:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|