Add Apps page with local icons, favicon, and React Router navigation
ci/woodpecker/push/woodpecker Pipeline was successful

- Move app links to dedicated /apps page with grouped icon tiles
- Download all icons locally to public/icons/ (no CDN dependency)
- Add favicon and replace header logo-mark with custom icon
- Fix icon names: synology, termix, docsight-light, dbgate (png)
- Add nav tabs in header for Dashboard/Apps routing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-15 20:12:13 +02:00
parent cbf27eae14
commit 48d6584b7d
43 changed files with 340 additions and 79 deletions
+48 -36
View File
@@ -1,3 +1,4 @@
import { BrowserRouter, Routes, Route } from 'react-router-dom'
import { Header } from './components/Header'
import { ProxmoxWidget } from './components/widgets/ProxmoxWidget'
import { NasWidget } from './components/widgets/NasWidget'
@@ -17,49 +18,60 @@ import { LokiWidget } from './components/widgets/LokiWidget'
import { JellyfinWidget } from './components/widgets/JellyfinWidget'
import { NavidromeWidget } from './components/widgets/NavidromeWidget'
import { RommWidget } from './components/widgets/RommWidget'
import { AppsPage } from './pages/AppsPage'
export default function App() {
function DashboardPage() {
return (
<>
<div className="blob blob-1" />
<div className="blob blob-2" />
<div className="shell">
<Header />
<div className="section-label">Infrastructure</div>
<div className="infra-grid">
<ProxmoxWidget />
<NasWidget />
<AdGuardWidget />
<HeadscaleWidget />
<FritzboxWidget />
</div>
<div className="section-label">Infrastructure</div>
<div className="infra-grid">
<ProxmoxWidget />
<NasWidget />
<AdGuardWidget />
<HeadscaleWidget />
<FritzboxWidget />
</div>
<div className="section-label">Media</div>
<div className="infra-grid">
<JellyfinWidget />
<NavidromeWidget />
<RommWidget />
<ArrCalendarWidget />
<ArrStatsWidget />
<QbittorrentWidget />
</div>
<div className="section-label">Media</div>
<div className="infra-grid">
<JellyfinWidget />
<NavidromeWidget />
<RommWidget />
<ArrCalendarWidget />
<ArrStatsWidget />
<QbittorrentWidget />
</div>
<div className="section-label">Monitoring</div>
<div className="infra-grid">
<KumaWidget />
<CrowdSecWidget />
<GrafanaWidget />
<PrometheusWidget />
<LokiWidget />
</div>
<div className="section-label">Monitoring</div>
<div className="infra-grid">
<KumaWidget />
<CrowdSecWidget />
<GrafanaWidget />
<PrometheusWidget />
<LokiWidget />
</div>
<div className="section-label">Access</div>
<div className="infra-grid">
<AuthentikWidget />
<VaultwardenWidget />
</div>
<div className="section-label">Access</div>
<div className="infra-grid">
<AuthentikWidget />
<VaultwardenWidget />
</div>
</>
)
}
export default function App() {
return (
<BrowserRouter>
<div className="blob blob-1" />
<div className="blob blob-2" />
<div className="shell">
<Header />
<Routes>
<Route path="/" element={<DashboardPage />} />
<Route path="/apps" element={<AppsPage />} />
</Routes>
</div>
</BrowserRouter>
)
}
+41
View File
@@ -0,0 +1,41 @@
import { useState } from 'react'
import { appGroups } from '../config/apps'
function AppTile({ name, url, icon }: { name: string; url: string; icon: string }) {
const [imgError, setImgError] = useState(false)
return (
<a href={url} target="_blank" rel="noopener noreferrer" className="app-tile">
{!imgError ? (
<img
src={icon}
alt={name}
className="app-icon"
onError={() => setImgError(true)}
/>
) : (
<div className="app-icon-fallback" style={{ display: 'flex' }}>
{name.charAt(0).toUpperCase()}
</div>
)}
<span className="app-name">{name}</span>
</a>
)
}
export function AppsSection() {
return (
<>
{appGroups.map(group => (
<div key={group.name} className="app-group">
<div className="app-group-label">{group.name}</div>
<div className="app-grid">
{group.apps.map(app => (
<AppTile key={app.name} {...app} />
))}
</div>
</div>
))}
</>
)
}
+6 -1
View File
@@ -1,4 +1,5 @@
import { useState, useEffect } from 'react'
import { NavLink } from 'react-router-dom'
export function Header() {
const [time, setTime] = useState('')
@@ -18,13 +19,17 @@ export function Header() {
return (
<header>
<div className="logo">
<div className="logo-mark">S</div>
<img src="/icons/favicon.png" alt="logo" className="logo-mark" />
<div>
<div className="logo-text">syco.me</div>
<div className="logo-sub">homelab dashboard</div>
</div>
</div>
<div className="topbar-right">
<nav className="nav-tabs">
<NavLink to="/" end className={({ isActive }) => `nav-tab${isActive ? ' nav-tab-active' : ''}`}>Dashboard</NavLink>
<NavLink to="/apps" className={({ isActive }) => `nav-tab${isActive ? ' nav-tab-active' : ''}`}>Apps</NavLink>
</nav>
<div className="clock-widget">
<div className="clock-time">{time}</div>
<div className="clock-date">{date}</div>
+72
View File
@@ -0,0 +1,72 @@
const ICONS = '/icons'
export interface AppLink {
name: string
url: string
icon: string
}
export interface AppGroup {
name: string
apps: AppLink[]
}
export const appGroups: AppGroup[] = [
{
name: 'Infrastructure',
apps: [
{ name: 'Proxmox', url: 'https://proxmox.syco.me', icon: `${ICONS}/proxmox.svg` },
{ name: 'Synology NAS', url: 'https://nas.syco.me', icon: `${ICONS}/synology.svg` },
{ name: 'AdGuard Home', url: 'https://adguard.syco.me', icon: `${ICONS}/adguard-home.svg` },
{ name: 'Headscale', url: 'https://headscale.syco.me/admin', icon: `${ICONS}/headscale.svg` },
{ name: 'FritzBox', url: 'http://fritz.box', icon: `${ICONS}/Fritz!_Logo.svg.png` },
{ name: 'Portainer', url: 'https://portainer.syco.me', icon: `${ICONS}/portainer.svg` },
{ name: 'CrowdSec', url: 'https://crowdsec.syco.me', icon: `${ICONS}/crowdsec.svg` },
],
},
{
name: 'Auth & Access',
apps: [
{ name: 'Authentik', url: 'https://auth.syco.me', icon: `${ICONS}/authentik.svg` },
{ name: 'Vaultwarden', url: 'https://vaultwarden.syco.me', icon: `${ICONS}/vaultwarden.svg` },
{ name: 'Termix', url: 'https://termix.syco.me', icon: `${ICONS}/termix.svg` },
],
},
{
name: 'Media',
apps: [
{ name: 'Jellyfin', url: 'https://jellyfin.syco.me', icon: `${ICONS}/jellyfin.svg` },
{ name: 'Navidrome', url: 'https://music.syco.me', icon: `${ICONS}/navidrome.svg` },
{ name: 'Overseerr', url: 'https://overseerr.syco.me', icon: `${ICONS}/overseerr.svg` },
{ name: 'Radarr', url: 'https://radarr.syco.me', icon: `${ICONS}/radarr.svg` },
{ name: 'Sonarr', url: 'https://sonarr.syco.me', icon: `${ICONS}/sonarr.svg` },
{ name: 'Lidarr', url: 'https://lidarr.syco.me', icon: `${ICONS}/lidarr.svg` },
{ name: 'Prowlarr', url: 'https://prowlarr.syco.me', icon: `${ICONS}/prowlarr.svg` },
{ name: 'Bazarr', url: 'https://bazarr.syco.me', icon: `${ICONS}/bazarr.svg` },
{ name: 'qBittorrent', url: 'https://bittorrent.syco.me', icon: `${ICONS}/qbittorrent.svg` },
{ name: 'RomM', url: 'https://romm.syco.me', icon: `${ICONS}/romm.svg` },
{ name: 'Shoko', url: 'http://shoko.internal', icon: `${ICONS}/shoko-server.svg` },
],
},
{
name: 'Monitoring',
apps: [
{ name: 'Grafana', url: 'https://grafana.syco.me', icon: `${ICONS}/grafana.svg` },
{ name: 'Uptime Kuma', url: 'https://uptime.syco.me', icon: `${ICONS}/uptime-kuma.svg` },
{ name: 'Update Dashboard', url: 'https://update-dashboard.syco.me', icon: `${ICONS}/linux-update-dashboard.svg` },
],
},
{
name: 'Dev & Tools',
apps: [
{ name: 'Gitea', url: 'https://git.syco.me', icon: `${ICONS}/gitea.svg` },
{ name: 'Woodpecker', url: 'https://woodpecker.syco.me', icon: `${ICONS}/woodpecker-ci.svg` },
{ name: 'DbGate', url: 'https://dbgate.syco.me', icon: `${ICONS}/dbgate.png` },
{ name: 'IT-Tools', url: 'https://it-tools.syco.me', icon: `${ICONS}/it-tools.svg` },
{ name: 'Docsight', url: 'http://docsight.internal', icon: `${ICONS}/docsight-light.svg` },
{ name: 'Firefly III', url: 'https://firefly.syco.me', icon: `${ICONS}/firefly-iii.svg` },
{ name: 'Copyparty', url: 'https://copyparty.syco.me', icon: `${ICONS}/copyparty.svg` },
{ name: 'Yu-Gi-Oh', url: 'https://yugioh.syco.me', icon: `${ICONS}/yugiohCard.jpg` },
],
},
]
+89 -3
View File
@@ -63,10 +63,8 @@ header {
.logo { display: flex; align-items: center; gap: 10px; }
.logo-mark {
width: 32px; height: 32px;
background: linear-gradient(135deg, var(--accent), var(--accent2));
border-radius: 8px;
display: flex; align-items: center; justify-content: center;
font-size: 14px; font-weight: 800; letter-spacing: -1px; color: #fff;
object-fit: contain;
}
.logo-text { font-size: 18px; font-weight: 700; letter-spacing: -0.5px; }
.logo-sub { font-family: 'JetBrains Mono', monospace; font-size: 11px; color: var(--muted); }
@@ -226,6 +224,94 @@ header {
.topbar-right { gap: 12px; }
}
/* ── Nav tabs ── */
.nav-tabs {
display: flex;
gap: 4px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
padding: 3px;
}
.nav-tab {
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
font-weight: 500;
padding: 5px 14px;
border-radius: 6px;
text-decoration: none;
color: var(--text2);
transition: background 0.15s, color 0.15s;
}
.nav-tab:hover { color: var(--text); }
.nav-tab-active { background: var(--surface2); color: var(--text); }
/* ── Apps Section ── */
.app-group { margin-bottom: 28px; }
.app-group-label {
font-family: 'JetBrains Mono', monospace;
font-size: 10px; font-weight: 500;
letter-spacing: 2px; text-transform: uppercase;
color: var(--muted);
margin-bottom: 12px;
}
.app-grid {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.app-tile {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 14px 12px;
width: 90px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
text-decoration: none;
color: var(--text2);
font-size: 11px;
text-align: center;
transition: border-color 0.2s, transform 0.2s, color 0.2s;
cursor: pointer;
}
.app-tile:hover {
border-color: var(--accent);
transform: translateY(-2px);
color: var(--text);
}
.app-icon {
width: 36px;
height: 36px;
object-fit: contain;
}
.app-icon-fallback {
width: 36px;
height: 36px;
border-radius: 8px;
background: var(--surface2);
align-items: center;
justify-content: center;
font-size: 16px;
font-weight: 700;
color: var(--accent);
}
.app-name {
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
color: inherit;
line-height: 1.3;
word-break: break-word;
}
/* ── Scrollbar ── */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; }
+10
View File
@@ -0,0 +1,10 @@
import { AppsSection } from '../components/AppsSection'
export function AppsPage() {
return (
<div className="shell">
<div className="section-label">Apps</div>
<AppsSection />
</div>
)
}