Unify dashboard mobile navigation (#54)
This commit is contained in:
@@ -1,17 +1,7 @@
|
|||||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
|
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
|
||||||
import ReactMarkdown from 'react-markdown';
|
import ReactMarkdown from 'react-markdown';
|
||||||
import remarkGfm from 'remark-gfm';
|
import remarkGfm from 'remark-gfm';
|
||||||
import {
|
import { Send } from 'lucide-react';
|
||||||
Activity,
|
|
||||||
Clock,
|
|
||||||
Download,
|
|
||||||
Gauge,
|
|
||||||
Inbox as InboxIcon,
|
|
||||||
MessageSquare,
|
|
||||||
RefreshCw,
|
|
||||||
Send,
|
|
||||||
Settings,
|
|
||||||
} from 'lucide-react';
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
type ClaudeAccountSummary,
|
type ClaudeAccountSummary,
|
||||||
@@ -61,6 +51,12 @@ import {
|
|||||||
useSelectedRoomActivity,
|
useSelectedRoomActivity,
|
||||||
type RoomActivityMap,
|
type RoomActivityMap,
|
||||||
} from './useRoomActivity';
|
} from './useRoomActivity';
|
||||||
|
import {
|
||||||
|
SectionNav,
|
||||||
|
SideRail,
|
||||||
|
type DashboardFreshness,
|
||||||
|
type DashboardView,
|
||||||
|
} from './DashboardNav';
|
||||||
import './styles.css';
|
import './styles.css';
|
||||||
|
|
||||||
interface DashboardState {
|
interface DashboardState {
|
||||||
@@ -74,13 +70,6 @@ type InboxItem = DashboardOverview['inbox'][number];
|
|||||||
type RiskLevel = 'ok' | 'warn' | 'critical';
|
type RiskLevel = 'ok' | 'warn' | 'critical';
|
||||||
type UsageGroup = 'primary' | 'codex';
|
type UsageGroup = 'primary' | 'codex';
|
||||||
type UsageLimitWindow = 'h5' | 'd7';
|
type UsageLimitWindow = 'h5' | 'd7';
|
||||||
type DashboardView =
|
|
||||||
| 'usage'
|
|
||||||
| 'inbox'
|
|
||||||
| 'health'
|
|
||||||
| 'rooms'
|
|
||||||
| 'scheduled'
|
|
||||||
| 'settings';
|
|
||||||
type TaskGroupKey = 'watchers' | 'scheduled' | 'paused' | 'completed';
|
type TaskGroupKey = 'watchers' | 'scheduled' | 'paused' | 'completed';
|
||||||
type TaskResultTone = 'ok' | 'fail' | 'none';
|
type TaskResultTone = 'ok' | 'fail' | 'none';
|
||||||
type TaskActionKey =
|
type TaskActionKey =
|
||||||
@@ -91,7 +80,7 @@ type InboxActionKey = `${string}:${DashboardInboxAction}`;
|
|||||||
type ServiceActionKey = 'stack:restart';
|
type ServiceActionKey = 'stack:restart';
|
||||||
type InboxFilter = 'all' | InboxItem['kind'];
|
type InboxFilter = 'all' | InboxItem['kind'];
|
||||||
type HealthLevel = 'ok' | 'stale' | 'down';
|
type HealthLevel = 'ok' | 'stale' | 'down';
|
||||||
type FreshnessLevel = 'fresh' | 'stale' | 'offline';
|
type FreshnessLevel = DashboardFreshness;
|
||||||
|
|
||||||
type BeforeInstallPromptEvent = Event & {
|
type BeforeInstallPromptEvent = Event & {
|
||||||
prompt: () => Promise<void>;
|
prompt: () => Promise<void>;
|
||||||
@@ -1005,26 +994,6 @@ function inboxTargetHref(item: InboxItem): string | null {
|
|||||||
return 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 }) {
|
function EmptyState({ children }: { children: ReactNode }) {
|
||||||
return <div className="empty-state">{children}</div>;
|
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({
|
function ControlRail({
|
||||||
canInstall,
|
canInstall,
|
||||||
data,
|
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() {
|
function App() {
|
||||||
const [data, setData] = useState<DashboardState | null>(null);
|
const [data, setData] = useState<DashboardState | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -4449,32 +4179,30 @@ function App() {
|
|||||||
<SectionNav
|
<SectionNav
|
||||||
activeView={activeView}
|
activeView={activeView}
|
||||||
canInstall={canInstall}
|
canInstall={canInstall}
|
||||||
|
data={data}
|
||||||
drawerOpen={drawerOpen}
|
drawerOpen={drawerOpen}
|
||||||
freshness={freshness}
|
freshness={freshness}
|
||||||
|
freshnessText={freshnessLabel(freshness, t)}
|
||||||
installed={installed}
|
installed={installed}
|
||||||
locale={locale}
|
|
||||||
onCloseDrawer={() => setDrawerOpen(false)}
|
onCloseDrawer={() => setDrawerOpen(false)}
|
||||||
onInstall={() => void handleInstallApp()}
|
onInstall={() => void handleInstallApp()}
|
||||||
onLocaleChange={setDashboardLocale}
|
|
||||||
onNavigate={navigateToView}
|
onNavigate={navigateToView}
|
||||||
onOpenDrawer={() => setDrawerOpen(true)}
|
onOpenDrawer={() => setDrawerOpen(true)}
|
||||||
offlineReady={offlineReady}
|
offlineReady={offlineReady}
|
||||||
onRefresh={() => void refresh(true)}
|
onRefresh={() => void refresh(true)}
|
||||||
|
online={online}
|
||||||
refreshing={refreshing}
|
refreshing={refreshing}
|
||||||
secureContext={secureContext}
|
secureContext={secureContext}
|
||||||
t={t}
|
t={t}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{error ? (
|
{error ? (
|
||||||
<section className="error-card" role="alert" aria-live="polite">
|
<DashboardErrorCard
|
||||||
<span>
|
error={error}
|
||||||
<strong>{t.error.api}</strong>
|
onRetry={() => void refresh(true)}
|
||||||
<small>{humanizeError(error, t)}</small>
|
refreshing={refreshing}
|
||||||
</span>
|
t={t}
|
||||||
<button disabled={refreshing} onClick={() => void refresh(true)}>
|
/>
|
||||||
{t.actions.retry}
|
|
||||||
</button>
|
|
||||||
</section>
|
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{data ? (
|
{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);
|
border-top: 1px solid var(--divider);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.rail-status-line {
|
||||||
|
display: inline-flex;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.rail-status-dot {
|
.rail-status-dot {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
|
flex: 0 0 auto;
|
||||||
width: 8px;
|
width: 8px;
|
||||||
height: 8px;
|
height: 8px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
@@ -565,6 +573,12 @@ button:disabled {
|
|||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.rail-actions {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
justify-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
.rail-btn {
|
.rail-btn {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
width: 32px;
|
width: 32px;
|
||||||
@@ -605,118 +619,10 @@ button:disabled {
|
|||||||
animation: rail-spin 0.9s linear infinite;
|
animation: rail-spin 0.9s linear infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-drawer nav a.is-active,
|
.rail-label,
|
||||||
.nav-drawer nav a[aria-current='page'] {
|
.rail-btn-label,
|
||||||
color: var(--bg);
|
.rail-foot-label {
|
||||||
background: var(--bg-ink);
|
display: none;
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-panel {
|
.settings-panel {
|
||||||
@@ -1149,79 +1055,125 @@ button:disabled {
|
|||||||
.nav-drawer {
|
.nav-drawer {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
z-index: 40;
|
z-index: 40;
|
||||||
top: max(14px, env(safe-area-inset-top));
|
top: max(10px, env(safe-area-inset-top));
|
||||||
bottom: max(14px, env(safe-area-inset-bottom));
|
bottom: max(10px, env(safe-area-inset-bottom));
|
||||||
left: 14px;
|
left: 10px;
|
||||||
display: grid;
|
display: grid;
|
||||||
width: min(360px, calc(100vw - 28px));
|
width: min(324px, calc(100vw - 20px));
|
||||||
grid-template-rows: auto 1fr auto;
|
grid-template-rows: auto 1fr auto;
|
||||||
gap: 18px;
|
gap: 10px;
|
||||||
padding: 18px;
|
padding: 10px;
|
||||||
border: 1px solid rgba(255, 250, 240, 0.3);
|
border: 1px solid var(--panel-border);
|
||||||
border-radius: 28px;
|
border-radius: 18px;
|
||||||
background: rgba(255, 250, 240, 0.94);
|
background: var(--panel);
|
||||||
box-shadow: 0 28px 90px rgba(20, 18, 10, 0.36);
|
box-shadow: 0 24px 72px rgba(20, 18, 10, 0.34);
|
||||||
backdrop-filter: blur(20px);
|
backdrop-filter: blur(20px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.drawer-head {
|
.drawer-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 12px;
|
gap: 10px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
}
|
||||||
|
|
||||||
|
.drawer-title {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.drawer-head strong {
|
.drawer-head strong {
|
||||||
display: block;
|
display: block;
|
||||||
margin-top: 4px;
|
margin-top: 2px;
|
||||||
font-size: 24px;
|
overflow: hidden;
|
||||||
letter-spacing: -0.04em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.drawer-head button {
|
|
||||||
min-height: 44px;
|
|
||||||
padding: 0 14px;
|
|
||||||
color: var(--bg-ink);
|
color: var(--bg-ink);
|
||||||
background: rgba(255, 255, 255, 0.05);
|
font-size: 15px;
|
||||||
box-shadow: none;
|
letter-spacing: -0.03em;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.drawer-pwa-row {
|
.nav-drawer .rail-brand {
|
||||||
display: grid;
|
flex: 0 0 auto;
|
||||||
gap: 8px;
|
margin: 0;
|
||||||
padding-top: 2px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.drawer-pwa-row button {
|
.nav-drawer .drawer-close {
|
||||||
min-height: 44px;
|
width: auto;
|
||||||
padding: 0 14px;
|
min-width: 48px;
|
||||||
font-size: 13px;
|
padding: 0 12px;
|
||||||
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 {
|
|
||||||
color: var(--muted);
|
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,
|
.error-card,
|
||||||
|
|||||||
Reference in New Issue
Block a user