Unify dashboard mobile navigation (#54)
This commit is contained in:
@@ -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<void>;
|
||||
@@ -1005,26 +994,6 @@ function inboxTargetHref(item: InboxItem): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
const NAV_ICONS: Record<DashboardView, ReactNode> = {
|
||||
usage: <Gauge size={20} strokeWidth={2} aria-hidden />,
|
||||
inbox: <InboxIcon size={20} strokeWidth={2} aria-hidden />,
|
||||
health: <Activity size={20} strokeWidth={2} aria-hidden />,
|
||||
rooms: <MessageSquare size={20} strokeWidth={2} aria-hidden />,
|
||||
scheduled: <Clock size={20} strokeWidth={2} aria-hidden />,
|
||||
settings: <Settings size={20} strokeWidth={2} aria-hidden />,
|
||||
};
|
||||
|
||||
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 <div className="empty-state">{children}</div>;
|
||||
}
|
||||
@@ -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 (
|
||||
<aside className="side-rail icon-rail" aria-label={t.nav.drawerAria}>
|
||||
<a
|
||||
className="rail-brand"
|
||||
href="#/usage"
|
||||
onClick={() => onNavigate('usage')}
|
||||
title="EJClaw"
|
||||
>
|
||||
EJ
|
||||
</a>
|
||||
<nav className="rail-nav" aria-label={t.nav.drawerNavAria}>
|
||||
{navItems(t).map((item) => {
|
||||
const badge =
|
||||
item.view === 'inbox' && stats && stats.inbox > 0
|
||||
? stats.inbox
|
||||
: item.view === 'rooms' && stats && stats.processing > 0
|
||||
? stats.processing
|
||||
: null;
|
||||
return (
|
||||
<a
|
||||
aria-current={activeView === item.view ? 'page' : undefined}
|
||||
aria-label={item.label}
|
||||
className={`rail-item${activeView === item.view ? ' is-active' : ''}`}
|
||||
href={item.href}
|
||||
key={item.href}
|
||||
onClick={() => onNavigate(item.view)}
|
||||
title={item.label}
|
||||
>
|
||||
<span className="rail-icon">{NAV_ICONS[item.view]}</span>
|
||||
{badge !== null ? (
|
||||
<span className="rail-badge">{badge}</span>
|
||||
) : null}
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
<div className="rail-foot">
|
||||
<span
|
||||
className={`rail-status-dot ${online ? 'is-online' : 'is-offline'}`}
|
||||
title={`${online ? t.pwa.online : t.pwa.offline} · ${pwaState}`}
|
||||
aria-label={`${online ? t.pwa.online : t.pwa.offline}`}
|
||||
/>
|
||||
{canInstall ? (
|
||||
<button
|
||||
className="rail-btn"
|
||||
onClick={onInstall}
|
||||
title={t.pwa.install}
|
||||
aria-label={t.pwa.install}
|
||||
type="button"
|
||||
>
|
||||
<Download size={16} strokeWidth={2} aria-hidden />
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
aria-busy={refreshing}
|
||||
aria-label={t.actions.refresh}
|
||||
className={`rail-btn${refreshing ? ' is-spinning' : ''}`}
|
||||
disabled={refreshing}
|
||||
onClick={onRefresh}
|
||||
title={refreshing ? t.actions.refreshing : t.actions.refresh}
|
||||
type="button"
|
||||
>
|
||||
<RefreshCw size={16} strokeWidth={2} aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<>
|
||||
<nav className="section-nav" aria-label={t.nav.aria}>
|
||||
<button
|
||||
aria-controls="dashboard-menu"
|
||||
aria-expanded={drawerOpen}
|
||||
aria-label={drawerOpen ? t.nav.menuClose : t.nav.menuOpen}
|
||||
className="menu-button"
|
||||
onClick={drawerOpen ? onCloseDrawer : onOpenDrawer}
|
||||
type="button"
|
||||
>
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
</button>
|
||||
<strong className="topbar-label">{activeLabel}</strong>
|
||||
<span className={`topbar-status topbar-status-${freshness}`}>
|
||||
{freshnessLabel(freshness, t)}
|
||||
</span>
|
||||
<button
|
||||
aria-busy={refreshing}
|
||||
aria-label={refreshing ? t.actions.refreshing : t.actions.refresh}
|
||||
className="refresh-button"
|
||||
disabled={refreshing}
|
||||
onClick={onRefresh}
|
||||
type="button"
|
||||
>
|
||||
{refreshing ? '...' : t.actions.refresh}
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
{drawerOpen ? (
|
||||
<>
|
||||
<button
|
||||
aria-label={t.nav.menuClose}
|
||||
className="drawer-backdrop"
|
||||
onClick={onCloseDrawer}
|
||||
type="button"
|
||||
/>
|
||||
<aside
|
||||
aria-label={t.nav.drawerAria}
|
||||
aria-modal="true"
|
||||
className="nav-drawer"
|
||||
id="dashboard-menu"
|
||||
role="dialog"
|
||||
>
|
||||
<div className="drawer-head">
|
||||
<div>
|
||||
<span className="eyebrow">EJClaw</span>
|
||||
<strong>{t.nav.operations}</strong>
|
||||
</div>
|
||||
<button
|
||||
aria-label={t.nav.menuClose}
|
||||
onClick={onCloseDrawer}
|
||||
type="button"
|
||||
>
|
||||
{t.actions.close}
|
||||
</button>
|
||||
</div>
|
||||
<nav aria-label={t.nav.drawerNavAria}>
|
||||
{navItems(t).map((item) => (
|
||||
<a
|
||||
aria-current={activeView === item.view ? 'page' : undefined}
|
||||
className={activeView === item.view ? 'is-active' : undefined}
|
||||
href={item.href}
|
||||
key={item.href}
|
||||
onClick={() => {
|
||||
onNavigate(item.view);
|
||||
onCloseDrawer();
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
<div className="drawer-pwa-row">
|
||||
<span>
|
||||
{!secureContext
|
||||
? t.pwa.secureRequired
|
||||
: installed
|
||||
? t.pwa.installed
|
||||
: offlineReady
|
||||
? t.pwa.ready
|
||||
: t.pwa.app}
|
||||
</span>
|
||||
{canInstall ? (
|
||||
<button onClick={onInstall} type="button">
|
||||
{t.pwa.install}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</aside>
|
||||
</>
|
||||
) : 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 (
|
||||
<section className="error-card" role="alert" aria-live="polite">
|
||||
<span>
|
||||
<strong>{t.error.api}</strong>
|
||||
<small>{humanizeError(error, t)}</small>
|
||||
</span>
|
||||
<button disabled={refreshing} onClick={onRetry}>
|
||||
{t.actions.retry}
|
||||
</button>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [data, setData] = useState<DashboardState | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -4449,32 +4179,30 @@ function App() {
|
||||
<SectionNav
|
||||
activeView={activeView}
|
||||
canInstall={canInstall}
|
||||
data={data}
|
||||
drawerOpen={drawerOpen}
|
||||
freshness={freshness}
|
||||
freshnessText={freshnessLabel(freshness, t)}
|
||||
installed={installed}
|
||||
locale={locale}
|
||||
onCloseDrawer={() => 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 ? (
|
||||
<section className="error-card" role="alert" aria-live="polite">
|
||||
<span>
|
||||
<strong>{t.error.api}</strong>
|
||||
<small>{humanizeError(error, t)}</small>
|
||||
</span>
|
||||
<button disabled={refreshing} onClick={() => void refresh(true)}>
|
||||
{t.actions.retry}
|
||||
</button>
|
||||
</section>
|
||||
<DashboardErrorCard
|
||||
error={error}
|
||||
onRetry={() => void refresh(true)}
|
||||
refreshing={refreshing}
|
||||
t={t}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{data ? (
|
||||
|
||||
394
apps/dashboard/src/DashboardNav.tsx
Normal file
394
apps/dashboard/src/DashboardNav.tsx
Normal file
@@ -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<DashboardView, ReactNode> = {
|
||||
usage: <Gauge size={20} strokeWidth={2} aria-hidden />,
|
||||
inbox: <InboxIcon size={20} strokeWidth={2} aria-hidden />,
|
||||
health: <Activity size={20} strokeWidth={2} aria-hidden />,
|
||||
rooms: <MessageSquare size={20} strokeWidth={2} aria-hidden />,
|
||||
scheduled: <Clock size={20} strokeWidth={2} aria-hidden />,
|
||||
settings: <Settings size={20} strokeWidth={2} aria-hidden />,
|
||||
};
|
||||
|
||||
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<typeof navStats>,
|
||||
): 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 (
|
||||
<a
|
||||
className="rail-brand"
|
||||
href="#/usage"
|
||||
onClick={() => onNavigate('usage')}
|
||||
title="EJClaw"
|
||||
>
|
||||
EJ
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<nav className="rail-nav" aria-label={t.nav.drawerNavAria}>
|
||||
{navItems(t).map((item) => {
|
||||
const badge = navBadge(item.view, stats);
|
||||
return (
|
||||
<a
|
||||
aria-current={activeView === item.view ? 'page' : undefined}
|
||||
aria-label={item.label}
|
||||
className={`rail-item${activeView === item.view ? ' is-active' : ''}`}
|
||||
href={item.href}
|
||||
key={item.href}
|
||||
onClick={() => {
|
||||
onNavigate(item.view);
|
||||
onAfterNavigate?.();
|
||||
}}
|
||||
title={item.label}
|
||||
>
|
||||
<span className="rail-icon">{NAV_ICONS[item.view]}</span>
|
||||
<span className="rail-label">{item.label}</span>
|
||||
{badge !== null ? (
|
||||
<span className="rail-badge">{badge}</span>
|
||||
) : null}
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="rail-foot">
|
||||
<div className="rail-status-line" title={status}>
|
||||
<span
|
||||
className={`rail-status-dot ${online ? 'is-online' : 'is-offline'}`}
|
||||
aria-label={online ? t.pwa.online : t.pwa.offline}
|
||||
/>
|
||||
<span className="rail-foot-label">{status}</span>
|
||||
</div>
|
||||
<div className="rail-actions">
|
||||
{canInstall ? (
|
||||
<button
|
||||
className="rail-btn"
|
||||
onClick={onInstall}
|
||||
title={t.pwa.install}
|
||||
aria-label={t.pwa.install}
|
||||
type="button"
|
||||
>
|
||||
<Download size={16} strokeWidth={2} aria-hidden />
|
||||
<span className="rail-btn-label">{t.pwa.install}</span>
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
aria-busy={refreshing}
|
||||
aria-label={t.actions.refresh}
|
||||
className={`rail-btn${refreshing ? ' is-spinning' : ''}`}
|
||||
disabled={refreshing}
|
||||
onClick={onRefresh}
|
||||
title={refreshing ? t.actions.refreshing : t.actions.refresh}
|
||||
type="button"
|
||||
>
|
||||
<RefreshCw size={16} strokeWidth={2} aria-hidden />
|
||||
<span className="rail-btn-label">
|
||||
{refreshing ? t.actions.refreshing : t.actions.refresh}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<aside className="side-rail icon-rail" aria-label={t.nav.drawerAria}>
|
||||
<DashboardBrandLink onNavigate={onNavigate} />
|
||||
<DashboardNavLinks
|
||||
activeView={activeView}
|
||||
data={data}
|
||||
onNavigate={onNavigate}
|
||||
t={t}
|
||||
/>
|
||||
<DashboardNavActions
|
||||
canInstall={canInstall}
|
||||
installed={installed}
|
||||
offlineReady={offlineReady}
|
||||
online={online}
|
||||
onInstall={onInstall}
|
||||
onRefresh={onRefresh}
|
||||
refreshing={refreshing}
|
||||
secureContext={secureContext}
|
||||
t={t}
|
||||
/>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<>
|
||||
<nav className="section-nav" aria-label={t.nav.aria}>
|
||||
<button
|
||||
aria-controls="dashboard-menu"
|
||||
aria-expanded={drawerOpen}
|
||||
aria-label={drawerOpen ? t.nav.menuClose : t.nav.menuOpen}
|
||||
className="menu-button"
|
||||
onClick={drawerOpen ? onCloseDrawer : onOpenDrawer}
|
||||
type="button"
|
||||
>
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
</button>
|
||||
<strong className="topbar-label">{activeLabel}</strong>
|
||||
<span className={`topbar-status topbar-status-${freshness}`}>
|
||||
{freshnessText}
|
||||
</span>
|
||||
<button
|
||||
aria-busy={refreshing}
|
||||
aria-label={refreshing ? t.actions.refreshing : t.actions.refresh}
|
||||
className="refresh-button"
|
||||
disabled={refreshing}
|
||||
onClick={onRefresh}
|
||||
type="button"
|
||||
>
|
||||
{refreshing ? '...' : t.actions.refresh}
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
{drawerOpen ? (
|
||||
<>
|
||||
<button
|
||||
aria-label={t.nav.menuClose}
|
||||
className="drawer-backdrop"
|
||||
onClick={onCloseDrawer}
|
||||
type="button"
|
||||
/>
|
||||
<aside
|
||||
aria-label={t.nav.drawerAria}
|
||||
aria-modal="true"
|
||||
className="nav-drawer"
|
||||
id="dashboard-menu"
|
||||
role="dialog"
|
||||
>
|
||||
<div className="drawer-head">
|
||||
<DashboardBrandLink onNavigate={onNavigate} />
|
||||
<div className="drawer-title">
|
||||
<span className="eyebrow">EJClaw</span>
|
||||
<strong>{t.nav.operations}</strong>
|
||||
</div>
|
||||
<button
|
||||
aria-label={t.nav.menuClose}
|
||||
className="rail-btn drawer-close"
|
||||
onClick={onCloseDrawer}
|
||||
type="button"
|
||||
>
|
||||
{t.actions.close}
|
||||
</button>
|
||||
</div>
|
||||
<DashboardNavLinks
|
||||
activeView={activeView}
|
||||
data={data}
|
||||
onAfterNavigate={onCloseDrawer}
|
||||
onNavigate={onNavigate}
|
||||
t={t}
|
||||
/>
|
||||
<DashboardNavActions
|
||||
canInstall={canInstall}
|
||||
installed={installed}
|
||||
offlineReady={offlineReady}
|
||||
online={online}
|
||||
onInstall={onInstall}
|
||||
onRefresh={onRefresh}
|
||||
refreshing={refreshing}
|
||||
secureContext={secureContext}
|
||||
t={t}
|
||||
/>
|
||||
</aside>
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user