Files
Remote-Terminal-for-MeshCore/frontend/src/components/settings/SettingsLocalSection.tsx
Jack Kingsman af76546287 Pass 2
2026-04-16 21:44:52 -07:00

893 lines
32 KiB
TypeScript

import { useState, useEffect } from 'react';
import { ChevronRight, Logs, MessageSquare, Send, Settings, X } from 'lucide-react';
import { toast } from '../ui/sonner';
import { usePush } from '../../contexts/PushSubscriptionContext';
import type { Channel, Contact } from '../../types';
import { getContactDisplayName } from '../../utils/pubkey';
import { Button } from '../ui/button';
import { Input } from '../ui/input';
import { Label } from '../ui/label';
import { Separator } from '../ui/separator';
import { cn } from '../../lib/utils';
import { ContactAvatar } from '../ContactAvatar';
import {
captureLastViewedConversationFromHash,
getReopenLastConversationEnabled,
setReopenLastConversationEnabled,
} from '../../utils/lastViewedConversation';
import { ThemeSelector } from './ThemeSelector';
import { getLocalLabel, setLocalLabel, type LocalLabel } from '../../utils/localLabel';
import {
DISTANCE_UNIT_LABELS,
DISTANCE_UNITS,
setSavedDistanceUnit,
} from '../../utils/distanceUnits';
import { useDistanceUnit } from '../../contexts/DistanceUnitContext';
import {
DEFAULT_FONT_SCALE,
FONT_SCALE_SLIDER_STEP,
MAX_FONT_SCALE,
MIN_FONT_SCALE,
getSavedFontScale,
setSavedFontScale,
} from '../../utils/fontScale';
import { getAutoFocusInputEnabled, setAutoFocusInputEnabled } from '../../utils/autoFocusInput';
import {
BATTERY_DISPLAY_CHANGE_EVENT,
getShowBatteryPercent,
setShowBatteryPercent as saveBatteryPercent,
getShowBatteryVoltage,
setShowBatteryVoltage as saveBatteryVoltage,
} from '../../utils/batteryDisplay';
import {
STATUS_DOT_PULSE_CHANGE_EVENT,
getStatusDotPulseEnabled,
setStatusDotPulseEnabled as saveStatusDotPulse,
} from '../../utils/statusDotPulse';
/** Resolve a state key like "contact-abc123" or "channel-def456" to a display name. */
function resolveConversationName(
stateKey: string,
contacts: Contact[],
channels: Channel[]
): string {
if (stateKey.startsWith('contact-')) {
const pubkey = stateKey.slice('contact-'.length);
const contact = contacts.find((c) => c.public_key === pubkey);
return contact ? getContactDisplayName(contact.name, contact.public_key) : pubkey.slice(0, 12);
}
if (stateKey.startsWith('channel-')) {
const key = stateKey.slice('channel-'.length);
const channel = channels.find((c) => c.key === key);
if (channel?.name) return channel.name.startsWith('#') ? channel.name : `#${channel.name}`;
return `#${key.slice(0, 12)}`;
}
return stateKey;
}
function PushDeviceManagement({
contacts = [],
channels = [],
}: {
contacts?: Contact[];
channels?: Channel[];
}) {
const {
isSupported,
allSubscriptions,
pushConversations,
loading,
subscribe,
currentSubscriptionId,
toggleConversation,
deleteSubscription,
testPush,
refreshSubscriptions,
} = usePush();
useEffect(() => {
refreshSubscriptions();
}, [refreshSubscriptions]);
if (!isSupported) {
return (
<div className="space-y-3">
<Label>Web Push Notifications</Label>
<p className="text-sm text-muted-foreground">
{window.isSecureContext
? 'Push notifications are not supported by this browser.'
: 'Web Push requires HTTPS. Access RemoteTerm over HTTPS (self-signed certificates work) to enable push notifications.'}
</p>
</div>
);
}
return (
<div className="space-y-4">
<div className="space-y-1">
<Label>Web Push Notifications</Label>
<p className="text-sm text-muted-foreground">
Receive notifications even when the browser is closed. Use the bell icon in any
conversation header to enable push for that contact or channel, or subscribe this browser
to receive notifications for all push-enabled conversations.
</p>
<p className="text-sm text-muted-foreground">
The set of channels or DMs that trigger push notifications are global per-install (i.e.
all devices that register for Web Push will have the same set of channels/DMs that trigger
notifications). Subscribing or unsubscribing a particular browser only controls whether
that browser receives notifications for the configured set of channels/DMs.
</p>
</div>
{!currentSubscriptionId && (
<Button variant="outline" size="sm" onClick={() => void subscribe()} disabled={loading}>
{loading ? 'Subscribing...' : 'Subscribe This Browser'}
</Button>
)}
{pushConversations.length > 0 && (
<div className="space-y-2">
<span className="text-[0.625rem] uppercase tracking-wider text-muted-foreground font-medium">
Push-enabled conversations
</span>
<div className="flex flex-wrap gap-1.5">
{pushConversations.map((key) => (
<span
key={key}
className="inline-flex items-center gap-1 rounded-full bg-muted px-2.5 py-1 text-sm"
>
{resolveConversationName(key, contacts, channels)}
<button
type="button"
onClick={() => void toggleConversation(key)}
className="rounded-full p-0.5 hover:bg-accent transition-colors"
title="Remove"
aria-label={`Remove ${resolveConversationName(key, contacts, channels)} from push`}
>
<X className="h-3.5 w-3.5" />
</button>
</span>
))}
</div>
</div>
)}
{allSubscriptions.length > 0 && (
<div className="space-y-2">
<span className="text-[0.625rem] uppercase tracking-wider text-muted-foreground font-medium">
Registered Devices
</span>
<div className="mt-2 space-y-2">
{allSubscriptions.map((sub) => (
<div
key={sub.id}
className="flex items-center justify-between gap-3 rounded-md border border-border px-3 py-2"
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 overflow-hidden">
<span className="truncate text-sm font-medium">
{sub.label || 'Unknown device'}
</span>
{sub.id === currentSubscriptionId && (
<span className="shrink-0 rounded bg-primary/10 px-1.5 py-0.5 text-[0.625rem] font-medium text-primary">
Current device
</span>
)}
</div>
<span className="text-xs text-muted-foreground">
{sub.last_success_at
? `Last push: ${new Date(sub.last_success_at * 1000).toLocaleDateString()}`
: 'Never pushed'}
{sub.failure_count > 0 && ` · ${sub.failure_count} failures`}
</span>
</div>
<div className="flex gap-1">
<Button
variant="ghost"
size="sm"
className="h-8 text-sm"
onClick={() => void testPush(sub.id)}
>
Test
</Button>
<Button
variant="ghost"
size="sm"
className="h-8 text-sm text-destructive hover:text-destructive"
onClick={() => {
void deleteSubscription(sub.id).then(() => toast.success('Device removed'));
}}
>
Unsubscribe this device
</Button>
</div>
</div>
))}
</div>
</div>
)}
</div>
);
}
export function SettingsLocalSection({
onLocalLabelChange,
contacts,
channels,
className,
}: {
onLocalLabelChange?: (label: LocalLabel) => void;
contacts?: Contact[];
channels?: Channel[];
className?: string;
}) {
const { distanceUnit, setDistanceUnit } = useDistanceUnit();
const [reopenLastConversation, setReopenLastConversation] = useState(
getReopenLastConversationEnabled
);
const [localLabelText, setLocalLabelText] = useState(() => getLocalLabel().text);
const [localLabelColor, setLocalLabelColor] = useState(() => getLocalLabel().color);
const [autoFocusInput, setAutoFocusInput] = useState(getAutoFocusInputEnabled);
const [batteryPercent, setBatteryPercent] = useState(getShowBatteryPercent);
const [batteryVoltage, setBatteryVoltage] = useState(getShowBatteryVoltage);
const [statusDotPulse, setStatusDotPulse] = useState(getStatusDotPulseEnabled);
const [fontScale, setFontScale] = useState(getSavedFontScale);
const [fontScaleSlider, setFontScaleSlider] = useState(getSavedFontScale);
const [fontScaleInput, setFontScaleInput] = useState(() => String(getSavedFontScale()));
const commitFontScale = (nextScale: number) => {
const normalized = setSavedFontScale(nextScale);
setFontScale(normalized);
setFontScaleSlider(normalized);
setFontScaleInput(String(normalized));
};
const restoreFontScaleInput = () => {
setFontScaleInput(String(fontScale));
};
const handleSliderChange = (nextScale: number) => {
setFontScaleSlider(nextScale);
setFontScaleInput(String(nextScale));
};
const handleSliderCommit = (nextScale: number) => {
commitFontScale(nextScale);
};
const handleToggleReopenLastConversation = (enabled: boolean) => {
setReopenLastConversation(enabled);
setReopenLastConversationEnabled(enabled);
if (enabled) {
captureLastViewedConversationFromHash();
}
};
return (
<div className={className}>
<p className="text-sm text-muted-foreground">
These settings apply only to this device/browser.
</p>
<div className="space-y-1">
<Label>Color Scheme</Label>
<ThemeSelector />
<ThemePreview className="mt-6" />
</div>
<Separator />
<div className="space-y-3">
<Label>Local Label</Label>
<div className="flex items-center gap-2">
<Input
value={localLabelText}
onChange={(e) => {
const text = e.target.value;
setLocalLabelText(text);
setLocalLabel(text, localLabelColor);
onLocalLabelChange?.({ text, color: localLabelColor });
}}
placeholder="e.g. Home Base, Field Radio 2"
aria-label="Local label text"
className="flex-1"
/>
<input
type="color"
value={localLabelColor}
onChange={(e) => {
const color = e.target.value;
setLocalLabelColor(color);
setLocalLabel(localLabelText, color);
onLocalLabelChange?.({ text: localLabelText, color });
}}
aria-label="Local label color"
className="w-10 h-9 rounded border border-input cursor-pointer bg-transparent p-0.5"
/>
</div>
<p className="text-xs text-muted-foreground">
Display a colored banner at the top of the page to identify this instance.
</p>
</div>
<Separator />
<div className="space-y-3">
<Label htmlFor="distance-units">Distance Units</Label>
<select
id="distance-units"
value={distanceUnit}
onChange={(event) => {
const nextUnit = event.target.value as (typeof DISTANCE_UNITS)[number];
setSavedDistanceUnit(nextUnit);
setDistanceUnit(nextUnit);
}}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
>
{DISTANCE_UNITS.map((unit) => (
<option key={unit} value={unit}>
{DISTANCE_UNIT_LABELS[unit]}
</option>
))}
</select>
<p className="text-xs text-muted-foreground">
Controls how distances are shown throughout the app.
</p>
</div>
<Separator />
<div className="space-y-3">
<Label>UI Tweaks</Label>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={reopenLastConversation}
onChange={(e) => handleToggleReopenLastConversation(e.target.checked)}
className="w-4 h-4 rounded border-input accent-primary"
/>
<span className="text-sm">Reopen to last viewed channel/conversation</span>
</label>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={autoFocusInput}
onChange={(e) => {
const v = e.target.checked;
setAutoFocusInput(v);
setAutoFocusInputEnabled(v);
}}
className="w-4 h-4 rounded border-input accent-primary"
/>
<span className="text-sm">Auto-focus input on conversation load (desktop only)</span>
</label>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={batteryPercent}
onChange={(e) => {
const v = e.target.checked;
setBatteryPercent(v);
saveBatteryPercent(v);
window.dispatchEvent(new Event(BATTERY_DISPLAY_CHANGE_EVENT));
}}
className="w-4 h-4 rounded border-input accent-primary"
/>
<span className="text-sm">Show battery percentage in status bar</span>
</label>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={batteryVoltage}
onChange={(e) => {
const v = e.target.checked;
setBatteryVoltage(v);
saveBatteryVoltage(v);
window.dispatchEvent(new Event(BATTERY_DISPLAY_CHANGE_EVENT));
}}
className="w-4 h-4 rounded border-input accent-primary"
/>
<span className="text-sm">Show battery voltage in status bar</span>
</label>
{(batteryPercent || batteryVoltage) && (
<p className="text-xs text-muted-foreground ml-7">
Battery data updates every 60 seconds and may take up to a minute to appear after
connecting.
</p>
)}
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={statusDotPulse}
onChange={(e) => {
const v = e.target.checked;
setStatusDotPulse(v);
saveStatusDotPulse(v);
window.dispatchEvent(new Event(STATUS_DOT_PULSE_CHANGE_EVENT));
}}
className="w-4 h-4 rounded border-input accent-primary"
/>
<span className="text-sm">
Glitter status dot as packets arrive (blue = channel, purple = DM, cyan = advert, dark
green = other)
</span>
</label>
<div className="space-y-3">
<Label htmlFor="font-scale-input">Relative Font Size</Label>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<input
type="range"
min={MIN_FONT_SCALE}
max={MAX_FONT_SCALE}
step={FONT_SCALE_SLIDER_STEP}
value={fontScaleSlider}
onChange={(event) => handleSliderChange(Number(event.target.value))}
onMouseUp={(event) => handleSliderCommit(Number(event.currentTarget.value))}
onTouchEnd={(event) => handleSliderCommit(Number(event.currentTarget.value))}
onKeyUp={(event) => handleSliderCommit(Number(event.currentTarget.value))}
onBlur={(event) => handleSliderCommit(Number(event.currentTarget.value))}
aria-label="Relative font size slider"
className="w-full accent-primary sm:flex-1"
/>
<div className="flex items-center gap-2 sm:w-40">
<Input
id="font-scale-input"
type="number"
inputMode="decimal"
min={MIN_FONT_SCALE}
max={MAX_FONT_SCALE}
step="any"
value={fontScaleInput}
onChange={(event) => {
const nextValue = event.target.value;
setFontScaleInput(nextValue);
if (nextValue === '') {
return;
}
if (event.target.validity.valid && Number.isFinite(event.target.valueAsNumber)) {
commitFontScale(event.target.valueAsNumber);
}
}}
onBlur={() => {
const parsed = Number.parseFloat(fontScaleInput);
if (!Number.isFinite(parsed)) {
restoreFontScaleInput();
return;
}
commitFontScale(parsed);
}}
onKeyDown={(event) => {
if (event.key !== 'Enter') {
return;
}
event.preventDefault();
const parsed = Number.parseFloat(fontScaleInput);
if (!Number.isFinite(parsed)) {
restoreFontScaleInput();
return;
}
commitFontScale(parsed);
}}
aria-label="Relative font size percentage"
/>
<span className="text-sm text-muted-foreground">%</span>
</div>
<button
type="button"
onClick={() => commitFontScale(DEFAULT_FONT_SCALE)}
className="inline-flex h-9 items-center justify-center rounded-md border border-input px-3 text-sm font-medium transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
disabled={fontScale === DEFAULT_FONT_SCALE}
>
Reset
</button>
</div>
<p className="text-xs text-muted-foreground">
Scales the app&apos;s typography for this browser only. The slider moves in 5% steps;
the number field accepts any value from 25% to 400%.
</p>
</div>
</div>
<Separator />
<PushDeviceManagement contacts={contacts} channels={channels} />
</div>
);
}
function ThemePreview({ className }: { className?: string }) {
const [showStyleRef, setShowStyleRef] = useState(false);
return (
<div className={`rounded-lg border border-border bg-card p-3 ${className ?? ''}`}>
<p className="text-xs text-muted-foreground mb-3">
Preview alert, message, sidebar, and badge contrast for the selected theme.
</p>
<div className="space-y-2">
<PreviewBanner className="border border-status-connected/30 bg-status-connected/15 text-status-connected">
Connected preview: radio link healthy and syncing.
</PreviewBanner>
<PreviewBanner className="border border-warning/50 bg-warning/10 text-warning">
Warning preview: packet audit suggests missing history.
</PreviewBanner>
<PreviewBanner className="border border-destructive/30 bg-destructive/10 text-destructive">
Error preview: radio reconnect failed.
</PreviewBanner>
</div>
<div className="mt-4 space-y-2">
<PreviewMessage
sender="Alice"
bubbleClassName="bg-msg-incoming text-foreground"
text="Hello, mesh!"
/>
<PreviewMessage
sender="You"
alignRight
bubbleClassName="bg-msg-outgoing text-foreground"
text="Hi there! I'm using RemoteTerm."
/>
</div>
<div className="mt-4 rounded-md border border-border bg-background p-2">
<p className="mb-2 text-[0.6875rem] font-medium text-muted-foreground">Sidebar preview</p>
<div className="space-y-1">
<PreviewSidebarRow
active
leading={
<span
className="flex h-6 w-6 items-center justify-center rounded-md bg-primary/10 text-primary"
aria-hidden="true"
>
<Logs className="h-3.5 w-3.5" />
</span>
}
label="Packet Feed"
/>
<PreviewSidebarRow
leading={<ContactAvatar name="Alice" publicKey={'ab'.repeat(32)} size={24} />}
label="Alice"
badge={
<span className="rounded-full bg-badge-unread/90 px-1.5 py-0.5 text-[0.625rem] font-semibold text-badge-unread-foreground">
3
</span>
}
/>
<PreviewSidebarRow
leading={<ContactAvatar name="Mesh Ops" publicKey={'cd'.repeat(32)} size={24} />}
label="Mesh Ops"
badge={
<span className="rounded-full bg-badge-mention px-1.5 py-0.5 text-[0.625rem] font-semibold text-badge-mention-foreground">
@2
</span>
}
/>
</div>
</div>
{/* ── Style Reference (collapsible) ── */}
<button
type="button"
onClick={() => setShowStyleRef((v) => !v)}
className="mt-4 flex w-full items-center gap-1.5 text-[0.6875rem] font-medium text-muted-foreground hover:text-foreground transition-colors"
>
<ChevronRight
className={cn('h-3.5 w-3.5 transition-transform', showStyleRef && 'rotate-90')}
/>
Canonical style reference
</button>
{showStyleRef && (
<>
{/* ── Text Hierarchy ── */}
<PreviewSection title="Text hierarchy">
<div className="space-y-2">
<PreviewTextRow
classes="text-xl font-semibold"
label="text-xl font-semibold"
desc="Hero / large data"
/>
<PreviewTextRow
classes="text-lg font-semibold"
label="text-lg font-semibold"
desc="Sheet / dialog title"
/>
<PreviewTextRow
classes="text-base font-semibold"
label="text-base font-semibold"
desc="Section title"
/>
<PreviewTextRow classes="text-sm" label="text-sm" desc="Body text, form labels" />
<PreviewTextRow
classes="text-xs text-muted-foreground"
label="text-xs text-muted-foreground"
desc="Helper text"
/>
<PreviewTextRow
classes="text-[0.6875rem] text-muted-foreground"
label="text-[0.6875rem] text-muted-foreground"
desc="Metadata, timestamps"
/>
<div>
<p className="text-[0.625rem] uppercase tracking-wider text-muted-foreground font-medium">
Section Label
</p>
<p className="text-[0.625rem] text-muted-foreground/60 mt-0.5">
text-[0.625rem] uppercase tracking-wider text-muted-foreground font-medium
</p>
</div>
</div>
</PreviewSection>
{/* ── Mono Text ── */}
<PreviewSection title="Mono text">
<div className="space-y-1.5">
<div>
<p className="text-xs font-mono text-muted-foreground">
a1b2c3d4e5f6...7890abcdef01
</p>
<p className="text-[0.625rem] text-muted-foreground/60">
text-xs font-mono keys, identifiers
</p>
</div>
<div>
<p className="text-[0.6875rem] font-mono">1h 23m 45s uptime</p>
<p className="text-[0.625rem] text-muted-foreground/60">
text-[0.6875rem] font-mono metadata mono
</p>
</div>
<div>
<p className="text-sm font-mono">$ req_status_sync 0xA1B2...</p>
<p className="text-[0.625rem] text-muted-foreground/60">
text-sm font-mono console / code
</p>
</div>
</div>
</PreviewSection>
{/* ── Badges ── */}
<PreviewSection title="Badges and tags">
<div className="flex flex-wrap items-center gap-2">
<span className="text-[0.625rem] uppercase tracking-wider px-1.5 py-0.5 rounded bg-muted text-muted-foreground font-medium">
Hashtag
</span>
<span className="text-[0.625rem] uppercase tracking-wider px-1.5 py-0.5 rounded bg-muted text-muted-foreground font-medium">
Repeater
</span>
<span className="text-[0.625rem] uppercase tracking-wider px-1.5 py-0.5 rounded bg-primary/10 text-primary font-medium">
On Radio
</span>
<span className="rounded-full bg-badge-unread/90 px-1.5 py-0.5 text-[0.625rem] font-semibold text-badge-unread-foreground">
3
</span>
<span className="rounded-full bg-badge-mention px-1.5 py-0.5 text-[0.625rem] font-semibold text-badge-mention-foreground">
@2
</span>
</div>
<p className="text-[0.625rem] text-muted-foreground/60 mt-1.5">
Muted: bg-muted &middot; Primary: bg-primary/10 &middot; Unread/Mention: bg-badge-*
</p>
</PreviewSection>
{/* ── Buttons ── */}
<PreviewSection title="Buttons">
<div className="space-y-3">
<div>
<p className="text-[0.625rem] text-muted-foreground/60 mb-1.5">
Standard variants (size sm)
</p>
<div className="flex flex-wrap gap-1.5">
<Button size="sm">Default</Button>
<Button size="sm" variant="outline">
Outline
</Button>
<Button size="sm" variant="secondary">
Secondary
</Button>
<Button size="sm" variant="destructive">
Destructive
</Button>
<Button size="sm" variant="ghost">
Ghost
</Button>
<Button size="icon" variant="outline">
<Settings className="h-4 w-4" />
</Button>
<Button size="icon" variant="outline">
<Send className="h-4 w-4" />
</Button>
</div>
</div>
<div>
<p className="text-[0.625rem] text-muted-foreground/60 mb-1.5">
Semantic outline variants
</p>
<div className="flex flex-wrap gap-1.5">
<Button
size="sm"
variant="outline"
className="border-destructive/50 text-destructive hover:bg-destructive/10"
>
Danger
</Button>
<Button
size="sm"
variant="outline"
className="border-warning/50 text-warning hover:bg-warning/10"
>
Warning
</Button>
<Button
size="sm"
variant="outline"
className="border-green-600/50 text-green-600 hover:bg-green-600/10"
>
Success
</Button>
</div>
</div>
<div>
<p className="text-[0.625rem] text-muted-foreground/60 mb-1.5">
Metric selector pills
</p>
<div className="flex gap-1">
{['Voltage', 'Noise Floor', 'Packets'].map((label, i) => (
<button
key={label}
type="button"
className={cn(
'text-[0.6875rem] px-2 py-0.5 rounded transition-colors',
i === 0
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
)}
>
{label}
</button>
))}
</div>
</div>
</div>
</PreviewSection>
{/* ── Clickable Text ── */}
<PreviewSection title="Clickable text">
<div className="space-y-1.5">
<span
role="button"
tabIndex={0}
className="text-xs font-mono text-muted-foreground cursor-pointer hover:text-primary transition-colors block"
>
a1b2c3d4e5f6 (click to copy)
</span>
<span
role="button"
tabIndex={0}
className="text-sm cursor-pointer underline underline-offset-2 decoration-muted-foreground/50 hover:text-primary transition-colors"
>
Underlined navigational link
</span>
</div>
<p className="text-[0.625rem] text-muted-foreground/60 mt-1.5">
cursor-pointer hover:text-primary transition-colors use role=&quot;button&quot; +
tabIndex
</p>
</PreviewSection>
{/* ── Inline Alerts ── */}
<PreviewSection title="Inline alerts">
<div className="space-y-1.5">
<div className="rounded-md border border-info/30 bg-info/10 px-3 py-2 text-xs text-info">
Info: channel slot cache refreshed from radio.
</div>
<div className="rounded-md border border-warning/30 bg-warning/10 px-3 py-2 text-xs text-warning">
Warning: radio clock skew detected.
</div>
<div className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
Error: post-connect setup timed out. Reboot the radio and restart.
</div>
</div>
</PreviewSection>
</>
)}
</div>
);
}
function PreviewSection({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="mt-4 rounded-md border border-border bg-background p-2">
<p className="mb-2 text-[0.6875rem] font-medium text-muted-foreground">{title}</p>
{children}
</div>
);
}
function PreviewTextRow({
classes,
label,
desc,
}: {
classes: string;
label: string;
desc: string;
}) {
return (
<div>
<p className={classes}>Sample text at this size</p>
<p className="text-[0.625rem] text-muted-foreground/60">
{label} {desc}
</p>
</div>
);
}
function PreviewBanner({ children, className }: { children: React.ReactNode; className: string }) {
return <div className={`rounded-md px-3 py-2 text-xs ${className}`}>{children}</div>;
}
function PreviewMessage({
sender,
text,
bubbleClassName,
alignRight = false,
}: {
sender: string;
text: string;
bubbleClassName: string;
alignRight?: boolean;
}) {
return (
<div className={`flex ${alignRight ? 'justify-end' : 'justify-start'}`}>
<div className={`max-w-[85%] ${alignRight ? 'items-end' : 'items-start'} flex flex-col`}>
<span className="mb-1 text-[0.6875rem] text-muted-foreground">{sender}</span>
<div className={`rounded-2xl px-3 py-2 text-sm break-words ${bubbleClassName}`}>{text}</div>
</div>
</div>
);
}
function PreviewSidebarRow({
leading,
label,
badge,
active = false,
}: {
leading: React.ReactNode;
label: string;
badge?: React.ReactNode;
active?: boolean;
}) {
return (
<div
data-active={active ? 'true' : undefined}
className={`sidebar-action-row flex items-center gap-2 rounded-md border-l-2 px-3 py-2 text-[0.8125rem] ${
active ? 'border-l-primary bg-accent text-foreground' : 'border-l-transparent'
}`}
>
<span className="sidebar-tool-icon" aria-hidden="true">
{leading}
</span>
<span className={`sidebar-tool-label min-w-0 flex-1 truncate ${active ? 'font-medium' : ''}`}>
{label}
</span>
{badge}
{!badge && (
<span className="sidebar-tool-icon" aria-hidden="true">
<MessageSquare className="h-3.5 w-3.5" />
</span>
)}
</div>
);
}