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,
|
2026-06-19 21:34:04 +00:00
|
|
|
Pencil,
|
|
|
|
|
Trash2,
|
2026-06-18 19:33:26 +00:00
|
|
|
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'
|
Fix favicon, dark select dropdowns, add brand bookmark icons and Help page
- Replace mislabeled Vite-logo favicon.svg with proper ArchNest mark
extracted from the logo, generated at 32/48/256px PNGs
- Force native <select>/<option> elements to render with the dark theme
(color-scheme + explicit colors) so options are readable
- Auto-detect real brand/service icons for bookmarks (AWS, Proxmox,
Azure, Docker, etc.) via the dashboard-icons CDN, with manual
override and graceful fallback to lucide icons
- Add a Help page with a guided tour of every page, linked from the
sidebar, top-bar search, and the user dropdown menu
2026-06-19 21:13:32 +00:00
|
|
|
import { guessServiceIconUrl } from '../lib/serviceIcons'
|
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
|
|
|
|
|
}
|
|
|
|
|
|
Fix favicon, dark select dropdowns, add brand bookmark icons and Help page
- Replace mislabeled Vite-logo favicon.svg with proper ArchNest mark
extracted from the logo, generated at 32/48/256px PNGs
- Force native <select>/<option> elements to render with the dark theme
(color-scheme + explicit colors) so options are readable
- Auto-detect real brand/service icons for bookmarks (AWS, Proxmox,
Azure, Docker, etc.) via the dashboard-icons CDN, with manual
override and graceful fallback to lucide icons
- Add a Help page with a guided tour of every page, linked from the
sidebar, top-bar search, and the user dropdown menu
2026-06-19 21:13:32 +00:00
|
|
|
function isIconUrl(icon: string | null | undefined): boolean {
|
|
|
|
|
return !!icon && /^https?:\/\//.test(icon)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Renders a bookmark's icon — a real brand logo if `icon` is a CDN URL, falling
|
|
|
|
|
back to the generic lucide set (and to a plain link icon if the image 404s). */
|
|
|
|
|
function BookmarkIcon({ icon, size = 16, style }: { icon: string | null | undefined; size?: number; style?: React.CSSProperties }) {
|
|
|
|
|
const [imgFailed, setImgFailed] = useState(false)
|
|
|
|
|
if (isIconUrl(icon) && !imgFailed) {
|
|
|
|
|
return (
|
|
|
|
|
<img
|
|
|
|
|
src={icon!}
|
|
|
|
|
alt=""
|
|
|
|
|
width={size}
|
|
|
|
|
height={size}
|
|
|
|
|
style={{ flexShrink: 0, objectFit: 'contain', ...style }}
|
|
|
|
|
onError={() => setImgFailed(true)}
|
|
|
|
|
/>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
const Icon = resolveIcon(imgFailed ? null : icon)
|
|
|
|
|
return <Icon size={size} style={{ flexShrink: 0, ...style }} />
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-18 19:33:26 +00:00
|
|
|
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-19 21:34:04 +00:00
|
|
|
function LinkRow({
|
|
|
|
|
bookmark,
|
|
|
|
|
onToggleFavorite,
|
|
|
|
|
onEdit,
|
|
|
|
|
onDelete,
|
|
|
|
|
}: {
|
|
|
|
|
bookmark: Bookmark
|
|
|
|
|
onToggleFavorite: () => void
|
|
|
|
|
onEdit: () => void
|
|
|
|
|
onDelete: () => void
|
|
|
|
|
}) {
|
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"
|
2026-06-19 21:34:04 +00:00
|
|
|
style={{ minWidth: 0, textDecoration: 'none', flex: 1 }}
|
2026-06-18 19:33:26 +00:00
|
|
|
>
|
Fix favicon, dark select dropdowns, add brand bookmark icons and Help page
- Replace mislabeled Vite-logo favicon.svg with proper ArchNest mark
extracted from the logo, generated at 32/48/256px PNGs
- Force native <select>/<option> elements to render with the dark theme
(color-scheme + explicit colors) so options are readable
- Auto-detect real brand/service icons for bookmarks (AWS, Proxmox,
Azure, Docker, etc.) via the dashboard-icons CDN, with manual
override and graceful fallback to lucide icons
- Add a Help page with a guided tour of every page, linked from the
sidebar, top-bar search, and the user dropdown menu
2026-06-19 21:13:32 +00:00
|
|
|
<BookmarkIcon icon={bookmark.icon} size={16} style={{ color: '#C8A434' }} />
|
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-19 21:34:04 +00:00
|
|
|
<div className="flex items-center gap-1.5" style={{ flexShrink: 0 }}>
|
|
|
|
|
<button
|
|
|
|
|
onClick={(e) => { e.preventDefault(); onEdit() }}
|
|
|
|
|
className="cursor-pointer bg-transparent border-none p-0 flex items-center opacity-0 group-hover:opacity-100 transition-opacity"
|
|
|
|
|
>
|
|
|
|
|
<Pencil size={12} style={{ color: '#7A7D85' }} />
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
onClick={(e) => { e.preventDefault(); onDelete() }}
|
|
|
|
|
className="cursor-pointer bg-transparent border-none p-0 flex items-center opacity-0 group-hover:opacity-100 transition-opacity"
|
|
|
|
|
>
|
|
|
|
|
<Trash2 size={12} style={{ color: '#7A7D85' }} />
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
onClick={onToggleFavorite}
|
|
|
|
|
className="cursor-pointer bg-transparent border-none p-0 flex items-center"
|
|
|
|
|
>
|
|
|
|
|
<Star size={14} fill={bookmark.favorite ? '#C8A434' : 'none'} style={{ color: bookmark.favorite ? '#C8A434' : '#4A4D55' }} />
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
2026-06-18 18:10:16 +00:00
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-19 21:34:04 +00:00
|
|
|
function BookmarkModal({
|
2026-06-18 19:33:26 +00:00
|
|
|
categories,
|
2026-06-19 21:34:04 +00:00
|
|
|
bookmark,
|
2026-06-18 19:33:26 +00:00
|
|
|
onClose,
|
|
|
|
|
onCreated,
|
2026-06-19 21:34:04 +00:00
|
|
|
onUpdated,
|
2026-06-18 19:33:26 +00:00
|
|
|
onCategoryCreated,
|
|
|
|
|
}: {
|
|
|
|
|
categories: BookmarkCategory[]
|
2026-06-19 21:34:04 +00:00
|
|
|
bookmark?: Bookmark
|
2026-06-18 19:33:26 +00:00
|
|
|
onClose: () => void
|
|
|
|
|
onCreated: (bookmark: Bookmark) => void
|
2026-06-19 21:34:04 +00:00
|
|
|
onUpdated: (bookmark: Bookmark) => void
|
2026-06-18 19:33:26 +00:00
|
|
|
onCategoryCreated: (category: BookmarkCategory) => void
|
|
|
|
|
}) {
|
2026-06-19 21:34:04 +00:00
|
|
|
const isEdit = !!bookmark
|
|
|
|
|
const [title, setTitle] = useState(bookmark?.title ?? '')
|
|
|
|
|
const [url, setUrl] = useState(bookmark?.url ?? '')
|
|
|
|
|
const [icon, setIcon] = useState(bookmark?.icon ?? 'link2')
|
|
|
|
|
const [iconMode, setIconMode] = useState<'auto' | 'manual'>(isIconUrl(bookmark?.icon) || !bookmark ? 'auto' : 'manual')
|
|
|
|
|
const [categoryId, setCategoryId] = useState<number | 'new' | ''>(bookmark?.category_id ?? '')
|
2026-06-18 19:33:26 +00:00
|
|
|
const [newCategoryName, setNewCategoryName] = useState('')
|
|
|
|
|
const [error, setError] = useState('')
|
|
|
|
|
const [busy, setBusy] = useState(false)
|
|
|
|
|
|
Fix favicon, dark select dropdowns, add brand bookmark icons and Help page
- Replace mislabeled Vite-logo favicon.svg with proper ArchNest mark
extracted from the logo, generated at 32/48/256px PNGs
- Force native <select>/<option> elements to render with the dark theme
(color-scheme + explicit colors) so options are readable
- Auto-detect real brand/service icons for bookmarks (AWS, Proxmox,
Azure, Docker, etc.) via the dashboard-icons CDN, with manual
override and graceful fallback to lucide icons
- Add a Help page with a guided tour of every page, linked from the
sidebar, top-bar search, and the user dropdown menu
2026-06-19 21:13:32 +00:00
|
|
|
useEffect(() => {
|
|
|
|
|
if (iconMode !== 'auto') return
|
|
|
|
|
setIcon(guessServiceIconUrl(title, url) ?? 'link2')
|
|
|
|
|
}, [title, url, iconMode])
|
|
|
|
|
|
2026-06-18 19:33:26 +00:00
|
|
|
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
|
|
|
|
|
}
|
2026-06-19 21:34:04 +00:00
|
|
|
if (isEdit && bookmark) {
|
|
|
|
|
await api.updateBookmark(bookmark.id, {
|
|
|
|
|
title: title.trim(),
|
|
|
|
|
url: url.trim(),
|
|
|
|
|
icon,
|
|
|
|
|
categoryId: resolvedCategoryId,
|
|
|
|
|
})
|
|
|
|
|
onUpdated({ ...bookmark, title: title.trim(), url: url.trim(), icon, category_id: resolvedCategoryId })
|
|
|
|
|
} else {
|
|
|
|
|
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(),
|
|
|
|
|
})
|
|
|
|
|
}
|
2026-06-18 19:33:26 +00:00
|
|
|
onClose()
|
|
|
|
|
} catch (err) {
|
2026-06-19 21:34:04 +00:00
|
|
|
setError(err instanceof ApiError ? err.message : `Failed to ${isEdit ? 'update' : 'create'} bookmark`)
|
2026-06-18 19:33:26 +00:00
|
|
|
} 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' }}>
|
2026-06-19 21:34:04 +00:00
|
|
|
<h3 style={{ fontSize: '14px', color: '#E8E6E0', fontWeight: 600 }}>{isEdit ? 'Edit Bookmark' : 'Add Bookmark'}</h3>
|
2026-06-18 19:33:26 +00:00
|
|
|
<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">
|
Fix favicon, dark select dropdowns, add brand bookmark icons and Help page
- Replace mislabeled Vite-logo favicon.svg with proper ArchNest mark
extracted from the logo, generated at 32/48/256px PNGs
- Force native <select>/<option> elements to render with the dark theme
(color-scheme + explicit colors) so options are readable
- Auto-detect real brand/service icons for bookmarks (AWS, Proxmox,
Azure, Docker, etc.) via the dashboard-icons CDN, with manual
override and graceful fallback to lucide icons
- Add a Help page with a guided tour of every page, linked from the
sidebar, top-bar search, and the user dropdown menu
2026-06-19 21:13:32 +00:00
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
<label style={{ fontSize: '11px', color: '#7A7D85' }}>Icon</label>
|
|
|
|
|
{iconMode === 'manual' && (
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => setIconMode('auto')}
|
|
|
|
|
className="cursor-pointer bg-transparent border-none p-0"
|
|
|
|
|
style={{ fontSize: '11px', color: '#C8A434' }}
|
|
|
|
|
>
|
|
|
|
|
Auto-detect again
|
|
|
|
|
</button>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
{iconMode === 'auto' ? (
|
|
|
|
|
<div className="flex items-center gap-2.5" style={{ ...inputStyle, justifyContent: 'space-between' }}>
|
|
|
|
|
<div className="flex items-center gap-2.5">
|
|
|
|
|
<BookmarkIcon icon={icon} size={16} style={{ color: '#C8A434' }} />
|
|
|
|
|
<span style={{ fontSize: '12px', color: isIconUrl(icon) ? '#E8E6E0' : '#7A7D85' }}>
|
|
|
|
|
{isIconUrl(icon) ? 'Detected from title/URL' : 'No match yet — type a title or pick one'}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => setIconMode('manual')}
|
|
|
|
|
className="cursor-pointer bg-transparent border-none p-0"
|
|
|
|
|
style={{ fontSize: '11px', color: '#7A7D85', whiteSpace: 'nowrap' }}
|
|
|
|
|
>
|
|
|
|
|
Choose manually
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<select style={inputStyle} value={icon} onChange={(e) => setIcon(e.target.value)}>
|
|
|
|
|
{Object.keys(ICONS).map((key) => (
|
|
|
|
|
<option key={key} value={key}>
|
|
|
|
|
{key}
|
|
|
|
|
</option>
|
|
|
|
|
))}
|
|
|
|
|
</select>
|
|
|
|
|
)}
|
2026-06-18 19:33:26 +00:00
|
|
|
</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',
|
|
|
|
|
}}
|
|
|
|
|
>
|
2026-06-19 21:34:04 +00:00
|
|
|
{busy ? 'Saving…' : isEdit ? 'Save Changes' : 'Add Bookmark'}
|
2026-06-18 19:33:26 +00:00
|
|
|
</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)
|
2026-06-19 21:34:04 +00:00
|
|
|
const [editingBookmark, setEditingBookmark] = useState<Bookmark | null>(null)
|
2026-06-18 19:33:26 +00:00
|
|
|
|
|
|
|
|
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
|
|
|
}, [])
|
|
|
|
|
|
2026-06-19 21:34:04 +00:00
|
|
|
async function deleteBookmark(bookmark: Bookmark) {
|
|
|
|
|
if (!window.confirm(`Delete "${bookmark.title}"?`)) return
|
|
|
|
|
setBookmarks((prev) => prev?.filter((b) => b.id !== bookmark.id) ?? prev)
|
|
|
|
|
try {
|
|
|
|
|
await api.deleteBookmark(bookmark.id)
|
|
|
|
|
} catch {
|
|
|
|
|
setBookmarks((prev) => (prev ? [...prev, bookmark] : prev))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
Add bulk delete-all for bookmarks (#20)
* Add editable display-name field to generic integrations
Lets users set a custom name for Proxmox, Docker, AWS, Remote Desktop,
Netbird, Cloudflare, Uptime Kuma, and Weather integrations, separate
from the host/IP field, mirroring the SSH host rename pattern.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4
* Surface the new-integration name field as a labeled input
The name field for new generic integrations was a faint header input
with only placeholder text, easy to miss. Move it into the form grid
as a proper labeled "Name" field next to the other connection fields.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4
* Add file upload for SSH private key and certificate fields
Lets users pick a key file from disk (e.g. ~/.ssh) instead of pasting
its contents into the Private Key / OPKSSH Certificate fields.
* Fix SSH private key paste corrupting multi-line PEM format
Private Key and Certificate fields were single-line <input> elements,
which strip newlines on paste and corrupt PEM-formatted keys (causing
'Unsupported key format' errors). Render them as multi-line textareas
instead so pasted keys keep their line breaks.
* Add JSON-converted bookmark import file for Archnest data import
Converts homarr-bookmarks.md into the format expected by /api/data/import.
* Auto-populate bookmark icons via favicon service in import JSON
Each bookmark now points to Google's favicon endpoint for its domain
instead of having no icon at all.
* Add bulk delete-all for bookmarks
Adds DELETE /api/bookmarks to clear every bookmark in one request, and a
"Delete All" button (with confirmation) on the BookNest page so re-imports
don't require deleting dozens of entries one at a time.
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-20 09:09:44 -04:00
|
|
|
async function deleteAllBookmarks() {
|
|
|
|
|
const count = bookmarks?.length ?? 0
|
|
|
|
|
if (count === 0) return
|
|
|
|
|
if (!window.confirm(`Delete all ${count} bookmark${count === 1 ? '' : 's'}? This cannot be undone.`)) return
|
|
|
|
|
const prevBookmarks = bookmarks
|
|
|
|
|
setBookmarks([])
|
|
|
|
|
try {
|
|
|
|
|
await api.deleteAllBookmarks()
|
|
|
|
|
} catch {
|
|
|
|
|
setBookmarks(prevBookmarks)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
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)
|
Fix favicon, dark select dropdowns, add brand bookmark icons and Help page
- Replace mislabeled Vite-logo favicon.svg with proper ArchNest mark
extracted from the logo, generated at 32/48/256px PNGs
- Force native <select>/<option> elements to render with the dark theme
(color-scheme + explicit colors) so options are readable
- Auto-detect real brand/service icons for bookmarks (AWS, Proxmox,
Azure, Docker, etc.) via the dashboard-icons CDN, with manual
override and graceful fallback to lucide icons
- Add a Help page with a guided tour of every page, linked from the
sidebar, top-bar search, and the user dropdown menu
2026-06-19 21:13:32 +00:00
|
|
|
.map((g) => ({ label: g.title, icons: g.links.slice(0, 5).map((l) => l.icon), count: g.links.length })),
|
2026-06-18 19:33:26 +00:00
|
|
|
[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-19 21:34:04 +00:00
|
|
|
{(showAddModal || editingBookmark) && (
|
|
|
|
|
<BookmarkModal
|
2026-06-18 19:33:26 +00:00
|
|
|
categories={categories}
|
2026-06-19 21:34:04 +00:00
|
|
|
bookmark={editingBookmark ?? undefined}
|
|
|
|
|
onClose={() => { setShowAddModal(false); setEditingBookmark(null) }}
|
2026-06-18 19:33:26 +00:00
|
|
|
onCreated={(b) => setBookmarks((prev) => [b, ...(prev ?? [])])}
|
2026-06-19 21:34:04 +00:00
|
|
|
onUpdated={(b) => setBookmarks((prev) => prev?.map((x) => (x.id === b.id ? b : x)) ?? prev)}
|
2026-06-18 19:33:26 +00:00
|
|
|
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 */}
|
Add bulk delete-all for bookmarks (#20)
* Add editable display-name field to generic integrations
Lets users set a custom name for Proxmox, Docker, AWS, Remote Desktop,
Netbird, Cloudflare, Uptime Kuma, and Weather integrations, separate
from the host/IP field, mirroring the SSH host rename pattern.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4
* Surface the new-integration name field as a labeled input
The name field for new generic integrations was a faint header input
with only placeholder text, easy to miss. Move it into the form grid
as a proper labeled "Name" field next to the other connection fields.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4
* Add file upload for SSH private key and certificate fields
Lets users pick a key file from disk (e.g. ~/.ssh) instead of pasting
its contents into the Private Key / OPKSSH Certificate fields.
* Fix SSH private key paste corrupting multi-line PEM format
Private Key and Certificate fields were single-line <input> elements,
which strip newlines on paste and corrupt PEM-formatted keys (causing
'Unsupported key format' errors). Render them as multi-line textareas
instead so pasted keys keep their line breaks.
* Add JSON-converted bookmark import file for Archnest data import
Converts homarr-bookmarks.md into the format expected by /api/data/import.
* Auto-populate bookmark icons via favicon service in import JSON
Each bookmark now points to Google's favicon endpoint for its domain
instead of having no icon at all.
* Add bulk delete-all for bookmarks
Adds DELETE /api/bookmarks to clear every bookmark in one request, and a
"Delete All" button (with confirmation) on the BookNest page so re-imports
don't require deleting dozens of entries one at a time.
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-20 09:09:44 -04:00
|
|
|
<div className="flex items-center justify-between 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>
|
Add bulk delete-all for bookmarks (#20)
* Add editable display-name field to generic integrations
Lets users set a custom name for Proxmox, Docker, AWS, Remote Desktop,
Netbird, Cloudflare, Uptime Kuma, and Weather integrations, separate
from the host/IP field, mirroring the SSH host rename pattern.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4
* Surface the new-integration name field as a labeled input
The name field for new generic integrations was a faint header input
with only placeholder text, easy to miss. Move it into the form grid
as a proper labeled "Name" field next to the other connection fields.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4
* Add file upload for SSH private key and certificate fields
Lets users pick a key file from disk (e.g. ~/.ssh) instead of pasting
its contents into the Private Key / OPKSSH Certificate fields.
* Fix SSH private key paste corrupting multi-line PEM format
Private Key and Certificate fields were single-line <input> elements,
which strip newlines on paste and corrupt PEM-formatted keys (causing
'Unsupported key format' errors). Render them as multi-line textareas
instead so pasted keys keep their line breaks.
* Add JSON-converted bookmark import file for Archnest data import
Converts homarr-bookmarks.md into the format expected by /api/data/import.
* Auto-populate bookmark icons via favicon service in import JSON
Each bookmark now points to Google's favicon endpoint for its domain
instead of having no icon at all.
* Add bulk delete-all for bookmarks
Adds DELETE /api/bookmarks to clear every bookmark in one request, and a
"Delete All" button (with confirmation) on the BookNest page so re-imports
don't require deleting dozens of entries one at a time.
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-20 09:09:44 -04:00
|
|
|
{bookmarks.length > 0 && (
|
|
|
|
|
<button
|
|
|
|
|
onClick={deleteAllBookmarks}
|
|
|
|
|
className="flex items-center gap-1.5 cursor-pointer transition-colors whitespace-nowrap"
|
|
|
|
|
style={{
|
|
|
|
|
fontSize: '11px',
|
|
|
|
|
fontWeight: 500,
|
|
|
|
|
color: '#7A7D85',
|
|
|
|
|
backgroundColor: 'transparent',
|
|
|
|
|
border: '1px solid rgba(231, 76, 60, 0.2)',
|
|
|
|
|
borderRadius: '8px',
|
|
|
|
|
padding: '6px 10px',
|
|
|
|
|
}}
|
|
|
|
|
onMouseEnter={(e) => { e.currentTarget.style.color = '#E74C3C'; e.currentTarget.style.borderColor = 'rgba(231, 76, 60, 0.5)' }}
|
|
|
|
|
onMouseLeave={(e) => { e.currentTarget.style.color = '#7A7D85'; e.currentTarget.style.borderColor = 'rgba(231, 76, 60, 0.2)' }}
|
|
|
|
|
>
|
|
|
|
|
<Trash2 size={12} />
|
|
|
|
|
Delete All
|
|
|
|
|
</button>
|
|
|
|
|
)}
|
2026-06-18 18:25:19 +00:00
|
|
|
</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' }}>
|
Fix favicon, dark select dropdowns, add brand bookmark icons and Help page
- Replace mislabeled Vite-logo favicon.svg with proper ArchNest mark
extracted from the logo, generated at 32/48/256px PNGs
- Force native <select>/<option> elements to render with the dark theme
(color-scheme + explicit colors) so options are readable
- Auto-detect real brand/service icons for bookmarks (AWS, Proxmox,
Azure, Docker, etc.) via the dashboard-icons CDN, with manual
override and graceful fallback to lucide icons
- Add a Help page with a guided tour of every page, linked from the
sidebar, top-bar search, and the user dropdown menu
2026-06-19 21:13:32 +00:00
|
|
|
{qa.icons.map((icon, i) => (
|
2026-06-18 19:33:26 +00:00
|
|
|
<div key={i} style={{ width: '22px', height: '22px', borderRadius: '6px', backgroundColor: 'rgba(200,164,52,0.08)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
Fix favicon, dark select dropdowns, add brand bookmark icons and Help page
- Replace mislabeled Vite-logo favicon.svg with proper ArchNest mark
extracted from the logo, generated at 32/48/256px PNGs
- Force native <select>/<option> elements to render with the dark theme
(color-scheme + explicit colors) so options are readable
- Auto-detect real brand/service icons for bookmarks (AWS, Proxmox,
Azure, Docker, etc.) via the dashboard-icons CDN, with manual
override and graceful fallback to lucide icons
- Add a Help page with a guided tour of every page, linked from the
sidebar, top-bar search, and the user dropdown menu
2026-06-19 21:13:32 +00:00
|
|
|
<BookmarkIcon icon={icon} size={12} style={{ color: '#C8A434' }} />
|
2026-06-18 19:33:26 +00:00
|
|
|
</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) => (
|
2026-06-19 21:34:04 +00:00
|
|
|
<LinkRow
|
|
|
|
|
key={link.id}
|
|
|
|
|
bookmark={link}
|
|
|
|
|
onToggleFavorite={() => toggleFavorite(link)}
|
|
|
|
|
onEdit={() => setEditingBookmark(link)}
|
|
|
|
|
onDelete={() => deleteBookmark(link)}
|
|
|
|
|
/>
|
2026-06-18 19:33:26 +00:00
|
|
|
))}
|
|
|
|
|
</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 ? (
|
Fix favicon, dark select dropdowns, add brand bookmark icons and Help page
- Replace mislabeled Vite-logo favicon.svg with proper ArchNest mark
extracted from the logo, generated at 32/48/256px PNGs
- Force native <select>/<option> elements to render with the dark theme
(color-scheme + explicit colors) so options are readable
- Auto-detect real brand/service icons for bookmarks (AWS, Proxmox,
Azure, Docker, etc.) via the dashboard-icons CDN, with manual
override and graceful fallback to lucide icons
- Add a Help page with a guided tour of every page, linked from the
sidebar, top-bar search, and the user dropdown menu
2026-06-19 21:13:32 +00:00
|
|
|
favorites.map((f) => (
|
|
|
|
|
<a key={f.id} href={f.url} target="_blank" rel="noreferrer" className="flex items-center gap-3" style={{ padding: '8px 0', textDecoration: 'none' }}>
|
|
|
|
|
<BookmarkIcon icon={f.icon} size={17} style={{ color: '#C8A434' }} />
|
|
|
|
|
<span style={{ fontSize: '13px', color: '#E8E6E0' }}>{f.title}</span>
|
|
|
|
|
</a>
|
|
|
|
|
))
|
2026-06-18 19:33:26 +00:00
|
|
|
) : (
|
|
|
|
|
<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>
|
|
|
|
|
)
|
|
|
|
|
}
|