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
This commit is contained in:
Claude 2026-06-19 21:13:32 +00:00
parent 3d9c4c65c2
commit 57086d2f6f
No known key found for this signature in database
12 changed files with 384 additions and 27 deletions

View file

@ -2,9 +2,11 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png" />
<link rel="icon" type="image/png" sizes="48x48" href="/favicon-48.png" />
<link rel="icon" type="image/png" href="/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>archnest</title>
<title>ArchNest</title>
</head>
<body>
<div id="root"></div>

BIN
public/favicon-32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 805 B

BIN
public/favicon-48.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

BIN
public/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 9.3 KiB

View file

@ -12,6 +12,7 @@ import Containers from './pages/Containers'
import RemoteDesktop from './pages/RemoteDesktop'
import HostMetrics from './pages/HostMetrics'
import Settings from './pages/Settings'
import Help from './pages/Help'
import Login from './pages/Login'
import Enrollment from './pages/Enrollment'
import { useAuth } from './lib/AuthContext'
@ -93,6 +94,7 @@ function Dashboard() {
<Route path="/remote-desktop" element={<RemoteDesktop />} />
<Route path="/host-metrics" element={<HostMetrics />} />
<Route path="/settings" element={<Settings />} />
<Route path="/help" element={<Help />} />
</Routes>
</section>
</main>

View file

@ -11,6 +11,7 @@ import {
MonitorSmartphone,
Gauge,
Settings,
HelpCircle,
ChevronLeft,
ChevronRight,
} from 'lucide-react'
@ -32,6 +33,7 @@ const navItems = [
{ icon: MonitorSmartphone, label: 'Remote Desktop', route: '/remote-desktop' },
{ icon: Gauge, label: 'Host Metrics', route: '/host-metrics' },
{ icon: Settings, label: 'Settings', route: '/settings' },
{ icon: HelpCircle, label: 'Help', route: '/help' },
]
export default function Sidebar({ collapsed, onToggle }: SidebarProps) {

View file

@ -10,6 +10,7 @@ const pageTitles: Record<string, string> = {
'/booknest': 'BookNest',
'/terminal': 'Terminal',
'/settings': 'Settings',
'/help': 'Help',
}
const pageSubtitles: Record<string, string> = {
@ -27,6 +28,7 @@ const staticPages: { name: string; path: string }[] = [
{ name: 'Remote Desktop', path: '/remote-desktop' },
{ name: 'Host Metrics', path: '/host-metrics' },
{ name: 'Settings', path: '/settings' },
{ name: 'Help', path: '/help' },
]
type SearchResult =
@ -225,10 +227,13 @@ export default function TopBar() {
<Shield size={14} />
<span>Security</span>
</a>
<a href="#" className="flex items-center gap-2.5 px-3 py-2 text-[12px] text-text-secondary hover:text-text-primary hover:bg-page transition-colors no-underline">
<button
onClick={() => { setUserMenuOpen(false); navigate('/help') }}
className="flex w-full items-center gap-2.5 px-3 py-2 text-[12px] text-text-secondary hover:text-text-primary hover:bg-page transition-colors cursor-pointer border-none bg-transparent text-left"
>
<HelpCircle size={14} />
<span>Help & Support</span>
</a>
</button>
</div>
<div className="border-t border-border py-1">
<button

View file

@ -38,3 +38,15 @@ html, body {
width: 100vw;
overflow: hidden;
}
/* Native <select> dropdown panels are OS/browser-rendered and ignore most
component styling without this, options render with a white background
and near-white text, making them unreadable against this dark theme. */
select {
color-scheme: dark;
}
select option {
background-color: #141518;
color: #E8E6E0;
}

132
src/lib/serviceIcons.ts Normal file
View file

@ -0,0 +1,132 @@
// Auto-suggests a real service/brand icon for a bookmark by matching its
// title (and URL host) against a curated alias list, pointing at the
// dashboard-icons CDN — the same icon pack used by most self-hosted
// dashboards (Homarr, Homepage, etc.) for exactly this purpose.
const ICON_CDN_BASE = 'https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg'
// key: lowercase phrase to match in the bookmark title or URL host.
// value: the icon slug in the dashboard-icons repo.
const SERVICE_ALIASES: Record<string, string> = {
aws: 'amazon-web-services',
'amazon web services': 'amazon-web-services',
ec2: 'amazon-web-services',
azure: 'azure',
'microsoft azure': 'azure',
gcp: 'google-cloud',
'google cloud': 'google-cloud',
proxmox: 'proxmox',
pve: 'proxmox',
docker: 'docker',
kubernetes: 'kubernetes',
k8s: 'kubernetes',
github: 'github',
gitlab: 'gitlab',
gitea: 'gitea',
bitbucket: 'bitbucket',
cloudflare: 'cloudflare',
netbird: 'netbird',
grafana: 'grafana',
prometheus: 'prometheus',
'uptime kuma': 'uptime-kuma',
'uptime-kuma': 'uptime-kuma',
nginx: 'nginx',
'nginx proxy manager': 'nginx-proxy-manager',
portainer: 'portainer',
jellyfin: 'jellyfin',
plex: 'plex',
sonarr: 'sonarr',
radarr: 'radarr',
wordpress: 'wordpress',
mysql: 'mysql',
mariadb: 'mariadb',
postgres: 'postgresql',
postgresql: 'postgresql',
mongodb: 'mongodb',
redis: 'redis',
'home assistant': 'home-assistant',
'home-assistant': 'home-assistant',
pihole: 'pi-hole',
'pi-hole': 'pi-hole',
traefik: 'traefik',
'visual studio code': 'visual-studio-code',
vscode: 'visual-studio-code',
slack: 'slack',
discord: 'discord',
notion: 'notion',
jira: 'jira',
confluence: 'confluence',
jenkins: 'jenkins',
ansible: 'ansible',
terraform: 'terraform',
vault: 'vault',
minio: 'minio',
truenas: 'truenas',
synology: 'synology',
unifi: 'unifi',
ubiquiti: 'unifi',
pfsense: 'pfsense',
opnsense: 'opnsense',
vmware: 'vmware',
vsphere: 'vmware',
esxi: 'vmware',
windows: 'windows',
linux: 'linux',
ubuntu: 'ubuntu',
debian: 'debian',
fedora: 'fedora',
archlinux: 'archlinux',
'arch linux': 'archlinux',
rancher: 'rancher',
jupyter: 'jupyter',
gitlabci: 'gitlab',
npm: 'npm',
nodejs: 'nodejs',
'node.js': 'nodejs',
python: 'python',
react: 'react',
vaultwarden: 'vaultwarden',
bitwarden: 'bitwarden',
netdata: 'netdata',
zabbix: 'zabbix',
nextcloud: 'nextcloud',
owncloud: 'owncloud',
gitkraken: 'gitkraken',
digitalocean: 'digitalocean',
'digital ocean': 'digitalocean',
linode: 'linode',
vultr: 'vultr',
heroku: 'heroku',
vercel: 'vercel',
netlify: 'netlify',
cpanel: 'cpanel',
webmin: 'webmin',
pterodactyl: 'pterodactyl',
rabbitmq: 'rabbitmq',
kafka: 'kafka',
elasticsearch: 'elasticsearch',
kibana: 'kibana',
logstash: 'logstash',
}
function normalize(s: string): string {
return s.toLowerCase().trim()
}
/** Best-effort guess at a real brand icon for a bookmark, or null if nothing matched. */
export function guessServiceIconUrl(title: string, url?: string): string | null {
const haystack = [normalize(title)]
if (url) {
try {
haystack.push(normalize(new URL(url).hostname))
} catch {
// not a valid absolute URL yet (e.g. still being typed) — ignore
}
}
for (const [alias, slug] of Object.entries(SERVICE_ALIASES)) {
if (haystack.some((h) => h.includes(alias))) {
return `${ICON_CDN_BASE}/${slug}.svg`
}
}
return null
}

View file

@ -42,6 +42,7 @@ import {
type LucideIcon,
} from 'lucide-react'
import { api, ApiError, type Bookmark, type BookmarkCategory } from '../lib/api'
import { guessServiceIconUrl } from '../lib/serviceIcons'
const ICONS: Record<string, LucideIcon> = {
server: Server,
@ -85,6 +86,30 @@ function resolveIcon(name: string | null | undefined): LucideIcon {
return ICONS[name.toLowerCase()] ?? Link2
}
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 }} />
}
const statusColors: Record<string, string> = {
online: '#2ECC71',
warning: '#E67E22',
@ -163,7 +188,6 @@ function Donut({ data, centerLabel }: { data: { name: string; value: number; col
}
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
@ -173,7 +197,7 @@ function LinkRow({ bookmark, onToggleFavorite }: { bookmark: Bookmark; onToggleF
className="flex items-center gap-2.5 cursor-pointer"
style={{ minWidth: 0, textDecoration: 'none' }}
>
<Icon size={16} style={{ color: '#C8A434', flexShrink: 0 }} />
<BookmarkIcon icon={bookmark.icon} size={16} style={{ color: '#C8A434' }} />
<span style={{ fontSize: '13px', color: '#E8E6E0', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{bookmark.title}</span>
</a>
<button
@ -201,11 +225,17 @@ function AddBookmarkModal({
const [title, setTitle] = useState('')
const [url, setUrl] = useState('')
const [icon, setIcon] = useState('link2')
const [iconMode, setIconMode] = useState<'auto' | 'manual'>('auto')
const [categoryId, setCategoryId] = useState<number | 'new' | ''>('')
const [newCategoryName, setNewCategoryName] = useState('')
const [error, setError] = useState('')
const [busy, setBusy] = useState(false)
useEffect(() => {
if (iconMode !== 'auto') return
setIcon(guessServiceIconUrl(title, url) ?? 'link2')
}, [title, url, iconMode])
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setError('')
@ -282,14 +312,45 @@ function AddBookmarkModal({
</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 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>
)}
</div>
<div className="flex flex-col gap-1.5">
@ -390,7 +451,7 @@ export default function BookNest() {
[...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 })),
.map((g) => ({ label: g.title, icons: g.links.slice(0, 5).map((l) => l.icon), count: g.links.length })),
[groups]
)
@ -464,9 +525,9 @@ export default function BookNest() {
<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) => (
{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' }} />
<BookmarkIcon icon={icon} size={12} style={{ color: '#C8A434' }} />
</div>
))}
</div>
@ -503,15 +564,12 @@ export default function BookNest() {
<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>
)
})
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>
))
) : (
<p style={{ fontSize: '12px', color: '#7A7D85' }}>No favorites yet</p>
)}

145
src/pages/Help.tsx Normal file
View file

@ -0,0 +1,145 @@
import {
LayoutGrid,
Server,
Bookmark,
Terminal,
Waypoints,
FolderOpen,
Box,
MonitorSmartphone,
Gauge,
Settings,
Search,
type LucideIcon,
} from 'lucide-react'
const cardBase: React.CSSProperties = {
backgroundColor: 'rgba(10, 10, 12, 0.92)',
border: '1px solid rgba(200, 164, 52, 0.08)',
borderRadius: '12px',
padding: '22px',
}
interface GuideEntry {
icon: LucideIcon
title: string
description: string
tips?: string[]
}
const guideEntries: GuideEntry[] = [
{
icon: LayoutGrid,
title: 'Glance',
description:
'The home dashboard. Shows overall system health, a rollup of connected integrations, recent activity, and shortcuts into the rest of the app.',
tips: ['Click "Connected Integrations" entries to jump straight to Infrastructure.'],
},
{
icon: Server,
title: 'Infrastructure',
description:
'Lists every connected integration (Proxmox, AWS, Docker, NetBird, Cloudflare, Uptime Kuma, Weather, SSH hosts) and the live resources/health each one reports.',
tips: ['Add new integrations from Settings → Integrations — they show up here automatically.'],
},
{
icon: Bookmark,
title: 'BookNest',
description: 'A categorized bookmark manager for the links you use most — internal tools, dashboards, docs, anything.',
tips: [
'Icons are auto-detected from the title or URL (e.g. typing "Proxmox" picks up the real Proxmox logo) — pick "Choose manually" if it guesses wrong.',
'Star a bookmark to pin it to the Favorites panel.',
],
},
{
icon: Terminal,
title: 'Terminal',
description: 'A full SSH terminal to any host you\'ve added as an integration — supports tabs, split panes, jump hosts, and certificate auth.',
tips: ['Session output can be logged; theme and font preferences are remembered between visits.'],
},
{
icon: Waypoints,
title: 'Tunnels',
description: 'Local, remote, and dynamic (SOCKS5) SSH tunnels. Tunnels can be set to auto-start whenever the backend boots.',
},
{
icon: FolderOpen,
title: 'Files',
description: 'Browse, edit, upload, and download files over SFTP on any connected SSH host — and transfer files directly between two hosts without round-tripping through your machine.',
tips: ['Use the "Send to another host" action on a file row to start a host-to-host transfer; progress shows live in the panel at the bottom.'],
},
{
icon: Box,
title: 'Containers',
description: 'Manage Docker containers on remote hosts — start, stop, view logs, and exec into a running container.',
},
{
icon: MonitorSmartphone,
title: 'Remote Desktop',
description: 'RDP, VNC, and Telnet sessions to remote machines, streamed through the built-in Guacamole proxy — no separate client needed.',
},
{
icon: Gauge,
title: 'Host Metrics',
description: 'Live CPU, memory, disk, network, listening-port, firewall, process, and login-activity widgets for any SSH-managed host.',
},
{
icon: Settings,
title: 'Settings',
description: 'Your profile, integrations, appearance, notifications, and full data export/import (back up or migrate every integration, bookmark, and tunnel as a single JSON file).',
},
{
icon: Search,
title: 'Search (top bar)',
description: 'The search box at the top of every page looks across pages, integrations, and bookmarks at once — press Enter to jump to the top match.',
},
]
export default function Help() {
return (
<div className="p-8" style={{ maxWidth: '1100px' }}>
<div style={{ marginBottom: '28px' }}>
<h1 style={{ fontSize: '22px', color: '#E8E6E0', fontWeight: 700, marginBottom: '6px' }}>How ArchNest works</h1>
<p style={{ fontSize: '13px', color: '#7A7D85' }}>
A quick tour of every page and what it's for. Use the sidebar to navigate, or the search bar at the top to jump straight to something.
</p>
</div>
<div className="grid grid-cols-2 gap-4">
{guideEntries.map((entry) => {
const Icon = entry.icon
return (
<div key={entry.title} style={cardBase}>
<div className="flex items-center gap-3" style={{ marginBottom: '10px' }}>
<div
className="flex items-center justify-center"
style={{
width: '36px',
height: '36px',
borderRadius: '8px',
backgroundColor: 'rgba(200,164,52,0.1)',
border: '1px solid rgba(200,164,52,0.18)',
flexShrink: 0,
}}
>
<Icon size={18} style={{ color: '#C8A434' }} />
</div>
<h3 style={{ fontSize: '14px', color: '#E8E6E0', fontWeight: 600 }}>{entry.title}</h3>
</div>
<p style={{ fontSize: '12.5px', color: '#A8A6A0', lineHeight: 1.6 }}>{entry.description}</p>
{entry.tips && entry.tips.length > 0 && (
<ul style={{ marginTop: '10px', paddingLeft: '16px', display: 'flex', flexDirection: 'column', gap: '4px' }}>
{entry.tips.map((tip, i) => (
<li key={i} style={{ fontSize: '11.5px', color: '#7A7D85', lineHeight: 1.5 }}>
{tip}
</li>
))}
</ul>
)}
</div>
)
})}
</div>
</div>
)
}