From 2d6711f4c775a653c13fc2a0ac5b3d9d6f21e72c Mon Sep 17 00:00:00 2001 From: Eyejoker Date: Tue, 28 Apr 2026 10:53:13 +0900 Subject: [PATCH] Unify dashboard mobile navigation (#54) --- apps/dashboard/src/App.tsx | 354 +++---------------------- apps/dashboard/src/DashboardNav.tsx | 394 ++++++++++++++++++++++++++++ apps/dashboard/src/styles.css | 286 +++++++++----------- 3 files changed, 554 insertions(+), 480 deletions(-) create mode 100644 apps/dashboard/src/DashboardNav.tsx diff --git a/apps/dashboard/src/App.tsx b/apps/dashboard/src/App.tsx index 7592895..1777e03 100644 --- a/apps/dashboard/src/App.tsx +++ b/apps/dashboard/src/App.tsx @@ -1,17 +1,7 @@ import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; -import { - Activity, - Clock, - Download, - Gauge, - Inbox as InboxIcon, - MessageSquare, - RefreshCw, - Send, - Settings, -} from 'lucide-react'; +import { Send } from 'lucide-react'; import { type ClaudeAccountSummary, @@ -61,6 +51,12 @@ import { useSelectedRoomActivity, type RoomActivityMap, } from './useRoomActivity'; +import { + SectionNav, + SideRail, + type DashboardFreshness, + type DashboardView, +} from './DashboardNav'; import './styles.css'; interface DashboardState { @@ -74,13 +70,6 @@ type InboxItem = DashboardOverview['inbox'][number]; type RiskLevel = 'ok' | 'warn' | 'critical'; type UsageGroup = 'primary' | 'codex'; type UsageLimitWindow = 'h5' | 'd7'; -type DashboardView = - | 'usage' - | 'inbox' - | 'health' - | 'rooms' - | 'scheduled' - | 'settings'; type TaskGroupKey = 'watchers' | 'scheduled' | 'paused' | 'completed'; type TaskResultTone = 'ok' | 'fail' | 'none'; type TaskActionKey = @@ -91,7 +80,7 @@ type InboxActionKey = `${string}:${DashboardInboxAction}`; type ServiceActionKey = 'stack:restart'; type InboxFilter = 'all' | InboxItem['kind']; type HealthLevel = 'ok' | 'stale' | 'down'; -type FreshnessLevel = 'fresh' | 'stale' | 'offline'; +type FreshnessLevel = DashboardFreshness; type BeforeInstallPromptEvent = Event & { prompt: () => Promise; @@ -1005,26 +994,6 @@ function inboxTargetHref(item: InboxItem): string | null { return null; } -const NAV_ICONS: Record = { - usage: , - inbox: , - health: , - rooms: , - scheduled: , - settings: , -}; - -function navItems(t: Messages) { - return [ - { href: '#/rooms', label: t.nav.rooms, view: 'rooms' as const }, - { href: '#/inbox', label: t.nav.inbox, view: 'inbox' as const }, - { href: '#/scheduled', label: t.nav.scheduled, view: 'scheduled' as const }, - { href: '#/health', label: t.nav.health, view: 'health' as const }, - { href: '#/usage', label: t.nav.usage, view: 'usage' as const }, - { href: '#/settings', label: t.nav.settings, view: 'settings' as const }, - ]; -} - function EmptyState({ children }: { children: ReactNode }) { return
{children}
; } @@ -1668,269 +1637,6 @@ function LoadingSkeleton({ t }: { t: Messages }) { ); } -function SideRail({ - activeView, - canInstall, - data, - installed, - onNavigate, - onInstall, - onRefresh, - online, - offlineReady, - refreshing, - secureContext, - t, -}: { - activeView: DashboardView; - canInstall: boolean; - data: DashboardState | null; - installed: boolean; - onNavigate: (view: DashboardView) => void; - onInstall: () => void; - onRefresh: () => void; - online: boolean; - offlineReady: boolean; - refreshing: boolean; - secureContext: boolean; - t: Messages; -}) { - const pwaState = !secureContext - ? t.pwa.secureRequired - : installed - ? t.pwa.installed - : offlineReady - ? t.pwa.ready - : t.pwa.app; - - const stats = data - ? (() => { - const queue = data.snapshots.reduce( - (acc, snapshot) => { - for (const entry of snapshot.entries) { - acc.pendingTasks += entry.pendingTasks; - if (entry.pendingMessages) acc.pendingMessageRooms += 1; - if (entry.status === 'processing') acc.processing += 1; - } - return acc; - }, - { pendingTasks: 0, pendingMessageRooms: 0, processing: 0 }, - ); - return { - rooms: data.overview.rooms, - inbox: data.overview.inbox.length, - processing: queue.processing, - pendingTasks: queue.pendingTasks, - pendingMessageRooms: queue.pendingMessageRooms, - }; - })() - : null; - - return ( - - ); -} - -function SectionNav({ - activeView, - drawerOpen, - freshness, - installed, - locale, - canInstall, - onCloseDrawer, - onInstall, - onLocaleChange, - onNavigate, - onOpenDrawer, - offlineReady, - refreshing, - onRefresh, - secureContext, - t, -}: { - activeView: DashboardView; - drawerOpen: boolean; - freshness: FreshnessLevel; - installed: boolean; - locale: Locale; - canInstall: boolean; - onCloseDrawer: () => void; - onInstall: () => void; - onLocaleChange: (locale: Locale) => void; - onNavigate: (view: DashboardView) => void; - onOpenDrawer: () => void; - offlineReady: boolean; - refreshing: boolean; - onRefresh: () => void; - secureContext: boolean; - t: Messages; -}) { - const activeLabel = - navItems(t).find((item) => item.view === activeView)?.label ?? t.nav.rooms; - - return ( - <> - - - {drawerOpen ? ( - <> - - - -
- - {!secureContext - ? t.pwa.secureRequired - : installed - ? t.pwa.installed - : offlineReady - ? t.pwa.ready - : t.pwa.app} - - {canInstall ? ( - - ) : null} -
- - - ) : null} - - ); -} - function ControlRail({ canInstall, data, @@ -4024,6 +3730,30 @@ function TaskPanel({ ); } +function DashboardErrorCard({ + error, + onRetry, + refreshing, + t, +}: { + error: string; + onRetry: () => void; + refreshing: boolean; + t: Messages; +}) { + return ( +
+ + {t.error.api} + {humanizeError(error, t)} + + +
+ ); +} + function App() { const [data, setData] = useState(null); const [error, setError] = useState(null); @@ -4449,32 +4179,30 @@ function App() { setDrawerOpen(false)} onInstall={() => void handleInstallApp()} - onLocaleChange={setDashboardLocale} onNavigate={navigateToView} onOpenDrawer={() => setDrawerOpen(true)} offlineReady={offlineReady} onRefresh={() => void refresh(true)} + online={online} refreshing={refreshing} secureContext={secureContext} t={t} /> {error ? ( -
- - {t.error.api} - {humanizeError(error, t)} - - -
+ void refresh(true)} + refreshing={refreshing} + t={t} + /> ) : null} {data ? ( diff --git a/apps/dashboard/src/DashboardNav.tsx b/apps/dashboard/src/DashboardNav.tsx new file mode 100644 index 0000000..217587a --- /dev/null +++ b/apps/dashboard/src/DashboardNav.tsx @@ -0,0 +1,394 @@ +import { type ReactNode } from 'react'; +import { + Activity, + Clock, + Download, + Gauge, + Inbox as InboxIcon, + MessageSquare, + RefreshCw, + Settings, +} from 'lucide-react'; + +import { type DashboardOverview, type StatusSnapshot } from './api'; +import { type Messages } from './i18n'; + +export type DashboardView = + | 'usage' + | 'inbox' + | 'health' + | 'rooms' + | 'scheduled' + | 'settings'; +export type DashboardFreshness = 'fresh' | 'stale' | 'offline'; + +interface DashboardNavData { + overview: DashboardOverview; + snapshots: StatusSnapshot[]; +} + +const NAV_ICONS: Record = { + usage: , + inbox: , + health: , + rooms: , + scheduled: , + settings: , +}; + +function navItems(t: Messages) { + return [ + { href: '#/rooms', label: t.nav.rooms, view: 'rooms' as const }, + { href: '#/inbox', label: t.nav.inbox, view: 'inbox' as const }, + { href: '#/scheduled', label: t.nav.scheduled, view: 'scheduled' as const }, + { href: '#/health', label: t.nav.health, view: 'health' as const }, + { href: '#/usage', label: t.nav.usage, view: 'usage' as const }, + { href: '#/settings', label: t.nav.settings, view: 'settings' as const }, + ]; +} + +function navStats(data: DashboardNavData | null) { + if (!data) return null; + const queue = data.snapshots.reduce( + (acc, snapshot) => { + for (const entry of snapshot.entries) { + if (entry.status === 'processing') acc.processing += 1; + } + return acc; + }, + { processing: 0 }, + ); + return { + inbox: data.overview.inbox.length, + processing: queue.processing, + }; +} + +function navBadge( + view: DashboardView, + stats: ReturnType, +): number | null { + if (view === 'inbox' && stats && stats.inbox > 0) return stats.inbox; + if (view === 'rooms' && stats && stats.processing > 0) { + return stats.processing; + } + return null; +} + +function pwaStateLabel({ + installed, + offlineReady, + secureContext, + t, +}: { + installed: boolean; + offlineReady: boolean; + secureContext: boolean; + t: Messages; +}) { + if (!secureContext) return t.pwa.secureRequired; + if (installed) return t.pwa.installed; + if (offlineReady) return t.pwa.ready; + return t.pwa.app; +} + +function DashboardBrandLink({ + onNavigate, +}: { + onNavigate: (view: DashboardView) => void; +}) { + return ( + onNavigate('usage')} + title="EJClaw" + > + EJ + + ); +} + +function DashboardNavLinks({ + activeView, + data, + onAfterNavigate, + onNavigate, + t, +}: { + activeView: DashboardView; + data: DashboardNavData | null; + onAfterNavigate?: () => void; + onNavigate: (view: DashboardView) => void; + t: Messages; +}) { + const stats = navStats(data); + return ( + + ); +} + +function DashboardNavActions({ + canInstall, + installed, + offlineReady, + onInstall, + onRefresh, + online, + refreshing, + secureContext, + t, +}: { + canInstall: boolean; + installed: boolean; + offlineReady: boolean; + onInstall: () => void; + onRefresh: () => void; + online: boolean; + refreshing: boolean; + secureContext: boolean; + t: Messages; +}) { + const pwaState = pwaStateLabel({ installed, offlineReady, secureContext, t }); + const status = `${online ? t.pwa.online : t.pwa.offline} · ${pwaState}`; + return ( +
+
+ + {status} +
+
+ {canInstall ? ( + + ) : null} + +
+
+ ); +} + +export function SideRail({ + activeView, + canInstall, + data, + installed, + offlineReady, + onInstall, + onNavigate, + onRefresh, + online, + refreshing, + secureContext, + t, +}: { + activeView: DashboardView; + canInstall: boolean; + data: DashboardNavData | null; + installed: boolean; + offlineReady: boolean; + onInstall: () => void; + onNavigate: (view: DashboardView) => void; + onRefresh: () => void; + online: boolean; + refreshing: boolean; + secureContext: boolean; + t: Messages; +}) { + return ( + + ); +} + +export function SectionNav({ + activeView, + canInstall, + data, + drawerOpen, + freshness, + freshnessText, + installed, + onCloseDrawer, + onInstall, + onNavigate, + onOpenDrawer, + offlineReady, + onRefresh, + online, + refreshing, + secureContext, + t, +}: { + activeView: DashboardView; + canInstall: boolean; + data: DashboardNavData | null; + drawerOpen: boolean; + freshness: DashboardFreshness; + freshnessText: string; + installed: boolean; + onCloseDrawer: () => void; + onInstall: () => void; + onNavigate: (view: DashboardView) => void; + onOpenDrawer: () => void; + offlineReady: boolean; + onRefresh: () => void; + online: boolean; + refreshing: boolean; + secureContext: boolean; + t: Messages; +}) { + const activeLabel = + navItems(t).find((item) => item.view === activeView)?.label ?? t.nav.rooms; + + return ( + <> + + + {drawerOpen ? ( + <> + + + + + + + ) : null} + + ); +} diff --git a/apps/dashboard/src/styles.css b/apps/dashboard/src/styles.css index 30164cc..20f5c64 100644 --- a/apps/dashboard/src/styles.css +++ b/apps/dashboard/src/styles.css @@ -547,8 +547,16 @@ button:disabled { border-top: 1px solid var(--divider); } +.rail-status-line { + display: inline-flex; + min-width: 0; + align-items: center; + gap: 8px; +} + .rail-status-dot { display: inline-block; + flex: 0 0 auto; width: 8px; height: 8px; border-radius: 50%; @@ -565,6 +573,12 @@ button:disabled { background: var(--accent); } +.rail-actions { + display: grid; + gap: 4px; + justify-items: center; +} + .rail-btn { display: inline-flex; width: 32px; @@ -605,118 +619,10 @@ button:disabled { animation: rail-spin 0.9s linear infinite; } -.nav-drawer nav a.is-active, -.nav-drawer nav a[aria-current='page'] { - color: var(--bg); - background: var(--bg-ink); - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25); -} - -.side-stats { - display: grid; - gap: 1px; - border: 1px solid var(--panel-border); - border-radius: 10px; - overflow: hidden; - background: rgba(255, 255, 255, 0.02); -} - -.side-stat { - display: flex; - align-items: baseline; - justify-content: space-between; - gap: 8px; - padding: 7px 10px; - background: rgba(0, 0, 0, 0.15); -} - -.side-stat span { - color: var(--muted-soft); - font-size: 10px; - text-transform: uppercase; - letter-spacing: 0.04em; -} - -.side-stat strong { - color: var(--bg-ink); - font-size: 14px; - font-variant-numeric: tabular-nums; -} - -.side-refresh { - width: 100%; - min-height: 32px; - padding: 0 10px; - border-radius: 8px; - color: var(--bg-ink); - background: rgba(255, 255, 255, 0.05); - box-shadow: none; - font-size: 12px; - font-weight: 700; -} - -.side-refresh:hover { - background: rgba(191, 95, 44, 0.12); -} - -.side-install { - width: 100%; - min-height: 32px; - padding: 0 10px; - border-radius: 8px; - background: linear-gradient(135deg, #2b3726, #1c211c); - box-shadow: 0 6px 16px rgba(28, 33, 28, 0.18); - font-size: 12px; - font-weight: 700; -} - -.side-pwa, -.pwa-card { - display: grid; - gap: 2px; - min-width: 0; - padding: 7px 10px; - border: 1px solid var(--panel-border); - border-radius: 10px; - background: rgba(255, 255, 255, 0.025); -} - -.side-pwa span, -.pwa-card span { - color: var(--muted-soft); - font-size: 10px; - text-transform: uppercase; - letter-spacing: 0.04em; -} - -.side-pwa strong, -.pwa-card strong { - font-size: 12px; - color: var(--bg-ink); -} - -.side-pwa.is-online strong, -.pwa-card.is-online strong { - color: var(--green); -} - -.side-pwa.is-offline strong, -.pwa-card.is-offline strong { - color: var(--accent); -} - -.drawer-pwa-row span { - color: var(--muted); - font-size: 11px; - font-weight: 800; - letter-spacing: 0.08em; - text-transform: uppercase; -} - -.side-pwa.is-offline, -.pwa-card.is-offline { - border-color: rgba(183, 71, 52, 0.24); - background: rgba(183, 71, 52, 0.08); +.rail-label, +.rail-btn-label, +.rail-foot-label { + display: none; } .settings-panel { @@ -1149,79 +1055,125 @@ button:disabled { .nav-drawer { position: fixed; z-index: 40; - top: max(14px, env(safe-area-inset-top)); - bottom: max(14px, env(safe-area-inset-bottom)); - left: 14px; + top: max(10px, env(safe-area-inset-top)); + bottom: max(10px, env(safe-area-inset-bottom)); + left: 10px; display: grid; - width: min(360px, calc(100vw - 28px)); + width: min(324px, calc(100vw - 20px)); grid-template-rows: auto 1fr auto; - gap: 18px; - padding: 18px; - border: 1px solid rgba(255, 250, 240, 0.3); - border-radius: 28px; - background: rgba(255, 250, 240, 0.94); - box-shadow: 0 28px 90px rgba(20, 18, 10, 0.36); + gap: 10px; + padding: 10px; + border: 1px solid var(--panel-border); + border-radius: 18px; + background: var(--panel); + box-shadow: 0 24px 72px rgba(20, 18, 10, 0.34); backdrop-filter: blur(20px); } .drawer-head { display: flex; - gap: 12px; + gap: 10px; align-items: center; - justify-content: space-between; +} + +.drawer-title { + min-width: 0; + flex: 1; } .drawer-head strong { display: block; - margin-top: 4px; - font-size: 24px; - letter-spacing: -0.04em; -} - -.drawer-head button { - min-height: 44px; - padding: 0 14px; + margin-top: 2px; + overflow: hidden; color: var(--bg-ink); - background: rgba(255, 255, 255, 0.05); - box-shadow: none; + font-size: 15px; + letter-spacing: -0.03em; + text-overflow: ellipsis; + white-space: nowrap; } -.drawer-pwa-row { - display: grid; - gap: 8px; - padding-top: 2px; +.nav-drawer .rail-brand { + flex: 0 0 auto; + margin: 0; } -.drawer-pwa-row button { - min-height: 44px; - padding: 0 14px; - font-size: 13px; - font-weight: 800; -} - -.nav-drawer nav { - display: grid; - align-content: start; - gap: 8px; -} - -.nav-drawer nav a { - display: flex; - min-height: 48px; - align-items: center; - justify-content: space-between; - padding: 0 16px; - border: 1px solid var(--panel-border); - border-radius: 18px; - color: var(--bg-ink); - background: rgba(255, 255, 255, 0.025); - font-weight: 800; - text-decoration: none; -} - -.nav-drawer nav a::after { +.nav-drawer .drawer-close { + width: auto; + min-width: 48px; + padding: 0 12px; color: var(--muted); - content: '↘'; + font-size: 12px; + font-weight: 800; +} + +.nav-drawer .rail-nav { + gap: 6px; + justify-items: stretch; +} + +.nav-drawer .rail-item { + width: 100%; + height: 48px; + justify-content: flex-start; + gap: 12px; + padding: 0 12px; + border: 1px solid var(--panel-border); + border-radius: 12px; +} + +.nav-drawer .rail-item.is-active { + box-shadow: inset 3px 0 0 var(--accent); +} + +.nav-drawer .rail-label, +.nav-drawer .rail-btn-label, +.nav-drawer .rail-foot-label { + display: inline; +} + +.nav-drawer .rail-label { + overflow: hidden; + color: inherit; + font-size: 14px; + font-weight: 800; + text-overflow: ellipsis; + white-space: nowrap; +} + +.nav-drawer .rail-badge { + position: static; + margin-left: auto; +} + +.nav-drawer .rail-foot { + justify-items: stretch; + padding-top: 10px; +} + +.nav-drawer .rail-status-line { + padding: 0 8px; + color: var(--muted); + font-size: 12px; + font-weight: 800; +} + +.nav-drawer .rail-actions { + grid-template-columns: repeat(2, minmax(0, 1fr)); + justify-items: stretch; +} + +.nav-drawer .rail-btn { + width: 100%; + min-height: 40px; + gap: 8px; + justify-content: center; + border: 1px solid var(--panel-border); +} + +.nav-drawer .rail-btn-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .error-card,