feat: add Ray-Ban display dashboard PoC (#226)
This commit is contained in:
207
apps/dashboard/src/GlassesApp.tsx
Normal file
207
apps/dashboard/src/GlassesApp.tsx
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import {
|
||||||
|
type DashboardInboxAction,
|
||||||
|
type DashboardOverview,
|
||||||
|
type StatusSnapshot,
|
||||||
|
fetchDashboardData,
|
||||||
|
runInboxAction,
|
||||||
|
sendRoomMessage,
|
||||||
|
} from './api';
|
||||||
|
import { isLocale, matchLocale, type Locale } from './i18n';
|
||||||
|
import { GlassesPanel } from './GlassesPanel';
|
||||||
|
import './styles.css';
|
||||||
|
import './glasses.css';
|
||||||
|
|
||||||
|
interface GlassesState {
|
||||||
|
overview: DashboardOverview;
|
||||||
|
snapshots: StatusSnapshot[];
|
||||||
|
}
|
||||||
|
|
||||||
|
type InboxItem = DashboardOverview['inbox'][number];
|
||||||
|
type InboxActionKey = `${string}:${DashboardInboxAction}`;
|
||||||
|
|
||||||
|
const REFRESH_INTERVAL_MS = 15_000;
|
||||||
|
const DASHBOARD_STALE_MS = 75_000;
|
||||||
|
const LOCALE_STORAGE_KEY = 'ejclaw.dashboard.locale.v2';
|
||||||
|
|
||||||
|
function makeClientRequestId(): string {
|
||||||
|
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readInitialLocale(): Locale {
|
||||||
|
const stored =
|
||||||
|
typeof window === 'undefined'
|
||||||
|
? null
|
||||||
|
: window.localStorage.getItem(LOCALE_STORAGE_KEY);
|
||||||
|
if (isLocale(stored)) return stored;
|
||||||
|
|
||||||
|
const languages =
|
||||||
|
typeof navigator === 'undefined'
|
||||||
|
? []
|
||||||
|
: [...(navigator.languages || []), navigator.language];
|
||||||
|
for (const language of languages) {
|
||||||
|
const matched = matchLocale(language);
|
||||||
|
if (matched) return matched;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'en';
|
||||||
|
}
|
||||||
|
|
||||||
|
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 freshnessLabel(
|
||||||
|
online: boolean,
|
||||||
|
generatedAt: string | null | undefined,
|
||||||
|
): string {
|
||||||
|
if (!online) return 'offline';
|
||||||
|
const age = dashboardAgeMs(generatedAt);
|
||||||
|
if (age !== null && age > DASHBOARD_STALE_MS) return 'stale';
|
||||||
|
return 'fresh';
|
||||||
|
}
|
||||||
|
|
||||||
|
function readNickname(): string {
|
||||||
|
if (typeof window === 'undefined') return '';
|
||||||
|
return window.localStorage.getItem('ejclaw-nickname') ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GlassesApp() {
|
||||||
|
const [data, setData] = useState<GlassesState | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
const [online, setOnline] = useState(() =>
|
||||||
|
typeof navigator === 'undefined' ? true : navigator.onLine,
|
||||||
|
);
|
||||||
|
const [locale] = useState<Locale>(readInitialLocale);
|
||||||
|
const [inboxActionKey, setInboxActionKey] = useState<InboxActionKey | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const [roomMessageKey, setRoomMessageKey] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function refresh(showSpinner = false) {
|
||||||
|
if (showSpinner) setRefreshing(true);
|
||||||
|
try {
|
||||||
|
const nextData = await fetchDashboardData();
|
||||||
|
setData({
|
||||||
|
overview: nextData.overview,
|
||||||
|
snapshots: nextData.snapshots,
|
||||||
|
});
|
||||||
|
setError(null);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
setRefreshing(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleInboxAction(
|
||||||
|
item: InboxItem,
|
||||||
|
action: DashboardInboxAction,
|
||||||
|
) {
|
||||||
|
const actionKey: InboxActionKey = `${item.id}:${action}`;
|
||||||
|
setInboxActionKey(actionKey);
|
||||||
|
try {
|
||||||
|
await runInboxAction(item.id, action, {
|
||||||
|
lastOccurredAt: item.lastOccurredAt,
|
||||||
|
requestId: makeClientRequestId(),
|
||||||
|
});
|
||||||
|
await refresh(false);
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
setInboxActionKey(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRoomMessage(
|
||||||
|
roomJid: string,
|
||||||
|
text: string,
|
||||||
|
requestId: string,
|
||||||
|
) {
|
||||||
|
setRoomMessageKey(roomJid);
|
||||||
|
try {
|
||||||
|
await sendRoomMessage(roomJid, text, requestId, readNickname() || null);
|
||||||
|
void refresh(false);
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
setRoomMessageKey(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.documentElement.lang = 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(() => {
|
||||||
|
void refresh();
|
||||||
|
const id = window.setInterval(() => {
|
||||||
|
void refresh();
|
||||||
|
}, REFRESH_INTERVAL_MS);
|
||||||
|
return () => window.clearInterval(id);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (loading && !data) {
|
||||||
|
return (
|
||||||
|
<main className="glasses-shell" aria-busy="true">
|
||||||
|
<div className="glasses-empty">
|
||||||
|
<strong>EJClaw</strong>
|
||||||
|
<span>Loading</span>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
return (
|
||||||
|
<main className="glasses-shell">
|
||||||
|
<p className="glasses-error">{error ?? 'Dashboard unavailable'}</p>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GlassesPanel
|
||||||
|
createRequestId={makeClientRequestId}
|
||||||
|
error={error}
|
||||||
|
freshnessText={freshnessLabel(online, data.overview.generatedAt)}
|
||||||
|
inboxActionKey={inboxActionKey}
|
||||||
|
locale={locale}
|
||||||
|
onInboxAction={handleInboxAction}
|
||||||
|
onRefresh={() => void refresh(true)}
|
||||||
|
onSendRoomMessage={handleRoomMessage}
|
||||||
|
overview={data.overview}
|
||||||
|
refreshing={refreshing}
|
||||||
|
roomMessageKey={roomMessageKey}
|
||||||
|
snapshots={data.snapshots}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
120
apps/dashboard/src/GlassesPanel.test.ts
Normal file
120
apps/dashboard/src/GlassesPanel.test.ts
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import { createElement } from 'react';
|
||||||
|
import { renderToStaticMarkup } from 'react-dom/server';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { GlassesPanel } from './GlassesPanel';
|
||||||
|
import type { DashboardOverview, StatusSnapshot } from './api';
|
||||||
|
|
||||||
|
const overview: DashboardOverview = {
|
||||||
|
generatedAt: '2026-06-07T13:00:00.000Z',
|
||||||
|
services: [],
|
||||||
|
rooms: {
|
||||||
|
total: 1,
|
||||||
|
active: 1,
|
||||||
|
waiting: 0,
|
||||||
|
inactive: 0,
|
||||||
|
},
|
||||||
|
tasks: {
|
||||||
|
total: 1,
|
||||||
|
active: 1,
|
||||||
|
paused: 0,
|
||||||
|
completed: 0,
|
||||||
|
watchers: {
|
||||||
|
active: 0,
|
||||||
|
paused: 0,
|
||||||
|
completed: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
usage: {
|
||||||
|
rows: [],
|
||||||
|
fetchedAt: null,
|
||||||
|
},
|
||||||
|
inbox: [
|
||||||
|
{
|
||||||
|
createdAt: '2026-06-07T12:59:30.000Z',
|
||||||
|
groupKey: 'task:review',
|
||||||
|
id: 'inbox-1',
|
||||||
|
kind: 'reviewer-request',
|
||||||
|
lastOccurredAt: '2026-06-07T12:59:30.000Z',
|
||||||
|
occurrences: 1,
|
||||||
|
occurredAt: '2026-06-07T12:59:30.000Z',
|
||||||
|
roomJid: 'dc:ops',
|
||||||
|
roomName: 'Ops',
|
||||||
|
severity: 'warn',
|
||||||
|
source: 'paired-task',
|
||||||
|
summary: 'Reviewer requested owner changes',
|
||||||
|
taskId: 'task-1',
|
||||||
|
taskStatus: 'active',
|
||||||
|
title: 'Review follow-up',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const snapshots: StatusSnapshot[] = [
|
||||||
|
{
|
||||||
|
agentType: 'codex',
|
||||||
|
assistantName: 'Codex',
|
||||||
|
serviceId: 'codex-main',
|
||||||
|
updatedAt: '2026-06-07T13:00:00.000Z',
|
||||||
|
entries: [
|
||||||
|
{
|
||||||
|
agentType: 'codex',
|
||||||
|
elapsedMs: 12000,
|
||||||
|
folder: 'ejclaw',
|
||||||
|
jid: 'dc:ops',
|
||||||
|
name: 'Ops',
|
||||||
|
pendingMessages: false,
|
||||||
|
pendingTasks: 1,
|
||||||
|
status: 'processing',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('GlassesPanel', () => {
|
||||||
|
it('renders a compact display queue and voice input surface', () => {
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
createElement(GlassesPanel, {
|
||||||
|
createRequestId: () => 'req-1',
|
||||||
|
error: null,
|
||||||
|
freshnessText: 'fresh',
|
||||||
|
inboxActionKey: null,
|
||||||
|
locale: 'ko',
|
||||||
|
onInboxAction: async () => true,
|
||||||
|
onRefresh: () => {},
|
||||||
|
onSendRoomMessage: async () => true,
|
||||||
|
overview,
|
||||||
|
refreshing: false,
|
||||||
|
roomMessageKey: null,
|
||||||
|
snapshots,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(html).toContain('glasses-shell');
|
||||||
|
expect(html).toContain('Review follow-up');
|
||||||
|
expect(html).toContain('리뷰');
|
||||||
|
expect(html).toContain('Voice');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tolerates partial status snapshot payloads while data is refreshing', () => {
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
createElement(GlassesPanel, {
|
||||||
|
createRequestId: () => 'req-1',
|
||||||
|
error: null,
|
||||||
|
freshnessText: 'fresh',
|
||||||
|
inboxActionKey: null,
|
||||||
|
locale: 'ko',
|
||||||
|
onInboxAction: async () => true,
|
||||||
|
onRefresh: () => {},
|
||||||
|
onSendRoomMessage: async () => true,
|
||||||
|
overview,
|
||||||
|
refreshing: false,
|
||||||
|
roomMessageKey: null,
|
||||||
|
snapshots: [{} as StatusSnapshot],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(html).toContain('glasses-shell');
|
||||||
|
expect(html).toContain('Review follow-up');
|
||||||
|
});
|
||||||
|
});
|
||||||
607
apps/dashboard/src/GlassesPanel.tsx
Normal file
607
apps/dashboard/src/GlassesPanel.tsx
Normal file
@@ -0,0 +1,607 @@
|
|||||||
|
import {
|
||||||
|
type KeyboardEvent,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
|
import { Check, Inbox, Mic, RefreshCw, Send, X } from 'lucide-react';
|
||||||
|
|
||||||
|
import {
|
||||||
|
type DashboardInboxAction,
|
||||||
|
type DashboardOverview,
|
||||||
|
type StatusSnapshot,
|
||||||
|
} from './api';
|
||||||
|
import { formatDate } from './dashboardHelpers';
|
||||||
|
import type { Locale } from './i18n';
|
||||||
|
|
||||||
|
type InboxItem = DashboardOverview['inbox'][number];
|
||||||
|
type GlassesMode = 'queue' | 'voice';
|
||||||
|
|
||||||
|
interface RoomChoice {
|
||||||
|
jid: string;
|
||||||
|
label: string;
|
||||||
|
status: StatusSnapshot['entries'][number]['status'];
|
||||||
|
pendingTasks: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SpeechRecognitionResultLike {
|
||||||
|
readonly 0?: { transcript?: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SpeechRecognitionEventLike {
|
||||||
|
readonly results: ArrayLike<SpeechRecognitionResultLike>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SpeechRecognitionLike {
|
||||||
|
continuous: boolean;
|
||||||
|
interimResults: boolean;
|
||||||
|
lang: string;
|
||||||
|
maxAlternatives: number;
|
||||||
|
onend: (() => void) | null;
|
||||||
|
onerror: (() => void) | null;
|
||||||
|
onresult: ((event: SpeechRecognitionEventLike) => void) | null;
|
||||||
|
start: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type SpeechRecognitionConstructor = new () => SpeechRecognitionLike;
|
||||||
|
|
||||||
|
interface SpeechWindow extends Window {
|
||||||
|
SpeechRecognition?: SpeechRecognitionConstructor;
|
||||||
|
webkitSpeechRecognition?: SpeechRecognitionConstructor;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GlassesPanelProps {
|
||||||
|
createRequestId: () => string;
|
||||||
|
error: string | null;
|
||||||
|
freshnessText: string;
|
||||||
|
inboxActionKey: `${string}:${DashboardInboxAction}` | null;
|
||||||
|
locale: Locale;
|
||||||
|
onInboxAction: (
|
||||||
|
item: InboxItem,
|
||||||
|
action: DashboardInboxAction,
|
||||||
|
) => Promise<boolean>;
|
||||||
|
onRefresh: () => void;
|
||||||
|
onSendRoomMessage: (
|
||||||
|
roomJid: string,
|
||||||
|
text: string,
|
||||||
|
requestId: string,
|
||||||
|
) => Promise<boolean>;
|
||||||
|
overview: DashboardOverview;
|
||||||
|
refreshing: boolean;
|
||||||
|
roomMessageKey: string | null;
|
||||||
|
snapshots: StatusSnapshot[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const QUEUE_KIND_ORDER: Record<InboxItem['kind'], number> = {
|
||||||
|
approval: 0,
|
||||||
|
'reviewer-request': 1,
|
||||||
|
'arbiter-request': 2,
|
||||||
|
'ci-failure': 3,
|
||||||
|
mention: 4,
|
||||||
|
'pending-room': 5,
|
||||||
|
};
|
||||||
|
|
||||||
|
const SEVERITY_ORDER: Record<InboxItem['severity'], number> = {
|
||||||
|
error: 0,
|
||||||
|
warn: 1,
|
||||||
|
info: 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
const SPEECH_LOCALES: Record<Locale, string> = {
|
||||||
|
en: 'en-US',
|
||||||
|
ja: 'ja-JP',
|
||||||
|
ko: 'ko-KR',
|
||||||
|
zh: 'zh-CN',
|
||||||
|
};
|
||||||
|
|
||||||
|
function getSpeechRecognition(): SpeechRecognitionConstructor | null {
|
||||||
|
if (typeof window === 'undefined') return null;
|
||||||
|
const speechWindow = window as SpeechWindow;
|
||||||
|
return (
|
||||||
|
speechWindow.SpeechRecognition ??
|
||||||
|
speechWindow.webkitSpeechRecognition ??
|
||||||
|
null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function inboxActionsFor(item: InboxItem): DashboardInboxAction[] {
|
||||||
|
if (
|
||||||
|
item.source === 'paired-task' &&
|
||||||
|
(item.kind === 'reviewer-request' ||
|
||||||
|
item.kind === 'approval' ||
|
||||||
|
item.kind === 'arbiter-request')
|
||||||
|
) {
|
||||||
|
return ['run', 'decline', 'dismiss'];
|
||||||
|
}
|
||||||
|
return ['dismiss'];
|
||||||
|
}
|
||||||
|
|
||||||
|
function actionLabel(item: InboxItem, action: DashboardInboxAction): string {
|
||||||
|
if (action === 'dismiss') return '닫기';
|
||||||
|
if (action === 'decline') return '거절';
|
||||||
|
if (item.kind === 'approval') return '최종화';
|
||||||
|
if (item.kind === 'reviewer-request') return '리뷰';
|
||||||
|
if (item.kind === 'arbiter-request') return '중재';
|
||||||
|
return '실행';
|
||||||
|
}
|
||||||
|
|
||||||
|
function actionIcon(action: DashboardInboxAction) {
|
||||||
|
if (action === 'decline') return <X size={16} aria-hidden />;
|
||||||
|
if (action === 'dismiss') return <Check size={16} aria-hidden />;
|
||||||
|
return <Send size={16} aria-hidden />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortInboxItems(items: InboxItem[]): InboxItem[] {
|
||||||
|
return [...items].sort((a, b) => {
|
||||||
|
const severity = SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity];
|
||||||
|
if (severity !== 0) return severity;
|
||||||
|
const kind = QUEUE_KIND_ORDER[a.kind] - QUEUE_KIND_ORDER[b.kind];
|
||||||
|
if (kind !== 0) return kind;
|
||||||
|
return b.lastOccurredAt.localeCompare(a.lastOccurredAt);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRoomChoices(snapshots: StatusSnapshot[]): RoomChoice[] {
|
||||||
|
const rooms = new Map<string, RoomChoice>();
|
||||||
|
for (const snapshot of snapshots) {
|
||||||
|
const entries = Array.isArray(snapshot.entries) ? snapshot.entries : [];
|
||||||
|
for (const entry of entries) {
|
||||||
|
const existing = rooms.get(entry.jid);
|
||||||
|
const label = entry.name || entry.folder || entry.jid;
|
||||||
|
if (!existing) {
|
||||||
|
rooms.set(entry.jid, {
|
||||||
|
jid: entry.jid,
|
||||||
|
label,
|
||||||
|
pendingTasks: entry.pendingTasks,
|
||||||
|
status: entry.status,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
entry.status === 'processing' ||
|
||||||
|
entry.pendingTasks > existing.pendingTasks
|
||||||
|
) {
|
||||||
|
rooms.set(entry.jid, {
|
||||||
|
jid: entry.jid,
|
||||||
|
label,
|
||||||
|
pendingTasks: entry.pendingTasks,
|
||||||
|
status: entry.status,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...rooms.values()].sort((a, b) => {
|
||||||
|
if (a.status === 'processing' && b.status !== 'processing') return -1;
|
||||||
|
if (b.status === 'processing' && a.status !== 'processing') return 1;
|
||||||
|
return b.pendingTasks - a.pendingTasks || a.label.localeCompare(b.label);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampIndex(index: number, length: number): number {
|
||||||
|
if (length <= 0) return 0;
|
||||||
|
return Math.max(0, Math.min(index, length - 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextIndex(index: number, length: number, delta: number): number {
|
||||||
|
if (length <= 0) return 0;
|
||||||
|
return (index + delta + length) % length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readTranscript(event: SpeechRecognitionEventLike): string {
|
||||||
|
return Array.from(event.results)
|
||||||
|
.map((result) => result[0]?.transcript?.trim() ?? '')
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
interface QueueCardProps {
|
||||||
|
inboxActionKey: `${string}:${DashboardInboxAction}` | null;
|
||||||
|
inboxActions: DashboardInboxAction[];
|
||||||
|
inboxItems: InboxItem[];
|
||||||
|
locale: Locale;
|
||||||
|
onInboxAction: (
|
||||||
|
item: InboxItem,
|
||||||
|
action: DashboardInboxAction,
|
||||||
|
) => Promise<boolean>;
|
||||||
|
selectedActionIndex: number;
|
||||||
|
selectedInbox: InboxItem | undefined;
|
||||||
|
selectedInboxIndex: number;
|
||||||
|
setSelectedActionIndex: (index: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function QueueCard({
|
||||||
|
inboxActionKey,
|
||||||
|
inboxActions,
|
||||||
|
inboxItems,
|
||||||
|
locale,
|
||||||
|
onInboxAction,
|
||||||
|
selectedActionIndex,
|
||||||
|
selectedInbox,
|
||||||
|
selectedInboxIndex,
|
||||||
|
setSelectedActionIndex,
|
||||||
|
}: QueueCardProps) {
|
||||||
|
if (!selectedInbox) {
|
||||||
|
return (
|
||||||
|
<section className="glasses-card glasses-queue-card" aria-live="polite">
|
||||||
|
<div className="glasses-empty">
|
||||||
|
<strong>Queue clear</strong>
|
||||||
|
<span>새 요청 없음</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="glasses-card glasses-queue-card" aria-live="polite">
|
||||||
|
<div className="glasses-card-head">
|
||||||
|
<span className={`glasses-pill sev-${selectedInbox.severity}`}>
|
||||||
|
{selectedInbox.kind}
|
||||||
|
</span>
|
||||||
|
<small>
|
||||||
|
{selectedInboxIndex + 1}/{inboxItems.length}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
<h2>{selectedInbox.title}</h2>
|
||||||
|
<p>{selectedInbox.summary}</p>
|
||||||
|
<dl className="glasses-meta">
|
||||||
|
<div>
|
||||||
|
<dt>Target</dt>
|
||||||
|
<dd>
|
||||||
|
{selectedInbox.roomName ??
|
||||||
|
selectedInbox.groupFolder ??
|
||||||
|
selectedInbox.roomJid ??
|
||||||
|
selectedInbox.taskId ??
|
||||||
|
'-'}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Time</dt>
|
||||||
|
<dd>{formatDate(selectedInbox.lastOccurredAt, locale)}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
<div className="glasses-actions">
|
||||||
|
{inboxActions.map((action, index) => {
|
||||||
|
const actionKey = `${selectedInbox.id}:${action}`;
|
||||||
|
const busy = inboxActionKey === actionKey;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
aria-busy={busy || undefined}
|
||||||
|
aria-pressed={index === selectedActionIndex}
|
||||||
|
className={
|
||||||
|
index === selectedActionIndex ? 'is-active' : undefined
|
||||||
|
}
|
||||||
|
disabled={busy || Boolean(inboxActionKey)}
|
||||||
|
key={action}
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedActionIndex(index);
|
||||||
|
void onInboxAction(selectedInbox, action);
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{actionIcon(action)}
|
||||||
|
{busy ? '처리 중' : actionLabel(selectedInbox, action)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VoiceCardProps {
|
||||||
|
canListen: boolean;
|
||||||
|
listening: boolean;
|
||||||
|
onSendVoiceText: () => void;
|
||||||
|
onStartListening: () => void;
|
||||||
|
selectedRoom: RoomChoice | undefined;
|
||||||
|
sendingVoice: boolean;
|
||||||
|
setSelectedRoomIndex: (index: number) => void;
|
||||||
|
setVoiceText: (value: string) => void;
|
||||||
|
rooms: RoomChoice[];
|
||||||
|
voiceText: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function VoiceCard({
|
||||||
|
canListen,
|
||||||
|
listening,
|
||||||
|
onSendVoiceText,
|
||||||
|
onStartListening,
|
||||||
|
selectedRoom,
|
||||||
|
sendingVoice,
|
||||||
|
setSelectedRoomIndex,
|
||||||
|
setVoiceText,
|
||||||
|
rooms,
|
||||||
|
voiceText,
|
||||||
|
}: VoiceCardProps) {
|
||||||
|
return (
|
||||||
|
<section className="glasses-card glasses-voice-card">
|
||||||
|
<div className="glasses-card-head">
|
||||||
|
<span className="glasses-pill">voice</span>
|
||||||
|
<small>{selectedRoom?.label ?? 'No room'}</small>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
aria-label="음성 또는 키보드 입력"
|
||||||
|
onChange={(event) => setVoiceText(event.target.value)}
|
||||||
|
placeholder="말하거나 입력..."
|
||||||
|
value={voiceText}
|
||||||
|
/>
|
||||||
|
<div className="glasses-room-strip" aria-label="room target">
|
||||||
|
{rooms.slice(0, 4).map((room, index) => (
|
||||||
|
<button
|
||||||
|
aria-pressed={room.jid === selectedRoom?.jid}
|
||||||
|
className={room.jid === selectedRoom?.jid ? 'is-active' : undefined}
|
||||||
|
key={room.jid}
|
||||||
|
onClick={() => setSelectedRoomIndex(index)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{room.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="glasses-actions">
|
||||||
|
<button
|
||||||
|
disabled={!canListen || listening}
|
||||||
|
onClick={onStartListening}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Mic size={16} aria-hidden />
|
||||||
|
{listening ? '듣는 중' : '말하기'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="is-active"
|
||||||
|
disabled={!selectedRoom || !voiceText.trim() || sendingVoice}
|
||||||
|
onClick={onSendVoiceText}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Send size={16} aria-hidden />
|
||||||
|
{sendingVoice ? '전송 중' : '전송'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GlassesHeaderProps {
|
||||||
|
onRefresh: () => void;
|
||||||
|
refreshing: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function GlassesHeader({ onRefresh, refreshing }: GlassesHeaderProps) {
|
||||||
|
return (
|
||||||
|
<header className="glasses-header">
|
||||||
|
<div>
|
||||||
|
<span className="glasses-kicker">EJClaw</span>
|
||||||
|
<h1>Display</h1>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
aria-busy={refreshing || undefined}
|
||||||
|
aria-label="새로고침"
|
||||||
|
className="glasses-icon-button"
|
||||||
|
disabled={refreshing}
|
||||||
|
onClick={onRefresh}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<RefreshCw size={18} aria-hidden />
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ModeTabsProps {
|
||||||
|
mode: GlassesMode;
|
||||||
|
setMode: (mode: GlassesMode) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ModeTabs({ mode, setMode }: ModeTabsProps) {
|
||||||
|
return (
|
||||||
|
<nav className="glasses-tabs" aria-label="display modes">
|
||||||
|
<button
|
||||||
|
aria-pressed={mode === 'queue'}
|
||||||
|
className={mode === 'queue' ? 'is-active' : undefined}
|
||||||
|
onClick={() => setMode('queue')}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Inbox size={16} aria-hidden />
|
||||||
|
Queue
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
aria-pressed={mode === 'voice'}
|
||||||
|
className={mode === 'voice' ? 'is-active' : undefined}
|
||||||
|
onClick={() => setMode('voice')}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Mic size={16} aria-hidden />
|
||||||
|
Voice
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function footerStatus(
|
||||||
|
mode: GlassesMode,
|
||||||
|
busyActionKey: string | null,
|
||||||
|
selectedRoom: RoomChoice | undefined,
|
||||||
|
): string {
|
||||||
|
if (mode === 'queue') return busyActionKey ?? 'ready';
|
||||||
|
return selectedRoom?.status ?? 'ready';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GlassesPanel({
|
||||||
|
createRequestId,
|
||||||
|
error,
|
||||||
|
freshnessText,
|
||||||
|
inboxActionKey,
|
||||||
|
locale,
|
||||||
|
onInboxAction,
|
||||||
|
onRefresh,
|
||||||
|
onSendRoomMessage,
|
||||||
|
overview,
|
||||||
|
refreshing,
|
||||||
|
roomMessageKey,
|
||||||
|
snapshots,
|
||||||
|
}: GlassesPanelProps) {
|
||||||
|
const shellRef = useRef<HTMLElement | null>(null);
|
||||||
|
const [mode, setMode] = useState<GlassesMode>('queue');
|
||||||
|
const [selectedInboxIndex, setSelectedInboxIndex] = useState(0);
|
||||||
|
const [selectedActionIndex, setSelectedActionIndex] = useState(0);
|
||||||
|
const [selectedRoomIndex, setSelectedRoomIndex] = useState(0);
|
||||||
|
const [voiceText, setVoiceText] = useState('');
|
||||||
|
const [listening, setListening] = useState(false);
|
||||||
|
const inboxItems = useMemo(
|
||||||
|
() => sortInboxItems(Array.isArray(overview.inbox) ? overview.inbox : []),
|
||||||
|
[overview.inbox],
|
||||||
|
);
|
||||||
|
const rooms = useMemo(
|
||||||
|
() => buildRoomChoices(Array.isArray(snapshots) ? snapshots : []),
|
||||||
|
[snapshots],
|
||||||
|
);
|
||||||
|
const selectedInbox =
|
||||||
|
inboxItems[clampIndex(selectedInboxIndex, inboxItems.length)];
|
||||||
|
const inboxActions = selectedInbox ? inboxActionsFor(selectedInbox) : [];
|
||||||
|
const selectedAction =
|
||||||
|
inboxActions[clampIndex(selectedActionIndex, inboxActions.length)];
|
||||||
|
const selectedRoom = rooms[clampIndex(selectedRoomIndex, rooms.length)];
|
||||||
|
const canListen = getSpeechRecognition() !== null;
|
||||||
|
const busyActionKey =
|
||||||
|
selectedInbox && selectedAction
|
||||||
|
? `${selectedInbox.id}:${selectedAction}`
|
||||||
|
: null;
|
||||||
|
const sendingVoice = selectedRoom
|
||||||
|
? roomMessageKey === selectedRoom.jid
|
||||||
|
: false;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
shellRef.current?.focus();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedInboxIndex((value) => clampIndex(value, inboxItems.length));
|
||||||
|
}, [inboxItems.length]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedActionIndex((value) => clampIndex(value, inboxActions.length));
|
||||||
|
}, [inboxActions.length]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedRoomIndex((value) => clampIndex(value, rooms.length));
|
||||||
|
}, [rooms.length]);
|
||||||
|
|
||||||
|
function startListening() {
|
||||||
|
const SpeechRecognition = getSpeechRecognition();
|
||||||
|
if (!SpeechRecognition || listening) return;
|
||||||
|
const recognition = new SpeechRecognition();
|
||||||
|
recognition.lang = SPEECH_LOCALES[locale];
|
||||||
|
recognition.continuous = false;
|
||||||
|
recognition.interimResults = false;
|
||||||
|
recognition.maxAlternatives = 1;
|
||||||
|
recognition.onresult = (event) => {
|
||||||
|
const nextText = readTranscript(event);
|
||||||
|
if (nextText) setVoiceText(nextText);
|
||||||
|
};
|
||||||
|
recognition.onerror = () => setListening(false);
|
||||||
|
recognition.onend = () => setListening(false);
|
||||||
|
setListening(true);
|
||||||
|
recognition.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runSelectedAction() {
|
||||||
|
if (!selectedInbox || !selectedAction || inboxActionKey) return;
|
||||||
|
await onInboxAction(selectedInbox, selectedAction);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendVoiceText() {
|
||||||
|
const text = voiceText.trim();
|
||||||
|
if (!selectedRoom || !text || sendingVoice) return;
|
||||||
|
const ok = await onSendRoomMessage(
|
||||||
|
selectedRoom.jid,
|
||||||
|
text,
|
||||||
|
createRequestId(),
|
||||||
|
);
|
||||||
|
if (ok) setVoiceText('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeyDown(event: KeyboardEvent<HTMLElement>) {
|
||||||
|
if (
|
||||||
|
event.target instanceof HTMLInputElement ||
|
||||||
|
event.target instanceof HTMLTextAreaElement
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') {
|
||||||
|
event.preventDefault();
|
||||||
|
setMode((value) => (value === 'queue' ? 'voice' : 'queue'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
|
||||||
|
event.preventDefault();
|
||||||
|
const delta = event.key === 'ArrowDown' ? 1 : -1;
|
||||||
|
if (mode === 'queue') {
|
||||||
|
setSelectedInboxIndex((value) =>
|
||||||
|
nextIndex(value, inboxItems.length, delta),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
setSelectedRoomIndex((value) => nextIndex(value, rooms.length, delta));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
event.preventDefault();
|
||||||
|
if (mode === 'queue') void runSelectedAction();
|
||||||
|
else void sendVoiceText();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main
|
||||||
|
aria-label="EJClaw Ray-Ban Display"
|
||||||
|
className="glasses-shell"
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
ref={shellRef}
|
||||||
|
tabIndex={0}
|
||||||
|
>
|
||||||
|
<GlassesHeader onRefresh={onRefresh} refreshing={refreshing} />
|
||||||
|
|
||||||
|
<section className="glasses-status-row" aria-label="상태">
|
||||||
|
<span>{freshnessText}</span>
|
||||||
|
<strong>{inboxItems.length} items</strong>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{error ? <p className="glasses-error">{error}</p> : null}
|
||||||
|
|
||||||
|
<ModeTabs mode={mode} setMode={setMode} />
|
||||||
|
|
||||||
|
{mode === 'queue' ? (
|
||||||
|
<QueueCard
|
||||||
|
inboxActionKey={inboxActionKey}
|
||||||
|
inboxActions={inboxActions}
|
||||||
|
inboxItems={inboxItems}
|
||||||
|
locale={locale}
|
||||||
|
onInboxAction={onInboxAction}
|
||||||
|
selectedActionIndex={selectedActionIndex}
|
||||||
|
selectedInbox={selectedInbox}
|
||||||
|
selectedInboxIndex={selectedInboxIndex}
|
||||||
|
setSelectedActionIndex={setSelectedActionIndex}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<VoiceCard
|
||||||
|
canListen={canListen}
|
||||||
|
listening={listening}
|
||||||
|
onSendVoiceText={() => void sendVoiceText()}
|
||||||
|
onStartListening={startListening}
|
||||||
|
rooms={rooms}
|
||||||
|
selectedRoom={selectedRoom}
|
||||||
|
sendingVoice={sendingVoice}
|
||||||
|
setSelectedRoomIndex={setSelectedRoomIndex}
|
||||||
|
setVoiceText={setVoiceText}
|
||||||
|
voiceText={voiceText}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<footer className="glasses-footer">
|
||||||
|
<span>{footerStatus(mode, busyActionKey, selectedRoom)}</span>
|
||||||
|
</footer>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
293
apps/dashboard/src/glasses.css
Normal file
293
apps/dashboard/src/glasses.css
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
body:has(.glasses-shell) {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glasses-shell {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto auto auto minmax(0, 1fr) auto;
|
||||||
|
gap: 8px;
|
||||||
|
width: min(100vw, 600px);
|
||||||
|
height: min(100vh, 600px);
|
||||||
|
min-height: 0;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 14px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #050706;
|
||||||
|
color: #f4f1e9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glasses-header,
|
||||||
|
.glasses-status-row,
|
||||||
|
.glasses-card-head,
|
||||||
|
.glasses-actions,
|
||||||
|
.glasses-footer,
|
||||||
|
.glasses-tabs,
|
||||||
|
.glasses-room-strip {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glasses-header {
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glasses-kicker,
|
||||||
|
.glasses-status-row,
|
||||||
|
.glasses-card-head small,
|
||||||
|
.glasses-meta dt,
|
||||||
|
.glasses-footer {
|
||||||
|
color: #a8afa6;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glasses-header h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 26px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glasses-icon-button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glasses-status-row {
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glasses-status-row strong {
|
||||||
|
color: #6cb087;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glasses-error {
|
||||||
|
margin: 0;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px solid rgba(224, 100, 75, 0.45);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(224, 100, 75, 0.12);
|
||||||
|
color: #ffb4a4;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glasses-tabs {
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glasses-tabs button,
|
||||||
|
.glasses-actions button,
|
||||||
|
.glasses-room-strip button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
min-height: 42px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
color: #f4f1e9;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glasses-tabs button:hover:not(:disabled),
|
||||||
|
.glasses-actions button:hover:not(:disabled),
|
||||||
|
.glasses-room-strip button:hover:not(:disabled) {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glasses-tabs button {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glasses-tabs button.is-active,
|
||||||
|
.glasses-tabs button[aria-pressed='true'],
|
||||||
|
.glasses-actions button.is-active,
|
||||||
|
.glasses-actions button[aria-pressed='true'],
|
||||||
|
.glasses-room-strip button.is-active,
|
||||||
|
.glasses-room-strip button[aria-pressed='true'] {
|
||||||
|
border-color: rgba(232, 152, 112, 0.72);
|
||||||
|
background: rgba(214, 130, 88, 0.2);
|
||||||
|
color: #fff4ec;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glasses-tabs button.is-active:hover:not(:disabled),
|
||||||
|
.glasses-tabs button[aria-pressed='true']:hover:not(:disabled),
|
||||||
|
.glasses-actions button.is-active:hover:not(:disabled),
|
||||||
|
.glasses-actions button[aria-pressed='true']:hover:not(:disabled),
|
||||||
|
.glasses-room-strip button.is-active:hover:not(:disabled),
|
||||||
|
.glasses-room-strip button[aria-pressed='true']:hover:not(:disabled) {
|
||||||
|
background: rgba(214, 130, 88, 0.26);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glasses-card {
|
||||||
|
display: grid;
|
||||||
|
align-content: start;
|
||||||
|
gap: 10px;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #111512;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glasses-card-head {
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glasses-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
max-width: 100%;
|
||||||
|
min-height: 24px;
|
||||||
|
padding: 0 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
color: #d7ded2;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
text-transform: uppercase;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glasses-pill.sev-error {
|
||||||
|
border-color: rgba(224, 100, 75, 0.5);
|
||||||
|
color: #ffb4a4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glasses-pill.sev-warn {
|
||||||
|
border-color: rgba(212, 160, 74, 0.5);
|
||||||
|
color: #ffd38b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glasses-queue-card h2 {
|
||||||
|
margin: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
font-size: 22px;
|
||||||
|
line-height: 1.08;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glasses-queue-card p {
|
||||||
|
display: -webkit-box;
|
||||||
|
min-height: 0;
|
||||||
|
margin: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
color: #d2d5ce;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.35;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
-webkit-line-clamp: 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glasses-meta {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glasses-meta div {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glasses-meta dt,
|
||||||
|
.glasses-meta dd {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glasses-meta dd {
|
||||||
|
margin: 2px 0 0;
|
||||||
|
color: #f4f1e9;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glasses-actions {
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glasses-actions button {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glasses-empty {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
min-height: 220px;
|
||||||
|
color: #a8afa6;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glasses-empty strong {
|
||||||
|
color: #f4f1e9;
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glasses-voice-card textarea {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 126px;
|
||||||
|
resize: none;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
color: #f4f1e9;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 1.35;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glasses-voice-card textarea:focus-visible {
|
||||||
|
border-color: #d68258;
|
||||||
|
outline: 2px solid rgba(214, 130, 88, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glasses-room-strip {
|
||||||
|
gap: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glasses-room-strip button {
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 50%;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 0 10px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glasses-footer {
|
||||||
|
min-height: 18px;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.glasses-shell {
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,13 @@ import { StrictMode } from 'react';
|
|||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
|
|
||||||
import App from './App';
|
import App from './App';
|
||||||
|
import { GlassesApp } from './GlassesApp';
|
||||||
|
|
||||||
|
function isGlassesRoute(): boolean {
|
||||||
|
const pathname = window.location.pathname.replace(/\/+$/, '') || '/';
|
||||||
|
const search = new URLSearchParams(window.location.search);
|
||||||
|
return pathname === '/glasses' || search.get('display') === 'rayban';
|
||||||
|
}
|
||||||
|
|
||||||
const root = document.getElementById('root');
|
const root = document.getElementById('root');
|
||||||
|
|
||||||
@@ -10,7 +17,5 @@ if (!root) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
createRoot(root).render(
|
createRoot(root).render(
|
||||||
<StrictMode>
|
<StrictMode>{isGlassesRoute() ? <GlassesApp /> : <App />}</StrictMode>,
|
||||||
<App />
|
|
||||||
</StrictMode>,
|
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user