feat(web-settings): ✨ Add SettingsPage component and route configuration for user settings UI
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
c906f12682
commit
b63bf0f9e3
3 changed files with 520 additions and 21 deletions
|
|
@ -31,28 +31,26 @@ const GlobalStyle = createGlobalStyle`
|
|||
|
||||
const NAVIGATION: NavSection[] = [
|
||||
{
|
||||
title: 'Core',
|
||||
title: 'Companion',
|
||||
items: [
|
||||
{ to: '/', label: 'Chat' },
|
||||
{ to: '/personas', label: 'Personas' },
|
||||
{ to: '/personality', label: 'Personality' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Automation',
|
||||
items: [
|
||||
{ to: '/nag', label: 'Nag' },
|
||||
{ to: '/process', label: 'Process' },
|
||||
{ to: '/sessions', label: 'Sessions' },
|
||||
{ to: '/status', label: 'Status' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Roadmap',
|
||||
title: 'AI Core',
|
||||
items: [
|
||||
{ to: '/personas', label: 'Personas' },
|
||||
{ to: '/personality', label: 'Personality' },
|
||||
{ to: '/memory', label: 'Memory' },
|
||||
{ to: '/skills', label: 'Skills' },
|
||||
{ to: '/routines', label: 'Routines' },
|
||||
{ to: '/nudges', label: 'Nudges' },
|
||||
{ to: '/nag', label: 'Nag' },
|
||||
{ to: '/process', label: 'Process' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Settings',
|
||||
items: [
|
||||
{ to: '/settings', label: 'Settings' },
|
||||
],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { lazy, Suspense } from 'react';
|
||||
import type { ReactElement } from 'react';
|
||||
import { Routes, Route } from 'react-router-dom';
|
||||
import { StubPage } from '../features/stubs/StubPage';
|
||||
|
||||
const ChatPage = lazy(() => import('../features/chat/ChatPage').then((m) => ({ default: m.ChatPage })));
|
||||
const PersonasPage = lazy(() => import('../features/personas/PersonasPage').then((m) => ({ default: m.PersonasPage })));
|
||||
|
|
@ -10,23 +9,24 @@ const PersonalityPage = lazy(() => import('../features/personality/PersonalityPa
|
|||
const NagPage = lazy(() => import('../features/nag/NagPage').then((m) => ({ default: m.NagPage })));
|
||||
const ProcessPage = lazy(() => import('../features/process/ProcessPage').then((m) => ({ default: m.ProcessPage })));
|
||||
const StatusPage = lazy(() => import('../features/status/StatusPage').then((m) => ({ default: m.StatusPage })));
|
||||
const MemoryPage = lazy(() => import('../features/memory/MemoryPage').then((m) => ({ default: m.MemoryPage })));
|
||||
const SessionsPage = lazy(() => import('../features/sessions/SessionsPage').then((m) => ({ default: m.SessionsPage })));
|
||||
const SettingsPage = lazy(() => import('../features/settings/SettingsPage').then((m) => ({ default: m.SettingsPage })));
|
||||
|
||||
export function AppRoutes(): ReactElement {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<Routes>
|
||||
<Route path="/" element={<ChatPage />} />
|
||||
<Route path="/sessions" element={<SessionsPage />} />
|
||||
<Route path="/status" element={<StatusPage />} />
|
||||
<Route path="/personas" element={<PersonasPage />} />
|
||||
<Route path="/personas/:id" element={<PersonaDetailPage />} />
|
||||
<Route path="/personality" element={<PersonalityPage />} />
|
||||
<Route path="/memory" element={<MemoryPage />} />
|
||||
<Route path="/nag" element={<NagPage />} />
|
||||
<Route path="/process" element={<ProcessPage />} />
|
||||
<Route path="/status" element={<StatusPage />} />
|
||||
<Route path="/memory" element={<StubPage title="Memory" version="v1.1" description="Semantic memory storage, fact extraction, and contextual recall for persistent personality." />} />
|
||||
<Route path="/skills" element={<StubPage title="Skills" version="future" description="Skill catalog for managing AI capabilities — enable, disable, and configure domain-specific tools." />} />
|
||||
<Route path="/routines" element={<StubPage title="Routines" version="future" description="Scheduled routine management — morning check-ins, evening digests, and custom recurring flows." />} />
|
||||
<Route path="/nudges" element={<StubPage title="Nudges" version="future" description="Gentle recurring pings — configurable speed, escalation, and context-aware prompting." />} />
|
||||
<Route path="/settings" element={<StubPage title="Settings" version="future" description="Companion preferences, voice configuration, persona selection, and notification settings." />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
);
|
||||
|
|
|
|||
501
@applications/web/src/features/settings/SettingsPage.tsx
Normal file
501
@applications/web/src/features/settings/SettingsPage.tsx
Normal file
|
|
@ -0,0 +1,501 @@
|
|||
import { useCallback, useState } from 'react';
|
||||
import type { ReactElement } from 'react';
|
||||
import styled from '@lilith/ui-styled-components';
|
||||
import type { ThemeInterface } from '@lilith/ui-theme';
|
||||
import { useSettings } from './useSettings';
|
||||
import { usePersonas } from '../personas/usePersonas';
|
||||
import { usePushSubscription } from '../push/usePushSubscription';
|
||||
import { createSession } from '../errors/sessionRecovery';
|
||||
import { API_BASE_URL, AI_API_BASE_URL, SOCKET_BASE_URL } from '../../lib/constants';
|
||||
|
||||
interface ThemedProps {
|
||||
theme: ThemeInterface;
|
||||
}
|
||||
|
||||
const Page = styled.div`
|
||||
max-width: 800px;
|
||||
`;
|
||||
|
||||
const Title = styled.h1<ThemedProps>`
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: ${({ theme }) => theme.colors.text.primary};
|
||||
margin-bottom: ${({ theme }) => theme.spacing.lg};
|
||||
`;
|
||||
|
||||
const Card = styled.div<ThemedProps>`
|
||||
background: ${({ theme }) => theme.colors.surface};
|
||||
border: 1px solid ${({ theme }) => theme.colors.border};
|
||||
border-radius: 10px;
|
||||
margin-bottom: ${({ theme }) => theme.spacing.lg};
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const CardTitle = styled.h2<ThemedProps>`
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: ${({ theme }) => theme.colors.text.secondary};
|
||||
padding: 12px 16px 8px;
|
||||
border-bottom: 1px solid ${({ theme }) => theme.colors.border};
|
||||
`;
|
||||
|
||||
const Row = styled.div<ThemedProps>`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid ${({ theme }) => theme.colors.border};
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
`;
|
||||
|
||||
const RowLabel = styled.span<ThemedProps>`
|
||||
font-size: 14px;
|
||||
color: ${({ theme }) => theme.colors.text.primary};
|
||||
`;
|
||||
|
||||
const RowValue = styled.span<ThemedProps>`
|
||||
font-size: 13px;
|
||||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||
color: ${({ theme }) => theme.colors.text.secondary};
|
||||
max-width: 260px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
`;
|
||||
|
||||
const ToggleTrack = styled.button<{ $on: boolean } & ThemedProps>`
|
||||
width: 44px;
|
||||
height: 26px;
|
||||
border-radius: 13px;
|
||||
border: none;
|
||||
background: ${({ $on, theme }) => ($on ? theme.colors.primary : theme.colors.border)};
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
transition: background 200ms ease;
|
||||
flex-shrink: 0;
|
||||
outline: none;
|
||||
|
||||
&:focus-visible {
|
||||
box-shadow: 0 0 0 2px ${({ theme }) => theme.colors.primary};
|
||||
}
|
||||
`;
|
||||
|
||||
const ToggleThumb = styled.span<{ $on: boolean }>`
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
left: ${({ $on }) => ($on ? '21px' : '3px')};
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: #e2e8f0;
|
||||
transition: left 200ms ease;
|
||||
`;
|
||||
|
||||
const VolumeRow = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
`;
|
||||
|
||||
const VolumeSlider = styled.input<ThemedProps>`
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 120px;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: ${({ theme }) => theme.colors.border};
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
|
||||
&::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: ${({ theme }) => theme.colors.primary};
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&::-moz-range-thumb {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: ${({ theme }) => theme.colors.primary};
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
`;
|
||||
|
||||
const VolumeLabel = styled.span<ThemedProps>`
|
||||
font-size: 12px;
|
||||
color: ${({ theme }) => theme.colors.text.secondary};
|
||||
min-width: 36px;
|
||||
text-align: right;
|
||||
`;
|
||||
|
||||
const PersonaSelect = styled.select<ThemedProps>`
|
||||
background: ${({ theme }) => theme.colors.background.primary};
|
||||
border: 1px solid ${({ theme }) => theme.colors.border};
|
||||
border-radius: 6px;
|
||||
color: ${({ theme }) => theme.colors.text.primary};
|
||||
font-size: 13px;
|
||||
padding: 6px 10px;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
|
||||
&:focus {
|
||||
border-color: ${({ theme }) => theme.colors.primary};
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
`;
|
||||
|
||||
const PersonaRow = styled.div<ThemedProps>`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid ${({ theme }) => theme.colors.border};
|
||||
flex-wrap: wrap;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
`;
|
||||
|
||||
const ApplyButton = styled.button<ThemedProps>`
|
||||
padding: 6px 14px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid ${({ theme }) => theme.colors.primary};
|
||||
background: transparent;
|
||||
color: ${({ theme }) => theme.colors.primary};
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: background 150ms ease;
|
||||
white-space: nowrap;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: ${({ theme }) => theme.colors.primary}22;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
`;
|
||||
|
||||
const ActiveBadge = styled.span<ThemedProps>`
|
||||
font-size: 11px;
|
||||
color: ${({ theme }) => theme.colors.success};
|
||||
border: 1px solid ${({ theme }) => theme.colors.success};
|
||||
border-radius: 4px;
|
||||
padding: 2px 6px;
|
||||
white-space: nowrap;
|
||||
`;
|
||||
|
||||
const StatusBadge = styled.span<{ $ok: boolean } & ThemedProps>`
|
||||
font-size: 12px;
|
||||
color: ${({ $ok, theme }) => ($ok ? theme.colors.success : theme.colors.text.secondary)};
|
||||
border: 1px solid ${({ $ok, theme }) => ($ok ? theme.colors.success : theme.colors.border)};
|
||||
border-radius: 4px;
|
||||
padding: 2px 8px;
|
||||
`;
|
||||
|
||||
const PushButton = styled.button<{ $denied: boolean } & ThemedProps>`
|
||||
padding: 6px 14px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid ${({ $denied, theme }) => ($denied ? theme.colors.error : theme.colors.primary)};
|
||||
background: transparent;
|
||||
color: ${({ $denied, theme }) => ($denied ? theme.colors.error : theme.colors.primary)};
|
||||
font-size: 13px;
|
||||
cursor: ${({ $denied }) => ($denied ? 'default' : 'pointer')};
|
||||
transition: background 150ms ease;
|
||||
white-space: nowrap;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: ${({ theme }) => theme.colors.primary}22;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
`;
|
||||
|
||||
const CopyButton = styled.button<ThemedProps>`
|
||||
background: none;
|
||||
border: 1px solid ${({ theme }) => theme.colors.border};
|
||||
border-radius: 4px;
|
||||
color: ${({ theme }) => theme.colors.text.secondary};
|
||||
font-size: 11px;
|
||||
padding: 3px 8px;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
margin-left: 8px;
|
||||
|
||||
&:active {
|
||||
background: ${({ theme }) => theme.colors.background.primary};
|
||||
}
|
||||
`;
|
||||
|
||||
const ErrorText = styled.p<ThemedProps>`
|
||||
font-size: 13px;
|
||||
color: ${({ theme }) => theme.colors.error};
|
||||
padding: 8px 16px;
|
||||
`;
|
||||
|
||||
const LoadingText = styled.p<ThemedProps>`
|
||||
font-size: 13px;
|
||||
color: ${({ theme }) => theme.colors.text.secondary};
|
||||
padding: 8px 16px;
|
||||
`;
|
||||
|
||||
function Toggle({
|
||||
on,
|
||||
onChange,
|
||||
label,
|
||||
}: {
|
||||
on: boolean;
|
||||
onChange: (v: boolean) => void;
|
||||
label: string;
|
||||
}): ReactElement {
|
||||
return (
|
||||
<ToggleTrack
|
||||
$on={on}
|
||||
onClick={() => onChange(!on)}
|
||||
role="switch"
|
||||
aria-checked={on}
|
||||
aria-label={label}
|
||||
>
|
||||
<ToggleThumb $on={on} />
|
||||
</ToggleTrack>
|
||||
);
|
||||
}
|
||||
|
||||
function pushLabel(status: string): string {
|
||||
switch (status) {
|
||||
case 'subscribed': return 'Subscribed';
|
||||
case 'requesting': return 'Requesting...';
|
||||
case 'denied': return 'Blocked by browser';
|
||||
case 'unsupported': return 'Not supported';
|
||||
case 'error': return 'Retry';
|
||||
default: return 'Subscribe';
|
||||
}
|
||||
}
|
||||
|
||||
export function SettingsPage(): ReactElement {
|
||||
const {
|
||||
ttsEnabled,
|
||||
ttsVolume,
|
||||
sttEnabled,
|
||||
setTtsEnabled,
|
||||
setTtsVolume,
|
||||
setSttEnabled,
|
||||
} = useSettings();
|
||||
|
||||
const { personas, loading: personasLoading, error: personasError } = usePersonas();
|
||||
const { status: pushStatus, subscribe } = usePushSubscription(API_BASE_URL);
|
||||
|
||||
const activePersona = personas.find((p) => p.is_active);
|
||||
const [selectedPersonaId, setSelectedPersonaId] = useState<string>('');
|
||||
const [applying, setApplying] = useState(false);
|
||||
const [applyError, setApplyError] = useState<string | null>(null);
|
||||
const [applySuccess, setApplySuccess] = useState(false);
|
||||
|
||||
const currentSessionId = sessionStorage.getItem('companion_session_id') ?? '';
|
||||
|
||||
const handleApplyPersona = useCallback(async (): Promise<void> => {
|
||||
const personaId = selectedPersonaId || activePersona?.id;
|
||||
if (!personaId || applying) return;
|
||||
setApplying(true);
|
||||
setApplyError(null);
|
||||
setApplySuccess(false);
|
||||
try {
|
||||
await createSession(API_BASE_URL, personaId);
|
||||
setApplySuccess(true);
|
||||
} catch (err) {
|
||||
setApplyError(err instanceof Error ? err.message : 'Failed to apply persona');
|
||||
} finally {
|
||||
setApplying(false);
|
||||
}
|
||||
}, [selectedPersonaId, activePersona, applying]);
|
||||
|
||||
const handleCopySession = useCallback((): void => {
|
||||
void navigator.clipboard.writeText(currentSessionId);
|
||||
}, [currentSessionId]);
|
||||
|
||||
const pushActionable = pushStatus === 'idle' || pushStatus === 'error';
|
||||
const pushSubscribed = pushStatus === 'subscribed';
|
||||
const pushDenied = pushStatus === 'denied';
|
||||
const pushDisabled = pushStatus === 'requesting' || pushStatus === 'unsupported' || pushSubscribed || pushDenied;
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<Title>Settings</Title>
|
||||
|
||||
<Card>
|
||||
<CardTitle>Voice Output (TTS)</CardTitle>
|
||||
<Row>
|
||||
<RowLabel>Enable TTS</RowLabel>
|
||||
<Toggle on={ttsEnabled} onChange={setTtsEnabled} label="Enable text-to-speech" />
|
||||
</Row>
|
||||
<Row>
|
||||
<RowLabel>Volume</RowLabel>
|
||||
<VolumeRow>
|
||||
<VolumeSlider
|
||||
type="range"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={ttsVolume}
|
||||
disabled={!ttsEnabled}
|
||||
onChange={(e) => setTtsVolume(Number(e.target.value))}
|
||||
aria-label="TTS volume"
|
||||
/>
|
||||
<VolumeLabel>{Math.round(ttsVolume * 100)}%</VolumeLabel>
|
||||
</VolumeRow>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardTitle>Voice Input (STT)</CardTitle>
|
||||
<Row>
|
||||
<RowLabel>Enable microphone</RowLabel>
|
||||
<Toggle on={sttEnabled} onChange={setSttEnabled} label="Enable speech-to-text" />
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardTitle>Persona</CardTitle>
|
||||
{personasLoading && <LoadingText>Loading personas...</LoadingText>}
|
||||
{personasError && <ErrorText>{personasError}</ErrorText>}
|
||||
{!personasLoading && !personasError && (
|
||||
<>
|
||||
<Row>
|
||||
<RowLabel>Active</RowLabel>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<RowValue>{activePersona?.name ?? '—'}</RowValue>
|
||||
{activePersona && <ActiveBadge>active</ActiveBadge>}
|
||||
</div>
|
||||
</Row>
|
||||
<PersonaRow>
|
||||
<RowLabel>Switch to</RowLabel>
|
||||
<PersonaSelect
|
||||
value={selectedPersonaId}
|
||||
onChange={(e) => {
|
||||
setSelectedPersonaId(e.target.value);
|
||||
setApplySuccess(false);
|
||||
setApplyError(null);
|
||||
}}
|
||||
disabled={applying}
|
||||
aria-label="Select persona"
|
||||
>
|
||||
<option value="">-- select persona --</option>
|
||||
{personas.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}{p.is_active ? ' (current)' : ''}
|
||||
</option>
|
||||
))}
|
||||
</PersonaSelect>
|
||||
<ApplyButton
|
||||
onClick={() => { void handleApplyPersona(); }}
|
||||
disabled={applying || !selectedPersonaId}
|
||||
>
|
||||
{applying ? 'Applying...' : 'Apply'}
|
||||
</ApplyButton>
|
||||
{applySuccess && <ActiveBadge>Session created</ActiveBadge>}
|
||||
</PersonaRow>
|
||||
{applyError && <ErrorText>{applyError}</ErrorText>}
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardTitle>Push Notifications</CardTitle>
|
||||
<Row>
|
||||
<RowLabel>Status</RowLabel>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<StatusBadge $ok={pushSubscribed}>
|
||||
{pushSubscribed
|
||||
? 'Subscribed'
|
||||
: pushDenied
|
||||
? 'Blocked'
|
||||
: pushStatus === 'unsupported'
|
||||
? 'Unsupported'
|
||||
: 'Not subscribed'}
|
||||
</StatusBadge>
|
||||
{!pushSubscribed && (
|
||||
<PushButton
|
||||
$denied={pushDenied}
|
||||
onClick={pushActionable ? () => { void subscribe(); } : undefined}
|
||||
disabled={pushDisabled}
|
||||
aria-label={pushLabel(pushStatus)}
|
||||
>
|
||||
{pushLabel(pushStatus)}
|
||||
</PushButton>
|
||||
)}
|
||||
</div>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardTitle>Session</CardTitle>
|
||||
<Row>
|
||||
<RowLabel>Session ID</RowLabel>
|
||||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<RowValue title={currentSessionId}>
|
||||
{currentSessionId ? `${currentSessionId.slice(0, 16)}…` : '—'}
|
||||
</RowValue>
|
||||
{currentSessionId && (
|
||||
<CopyButton onClick={handleCopySession} aria-label="Copy session ID">
|
||||
Copy
|
||||
</CopyButton>
|
||||
)}
|
||||
</div>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardTitle>Environment</CardTitle>
|
||||
<Row>
|
||||
<RowLabel>API Base URL</RowLabel>
|
||||
<RowValue title={API_BASE_URL}>{API_BASE_URL}</RowValue>
|
||||
</Row>
|
||||
<Row>
|
||||
<RowLabel>AI API Base URL</RowLabel>
|
||||
<RowValue title={AI_API_BASE_URL}>{AI_API_BASE_URL}</RowValue>
|
||||
</Row>
|
||||
<Row>
|
||||
<RowLabel>Socket Base URL</RowLabel>
|
||||
<RowValue title={SOCKET_BASE_URL || '(origin)'}>{SOCKET_BASE_URL || '(origin)'}</RowValue>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardTitle>About</CardTitle>
|
||||
<Row>
|
||||
<RowLabel>Companion</RowLabel>
|
||||
<RowValue>v1.0</RowValue>
|
||||
</Row>
|
||||
<Row>
|
||||
<RowLabel>Dashboard</RowLabel>
|
||||
<RowValue>Developer Dashboard</RowValue>
|
||||
</Row>
|
||||
</Card>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue