dev_arc_aws/src/pages/BookNest.tsx

553 lines
20 KiB
TypeScript
Raw Normal View History

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,
type LucideIcon,
} from 'lucide-react'
import { api, ApiError, type Bookmark, type BookmarkCategory } from '../lib/api'
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']
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 }: { bookmark: Bookmark; onToggleFavorite: () => void }) {
const Icon = resolveIcon(bookmark.icon)
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' }}
>
<Icon size={16} style={{ color: '#C8A434', flexShrink: 0 }} />
<span style={{ fontSize: '13px', color: '#E8E6E0', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{bookmark.title}</span>
</a>
<button
onClick={onToggleFavorite}
className="cursor-pointer bg-transparent border-none p-0 flex items-center"
style={{ flexShrink: 0 }}
>
<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 [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]
)
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' }}>{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>
{/* 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' }}>
<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)} />
))}
</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) => {
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 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>
)
}