Add dashboard PWA shell and status UX
This commit is contained in:
@@ -1,9 +1,19 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>EJClaw Dashboard</title>
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0, viewport-fit=cover"
|
||||
/>
|
||||
<meta name="theme-color" content="#2b3726" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-title" content="EJClaw" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<link rel="icon" href="/icons/icon-192.svg" type="image/svg+xml" />
|
||||
<link rel="apple-touch-icon" href="/icons/icon-192.png" />
|
||||
<title>EJClaw Ops</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
BIN
apps/dashboard/public/icons/icon-192.png
Normal file
BIN
apps/dashboard/public/icons/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 861 B |
8
apps/dashboard/public/icons/icon-192.svg
Normal file
8
apps/dashboard/public/icons/icon-192.svg
Normal file
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="192" height="192" viewBox="0 0 192 192" role="img" aria-label="EJClaw">
|
||||
<rect width="192" height="192" rx="46" fill="#2b3726" />
|
||||
<path d="M39 122c18 21 47 29 81 17 18-6 30-18 36-35" fill="none" stroke="#f7edda" stroke-width="14" stroke-linecap="round" />
|
||||
<path d="M50 78c16-24 45-36 76-25 13 5 24 13 32 25" fill="none" stroke="#bf5f2c" stroke-width="14" stroke-linecap="round" />
|
||||
<circle cx="74" cy="94" r="11" fill="#f7edda" />
|
||||
<circle cx="118" cy="94" r="11" fill="#f7edda" />
|
||||
<path d="M88 126h21" stroke="#f7edda" stroke-width="10" stroke-linecap="round" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 627 B |
BIN
apps/dashboard/public/icons/icon-512.png
Normal file
BIN
apps/dashboard/public/icons/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.7 KiB |
8
apps/dashboard/public/icons/icon-512.svg
Normal file
8
apps/dashboard/public/icons/icon-512.svg
Normal file
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512" role="img" aria-label="EJClaw">
|
||||
<rect width="512" height="512" rx="124" fill="#2b3726" />
|
||||
<path d="M104 325c48 58 126 78 217 47 48-16 80-49 96-93" fill="none" stroke="#f7edda" stroke-width="38" stroke-linecap="round" />
|
||||
<path d="M134 208c43-64 120-96 203-66 36 13 64 35 86 66" fill="none" stroke="#bf5f2c" stroke-width="38" stroke-linecap="round" />
|
||||
<circle cx="198" cy="250" r="30" fill="#f7edda" />
|
||||
<circle cx="314" cy="250" r="30" fill="#f7edda" />
|
||||
<path d="M235 336h56" stroke="#f7edda" stroke-width="28" stroke-linecap="round" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 641 B |
27
apps/dashboard/public/manifest.webmanifest
Normal file
27
apps/dashboard/public/manifest.webmanifest
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "EJClaw Ops",
|
||||
"short_name": "EJClaw",
|
||||
"description": "EJClaw operations console",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"display_override": ["window-controls-overlay", "standalone", "browser"],
|
||||
"background_color": "#f3efe4",
|
||||
"theme_color": "#2b3726",
|
||||
"orientation": "portrait-primary",
|
||||
"categories": ["productivity", "utilities"],
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icons/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
87
apps/dashboard/public/offline.html
Normal file
87
apps/dashboard/public/offline.html
Normal file
@@ -0,0 +1,87 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0, viewport-fit=cover"
|
||||
/>
|
||||
<meta name="theme-color" content="#2b3726" />
|
||||
<title>EJClaw Offline</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light;
|
||||
font-family:
|
||||
Avenir Next,
|
||||
Segoe UI Variable,
|
||||
Noto Sans KR,
|
||||
ui-sans-serif,
|
||||
sans-serif;
|
||||
background: #f3efe4;
|
||||
color: #1c211c;
|
||||
}
|
||||
|
||||
body {
|
||||
display: grid;
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
place-items: center;
|
||||
background:
|
||||
radial-gradient(
|
||||
circle at 20% 18%,
|
||||
rgba(191, 95, 44, 0.2),
|
||||
transparent 26rem
|
||||
),
|
||||
linear-gradient(135deg, #f4ead5, #edf1df);
|
||||
}
|
||||
|
||||
main {
|
||||
width: min(420px, calc(100% - 32px));
|
||||
padding: 24px;
|
||||
border: 1px solid rgba(43, 55, 38, 0.16);
|
||||
border-radius: 28px;
|
||||
background: rgba(255, 250, 240, 0.86);
|
||||
box-shadow: 0 24px 70px rgba(44, 39, 25, 0.16);
|
||||
}
|
||||
|
||||
span {
|
||||
color: #8f351b;
|
||||
font-size: 12px;
|
||||
font-weight: 850;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 8px 0;
|
||||
font-size: 30px;
|
||||
letter-spacing: -0.05em;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0 0 18px;
|
||||
color: #6d735f;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
button {
|
||||
min-height: 44px;
|
||||
padding: 0 18px;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
color: #fffaf0;
|
||||
background: linear-gradient(135deg, #bf5f2c, #8f351b);
|
||||
font: inherit;
|
||||
font-weight: 900;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<span>EJClaw</span>
|
||||
<h1>Offline</h1>
|
||||
<p>네트워크가 복구되면 대시보드를 다시 불러옵니다.</p>
|
||||
<button type="button" onclick="window.location.reload()">Retry</button>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
78
apps/dashboard/public/sw.js
Normal file
78
apps/dashboard/public/sw.js
Normal file
@@ -0,0 +1,78 @@
|
||||
const CACHE_NAME = 'ejclaw-dashboard-v1';
|
||||
const PRECACHE_URLS = [
|
||||
'/',
|
||||
'/index.html',
|
||||
'/offline.html',
|
||||
'/manifest.webmanifest',
|
||||
'/icons/icon-192.png',
|
||||
'/icons/icon-512.png',
|
||||
'/icons/icon-192.svg',
|
||||
'/icons/icon-512.svg',
|
||||
];
|
||||
|
||||
self.addEventListener('install', (event) => {
|
||||
event.waitUntil(
|
||||
caches
|
||||
.open(CACHE_NAME)
|
||||
.then((cache) => cache.addAll(PRECACHE_URLS))
|
||||
.then(() => self.skipWaiting()),
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(
|
||||
caches
|
||||
.keys()
|
||||
.then((keys) =>
|
||||
Promise.all(
|
||||
keys
|
||||
.filter((key) => key !== CACHE_NAME)
|
||||
.map((key) => caches.delete(key)),
|
||||
),
|
||||
)
|
||||
.then(() => self.clients.claim()),
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', (event) => {
|
||||
const { request } = event;
|
||||
const url = new URL(request.url);
|
||||
|
||||
if (request.method !== 'GET' || url.origin !== self.location.origin) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith('/api/')) {
|
||||
event.respondWith(fetch(request));
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.mode === 'navigate') {
|
||||
event.respondWith(
|
||||
fetch(request)
|
||||
.then((response) => {
|
||||
const copy = response.clone();
|
||||
caches.open(CACHE_NAME).then((cache) => cache.put('/', copy));
|
||||
return response;
|
||||
})
|
||||
.catch(() =>
|
||||
caches
|
||||
.match('/')
|
||||
.then((cached) => cached || caches.match('/offline.html')),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
event.respondWith(
|
||||
caches.match(request).then((cached) => {
|
||||
if (cached) return cached;
|
||||
return fetch(request).then((response) => {
|
||||
if (!response.ok) return response;
|
||||
const copy = response.clone();
|
||||
caches.open(CACHE_NAME).then((cache) => cache.put(request, copy));
|
||||
return response;
|
||||
});
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -52,6 +52,12 @@ type InboxActionKey = `${string}:${DashboardInboxAction}`;
|
||||
type ServiceActionKey = 'stack:restart';
|
||||
type InboxFilter = 'all' | InboxItem['kind'];
|
||||
type HealthLevel = 'ok' | 'stale' | 'down';
|
||||
type FreshnessLevel = 'fresh' | 'stale' | 'offline';
|
||||
|
||||
type BeforeInstallPromptEvent = Event & {
|
||||
prompt: () => Promise<void>;
|
||||
userChoice: Promise<{ outcome: 'accepted' | 'dismissed'; platform: string }>;
|
||||
};
|
||||
|
||||
interface RoomOption {
|
||||
jid: string;
|
||||
@@ -64,6 +70,7 @@ const LOCALE_STORAGE_KEY = 'ejclaw.dashboard.locale.v2';
|
||||
const DEFAULT_VIEW: DashboardView = 'inbox';
|
||||
const HEALTH_STALE_MS = 5 * 60_000;
|
||||
const HEALTH_DOWN_MS = 15 * 60_000;
|
||||
const DASHBOARD_STALE_MS = 75_000;
|
||||
|
||||
function makeClientRequestId(): string {
|
||||
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
@@ -119,6 +126,47 @@ function formatDate(value: string | null | undefined, locale: Locale): string {
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
function dashboardAgeMs(value: string | null | undefined): number | null {
|
||||
if (!value) return null;
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return null;
|
||||
return Math.max(0, Date.now() - date.getTime());
|
||||
}
|
||||
|
||||
function dashboardFreshness(
|
||||
online: boolean,
|
||||
generatedAt: string | null | undefined,
|
||||
): FreshnessLevel {
|
||||
if (!online) return 'offline';
|
||||
const age = dashboardAgeMs(generatedAt);
|
||||
if (age !== null && age > DASHBOARD_STALE_MS) return 'stale';
|
||||
return 'fresh';
|
||||
}
|
||||
|
||||
function freshnessLabel(level: FreshnessLevel, t: Messages): string {
|
||||
if (level === 'offline') return t.pwa.offline;
|
||||
if (level === 'stale') return t.pwa.stale;
|
||||
return t.pwa.fresh;
|
||||
}
|
||||
|
||||
function isStandaloneDisplay(): boolean {
|
||||
if (typeof window === 'undefined') return false;
|
||||
const standaloneNavigator = navigator as Navigator & { standalone?: boolean };
|
||||
return (
|
||||
standaloneNavigator.standalone === true ||
|
||||
(typeof window.matchMedia === 'function' &&
|
||||
window.matchMedia('(display-mode: standalone)').matches)
|
||||
);
|
||||
}
|
||||
|
||||
function canUsePwaCore(): boolean {
|
||||
return (
|
||||
typeof window !== 'undefined' &&
|
||||
window.isSecureContext &&
|
||||
'serviceWorker' in navigator
|
||||
);
|
||||
}
|
||||
|
||||
function formatTaskDate(
|
||||
value: string | null | undefined,
|
||||
locale: Locale,
|
||||
@@ -517,21 +565,41 @@ function LoadingSkeleton({ t }: { t: Messages }) {
|
||||
|
||||
function SideRail({
|
||||
activeView,
|
||||
canInstall,
|
||||
installed,
|
||||
locale,
|
||||
onNavigate,
|
||||
onInstall,
|
||||
onLocaleChange,
|
||||
onRefresh,
|
||||
online,
|
||||
offlineReady,
|
||||
refreshing,
|
||||
secureContext,
|
||||
t,
|
||||
}: {
|
||||
activeView: DashboardView;
|
||||
canInstall: boolean;
|
||||
installed: boolean;
|
||||
locale: Locale;
|
||||
onNavigate: (view: DashboardView) => void;
|
||||
onInstall: () => void;
|
||||
onLocaleChange: (locale: Locale) => 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;
|
||||
|
||||
return (
|
||||
<aside className="side-rail" aria-label={t.nav.drawerAria}>
|
||||
<div className="side-rail-brand">
|
||||
@@ -552,6 +620,15 @@ function SideRail({
|
||||
))}
|
||||
</nav>
|
||||
<LanguageSelector locale={locale} onLocaleChange={onLocaleChange} t={t} />
|
||||
<div className={`pwa-card ${online ? 'is-online' : 'is-offline'}`}>
|
||||
<span>{online ? t.pwa.online : t.pwa.offline}</span>
|
||||
<strong>{pwaState}</strong>
|
||||
</div>
|
||||
{canInstall ? (
|
||||
<button className="side-install" onClick={onInstall} type="button">
|
||||
{t.pwa.install}
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
aria-busy={refreshing}
|
||||
className="side-refresh"
|
||||
@@ -568,24 +645,36 @@ function SideRail({
|
||||
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 =
|
||||
@@ -607,6 +696,9 @@ function SectionNav({
|
||||
<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}
|
||||
@@ -668,6 +760,22 @@ function SectionNav({
|
||||
onLocaleChange={onLocaleChange}
|
||||
t={t}
|
||||
/>
|
||||
<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}
|
||||
@@ -675,7 +783,27 @@ function SectionNav({
|
||||
);
|
||||
}
|
||||
|
||||
function ControlRail({ data, t }: { data: DashboardState; t: Messages }) {
|
||||
function ControlRail({
|
||||
canInstall,
|
||||
data,
|
||||
installed,
|
||||
locale,
|
||||
offlineReady,
|
||||
onInstall,
|
||||
online,
|
||||
secureContext,
|
||||
t,
|
||||
}: {
|
||||
canInstall: boolean;
|
||||
data: DashboardState;
|
||||
installed: boolean;
|
||||
locale: Locale;
|
||||
offlineReady: boolean;
|
||||
onInstall: () => void;
|
||||
online: boolean;
|
||||
secureContext: boolean;
|
||||
t: Messages;
|
||||
}) {
|
||||
const queue = data.snapshots.reduce(
|
||||
(acc, snapshot) => {
|
||||
for (const entry of snapshot.entries) {
|
||||
@@ -686,9 +814,44 @@ function ControlRail({ data, t }: { data: DashboardState; t: Messages }) {
|
||||
},
|
||||
{ pendingTasks: 0, pendingMessageRooms: 0 },
|
||||
);
|
||||
const freshness = dashboardFreshness(online, data.overview.generatedAt);
|
||||
const age = dashboardAgeMs(data.overview.generatedAt);
|
||||
|
||||
return (
|
||||
<section className="ops-strip" id="overview" aria-label={t.control.aria}>
|
||||
<div className={`ops-tile-freshness ops-${freshness}`}>
|
||||
<span>{t.pwa.updated}</span>
|
||||
<strong>{freshnessLabel(freshness, t)}</strong>
|
||||
<small>
|
||||
{formatDate(data.overview.generatedAt, locale)}
|
||||
{age === null ? '' : ` · ${formatDuration(age, t)}`}
|
||||
</small>
|
||||
</div>
|
||||
<div className="ops-tile-pwa">
|
||||
<span>{t.pwa.app}</span>
|
||||
<strong>
|
||||
{!secureContext
|
||||
? t.pwa.secureRequired
|
||||
: installed
|
||||
? t.pwa.installed
|
||||
: offlineReady
|
||||
? t.pwa.ready
|
||||
: t.pwa.app}
|
||||
</strong>
|
||||
{canInstall ? (
|
||||
<button onClick={onInstall} type="button">
|
||||
{t.pwa.install}
|
||||
</button>
|
||||
) : (
|
||||
<small>
|
||||
{!secureContext
|
||||
? t.pwa.secureRequired
|
||||
: offlineReady
|
||||
? t.pwa.cached
|
||||
: t.pwa.online}
|
||||
</small>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<span>{t.metrics.rooms}</span>
|
||||
<strong>
|
||||
@@ -1771,6 +1934,13 @@ function App() {
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [activeView, setActiveView] = useState<DashboardView>(readViewFromHash);
|
||||
const [locale, setLocale] = useState<Locale>(readInitialLocale);
|
||||
const [online, setOnline] = useState(() =>
|
||||
typeof navigator === 'undefined' ? true : navigator.onLine,
|
||||
);
|
||||
const [offlineReady, setOfflineReady] = useState(false);
|
||||
const [installPrompt, setInstallPrompt] =
|
||||
useState<BeforeInstallPromptEvent | null>(null);
|
||||
const [installed, setInstalled] = useState(isStandaloneDisplay);
|
||||
const [taskActionKey, setTaskActionKey] = useState<TaskActionKey | null>(
|
||||
null,
|
||||
);
|
||||
@@ -1781,6 +1951,8 @@ function App() {
|
||||
useState<ServiceActionKey | null>(null);
|
||||
const [roomMessageKey, setRoomMessageKey] = useState<string | null>(null);
|
||||
const t = messages[locale];
|
||||
const secureContext =
|
||||
typeof window === 'undefined' ? true : window.isSecureContext;
|
||||
|
||||
function setDashboardLocale(nextLocale: Locale) {
|
||||
setLocale(nextLocale);
|
||||
@@ -1922,10 +2094,90 @@ function App() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleInstallApp() {
|
||||
if (!installPrompt) return;
|
||||
await installPrompt.prompt();
|
||||
await installPrompt.userChoice;
|
||||
setInstallPrompt(null);
|
||||
setInstalled(isStandaloneDisplay());
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.lang = localeTags[locale];
|
||||
}, [locale]);
|
||||
|
||||
useEffect(() => {
|
||||
function handleOnline() {
|
||||
setOnline(true);
|
||||
}
|
||||
|
||||
function handleOffline() {
|
||||
setOnline(false);
|
||||
}
|
||||
|
||||
window.addEventListener('online', handleOnline);
|
||||
window.addEventListener('offline', handleOffline);
|
||||
return () => {
|
||||
window.removeEventListener('online', handleOnline);
|
||||
window.removeEventListener('offline', handleOffline);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!import.meta.env.PROD || !canUsePwaCore()) {
|
||||
setOfflineReady(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
void navigator.serviceWorker
|
||||
.register('/sw.js')
|
||||
.then((registration) => {
|
||||
if (!cancelled) {
|
||||
setOfflineReady(
|
||||
Boolean(
|
||||
registration.active ||
|
||||
registration.waiting ||
|
||||
registration.installing,
|
||||
),
|
||||
);
|
||||
}
|
||||
return navigator.serviceWorker.ready;
|
||||
})
|
||||
.then(() => {
|
||||
if (!cancelled) setOfflineReady(true);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setOfflineReady(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
function handleBeforeInstallPrompt(event: Event) {
|
||||
event.preventDefault();
|
||||
setInstallPrompt(event as BeforeInstallPromptEvent);
|
||||
}
|
||||
|
||||
function handleInstalled() {
|
||||
setInstalled(true);
|
||||
setInstallPrompt(null);
|
||||
}
|
||||
|
||||
window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
|
||||
window.addEventListener('appinstalled', handleInstalled);
|
||||
return () => {
|
||||
window.removeEventListener(
|
||||
'beforeinstallprompt',
|
||||
handleBeforeInstallPrompt,
|
||||
);
|
||||
window.removeEventListener('appinstalled', handleInstalled);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
function handleHashChange() {
|
||||
setActiveView(readViewFromHash());
|
||||
@@ -1960,29 +2212,43 @@ function App() {
|
||||
}
|
||||
|
||||
const roomOptions = data ? buildRoomOptions(data.snapshots) : [];
|
||||
const freshness = dashboardFreshness(online, data?.overview.generatedAt);
|
||||
const canInstall = Boolean(secureContext && installPrompt && !installed);
|
||||
|
||||
return (
|
||||
<div className="shell">
|
||||
<SideRail
|
||||
activeView={activeView}
|
||||
canInstall={canInstall}
|
||||
installed={installed}
|
||||
locale={locale}
|
||||
offlineReady={offlineReady}
|
||||
online={online}
|
||||
onInstall={() => void handleInstallApp()}
|
||||
onNavigate={navigateToView}
|
||||
onLocaleChange={setDashboardLocale}
|
||||
onRefresh={() => void refresh(true)}
|
||||
refreshing={refreshing}
|
||||
secureContext={secureContext}
|
||||
t={t}
|
||||
/>
|
||||
<main className="dashboard-content">
|
||||
<SectionNav
|
||||
activeView={activeView}
|
||||
canInstall={canInstall}
|
||||
drawerOpen={drawerOpen}
|
||||
freshness={freshness}
|
||||
installed={installed}
|
||||
locale={locale}
|
||||
onCloseDrawer={() => setDrawerOpen(false)}
|
||||
onInstall={() => void handleInstallApp()}
|
||||
onLocaleChange={setDashboardLocale}
|
||||
onNavigate={navigateToView}
|
||||
onOpenDrawer={() => setDrawerOpen(true)}
|
||||
offlineReady={offlineReady}
|
||||
onRefresh={() => void refresh(true)}
|
||||
refreshing={refreshing}
|
||||
secureContext={secureContext}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
@@ -2008,7 +2274,17 @@ function App() {
|
||||
</div>
|
||||
<UsagePanel overview={data.overview} t={t} />
|
||||
</section>
|
||||
<ControlRail data={data} t={t} />
|
||||
<ControlRail
|
||||
canInstall={canInstall}
|
||||
data={data}
|
||||
installed={installed}
|
||||
locale={locale}
|
||||
offlineReady={offlineReady}
|
||||
online={online}
|
||||
onInstall={() => void handleInstallApp()}
|
||||
secureContext={secureContext}
|
||||
t={t}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -38,6 +38,19 @@ export interface Messages {
|
||||
activeRooms: string;
|
||||
pendingRooms: string;
|
||||
};
|
||||
pwa: {
|
||||
app: string;
|
||||
install: string;
|
||||
installed: string;
|
||||
ready: string;
|
||||
cached: string;
|
||||
online: string;
|
||||
offline: string;
|
||||
fresh: string;
|
||||
stale: string;
|
||||
secureRequired: string;
|
||||
updated: string;
|
||||
};
|
||||
metrics: {
|
||||
agents: string;
|
||||
rooms: string;
|
||||
@@ -299,6 +312,19 @@ export const messages = {
|
||||
activeRooms: '처리 + 대기 룸',
|
||||
pendingRooms: '메시지 대기 룸',
|
||||
},
|
||||
pwa: {
|
||||
app: 'PWA',
|
||||
install: '설치',
|
||||
installed: '설치됨',
|
||||
ready: '오프라인 준비',
|
||||
cached: '캐시됨',
|
||||
online: '온라인',
|
||||
offline: '오프라인',
|
||||
fresh: '최신',
|
||||
stale: '지연',
|
||||
secureRequired: 'HTTPS 필요',
|
||||
updated: '갱신',
|
||||
},
|
||||
metrics: {
|
||||
agents: '에이전트',
|
||||
rooms: '룸',
|
||||
@@ -544,6 +570,19 @@ export const messages = {
|
||||
activeRooms: 'processing + waiting rooms',
|
||||
pendingRooms: 'rooms with pending messages',
|
||||
},
|
||||
pwa: {
|
||||
app: 'PWA',
|
||||
install: 'Install',
|
||||
installed: 'Installed',
|
||||
ready: 'Offline ready',
|
||||
cached: 'Cached',
|
||||
online: 'Online',
|
||||
offline: 'Offline',
|
||||
fresh: 'Fresh',
|
||||
stale: 'Stale',
|
||||
secureRequired: 'HTTPS required',
|
||||
updated: 'Updated',
|
||||
},
|
||||
metrics: {
|
||||
agents: 'agents',
|
||||
rooms: 'rooms',
|
||||
@@ -789,6 +828,19 @@ export const messages = {
|
||||
activeRooms: '处理中 + 等待房间',
|
||||
pendingRooms: '有待处理消息的房间',
|
||||
},
|
||||
pwa: {
|
||||
app: 'PWA',
|
||||
install: '安装',
|
||||
installed: '已安装',
|
||||
ready: '离线可用',
|
||||
cached: '已缓存',
|
||||
online: '在线',
|
||||
offline: '离线',
|
||||
fresh: '最新',
|
||||
stale: '延迟',
|
||||
secureRequired: '需要 HTTPS',
|
||||
updated: '更新',
|
||||
},
|
||||
metrics: {
|
||||
agents: '代理',
|
||||
rooms: '房间',
|
||||
@@ -1034,6 +1086,19 @@ export const messages = {
|
||||
activeRooms: '処理中 + 待機ルーム',
|
||||
pendingRooms: 'メッセージ待ちルーム',
|
||||
},
|
||||
pwa: {
|
||||
app: 'PWA',
|
||||
install: 'インストール',
|
||||
installed: 'インストール済み',
|
||||
ready: 'オフライン可',
|
||||
cached: 'キャッシュ済み',
|
||||
online: 'オンライン',
|
||||
offline: 'オフライン',
|
||||
fresh: '最新',
|
||||
stale: '遅延',
|
||||
secureRequired: 'HTTPS 必須',
|
||||
updated: '更新',
|
||||
},
|
||||
metrics: {
|
||||
agents: 'エージェント',
|
||||
rooms: 'ルーム',
|
||||
|
||||
@@ -206,6 +206,29 @@ button:disabled {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.topbar-status {
|
||||
display: inline-flex;
|
||||
min-height: 34px;
|
||||
align-items: center;
|
||||
padding: 0 11px;
|
||||
border-radius: 999px;
|
||||
color: var(--muted);
|
||||
background: rgba(43, 55, 38, 0.075);
|
||||
font-size: 12px;
|
||||
font-weight: 900;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.topbar-status-offline {
|
||||
color: #7c2518;
|
||||
background: rgba(183, 71, 52, 0.14);
|
||||
}
|
||||
|
||||
.topbar-status-stale {
|
||||
color: #6e4a05;
|
||||
background: rgba(181, 138, 34, 0.16);
|
||||
}
|
||||
|
||||
.side-rail {
|
||||
position: sticky;
|
||||
top: 18px;
|
||||
@@ -271,6 +294,46 @@ button:disabled {
|
||||
background: rgba(191, 95, 44, 0.12);
|
||||
}
|
||||
|
||||
.side-install {
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
background: linear-gradient(135deg, #2b3726, #1c211c);
|
||||
box-shadow: 0 12px 26px rgba(28, 33, 28, 0.18);
|
||||
font-weight: 950;
|
||||
}
|
||||
|
||||
.pwa-card {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
padding: 11px 12px;
|
||||
border: 1px solid rgba(43, 55, 38, 0.1);
|
||||
border-radius: 17px;
|
||||
background: rgba(43, 55, 38, 0.045);
|
||||
}
|
||||
|
||||
.pwa-card span,
|
||||
.drawer-pwa-row span {
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.pwa-card strong {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
font-size: 13px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pwa-card.is-offline {
|
||||
border-color: rgba(183, 71, 52, 0.24);
|
||||
background: rgba(183, 71, 52, 0.08);
|
||||
}
|
||||
|
||||
.language-select {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
@@ -347,6 +410,19 @@ button:disabled {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.drawer-pwa-row {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
.drawer-pwa-row button {
|
||||
min-height: 44px;
|
||||
padding: 0 14px;
|
||||
font-size: 13px;
|
||||
font-weight: 950;
|
||||
}
|
||||
|
||||
.nav-drawer nav {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
@@ -409,7 +485,7 @@ button:disabled {
|
||||
|
||||
.ops-strip {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
gap: 1px;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
@@ -457,6 +533,27 @@ button:disabled {
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.ops-fresh {
|
||||
border-color: rgba(63, 127, 81, 0.16);
|
||||
}
|
||||
|
||||
.ops-stale {
|
||||
border-color: rgba(181, 138, 34, 0.22);
|
||||
}
|
||||
|
||||
.ops-offline {
|
||||
border-color: rgba(183, 71, 52, 0.28);
|
||||
}
|
||||
|
||||
.ops-tile-pwa button {
|
||||
min-height: 30px;
|
||||
width: fit-content;
|
||||
padding: 0 10px;
|
||||
box-shadow: none;
|
||||
font-size: 12px;
|
||||
font-weight: 950;
|
||||
}
|
||||
|
||||
.skeleton-card {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
@@ -1787,7 +1884,7 @@ progress::-moz-progress-bar {
|
||||
top: max(8px, env(safe-area-inset-top));
|
||||
right: 10px;
|
||||
left: 10px;
|
||||
grid-template-columns: 52px minmax(0, 1fr) 54px;
|
||||
grid-template-columns: 52px minmax(0, 1fr) auto 54px;
|
||||
gap: 6px;
|
||||
margin: 0;
|
||||
padding: 8px;
|
||||
@@ -1818,6 +1915,12 @@ progress::-moz-progress-bar {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.topbar-status {
|
||||
min-height: 30px;
|
||||
padding: 0 8px;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.section-nav .refresh-button::before {
|
||||
color: #fffaf0;
|
||||
content: '↻';
|
||||
@@ -2004,6 +2107,12 @@ progress::-moz-progress-bar {
|
||||
.section-nav {
|
||||
right: 8px;
|
||||
left: 8px;
|
||||
grid-template-columns: 48px minmax(0, 1fr) auto 50px;
|
||||
}
|
||||
|
||||
.topbar-status {
|
||||
padding: 0 7px;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.usage-summary {
|
||||
@@ -2057,6 +2166,12 @@ progress::-moz-progress-bar {
|
||||
}
|
||||
}
|
||||
|
||||
@media (display-mode: standalone) {
|
||||
.shell {
|
||||
padding-top: max(18px, env(safe-area-inset-top));
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
|
||||
Reference in New Issue
Block a user