mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-10 02:41:14 +00:00
03f91cd5bb
* feat(cloud-backup): mirror fleet snapshots to S3-compatible storage
Add an Admiral-tier Cloud Backup feature that replicates every fleet
snapshot to off-site storage, with two provider modes that share the
same `@aws-sdk/client-s3` code path:
- Sencho Cloud Backup: zero-config, 500 MB allowance backed by
Cloudflare R2, provisioned via the sencho.io worker against the
user's Lemon Squeezy license.
- Custom S3 (BYOB): any S3-compatible bucket (AWS, MinIO, Backblaze
B2, Wasabi, R2 with own keys), with credentials encrypted via
`CryptoService` before storage.
API-triggered snapshots upload fire-and-forget so the UI returns
immediately; scheduled snapshots block on the upload so the task's
success/failure reflects cloud durability. Object keys include the
instance_id segment to prevent collisions when the same Admiral
license is activated on multiple Sencho instances.
* fix(cloud-backup): drop ES2022-only Error cause arg breaking ES2020 build
The backend tsconfig pins lib to ES2020. The two-argument
`Error(message, { cause })` form requires ES2022, so tsc rejected it
with TS2554. Revert to single-argument throw to match the
convention used elsewhere in the backend services.
539 lines
25 KiB
TypeScript
539 lines
25 KiB
TypeScript
import { useState, useEffect, useLayoutEffect, useRef, useMemo, useCallback } from 'react';
|
|
import {
|
|
Dialog,
|
|
DialogClose,
|
|
DialogContent,
|
|
DialogTitle,
|
|
DialogDescription,
|
|
} from '@/components/ui/dialog';
|
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
|
import { VisuallyHidden } from '@radix-ui/react-visually-hidden';
|
|
import {
|
|
CommandDialog,
|
|
CommandEmpty,
|
|
CommandGroup,
|
|
CommandInput,
|
|
CommandItem,
|
|
CommandList,
|
|
} from '@/components/ui/command';
|
|
import { toast } from '@/components/ui/toast-store';
|
|
import { apiFetch } from '@/lib/api';
|
|
import { cn } from '@/lib/utils';
|
|
import { SENCHO_SETTINGS_CHANGED } from '@/lib/events';
|
|
import type { SenchoSettingsChangedDetail } from '@/lib/events';
|
|
import { Search, X, Lock } from 'lucide-react';
|
|
import { NodeManager } from './NodeManager';
|
|
import { useNodes } from '@/context/NodeContext';
|
|
import { useAuth } from '@/context/AuthContext';
|
|
import { useLicense } from '@/context/LicenseContext';
|
|
import { SSOSection } from './SSOSection';
|
|
import { ApiTokensSection } from './ApiTokensSection';
|
|
import { RegistriesSection } from './RegistriesSection';
|
|
import {
|
|
AccountSection,
|
|
AppearanceSection,
|
|
LicenseSection,
|
|
UsersSection,
|
|
SystemSection,
|
|
NotificationsSection,
|
|
NotificationRoutingSection,
|
|
WebhooksSection,
|
|
SecuritySection,
|
|
CloudBackupSection,
|
|
DeveloperSection,
|
|
AppStoreSection,
|
|
SupportSection,
|
|
AboutSection,
|
|
LabelsSection,
|
|
DEFAULT_SETTINGS,
|
|
SETTINGS_GROUPS,
|
|
SETTINGS_ITEMS,
|
|
getSettingsItem,
|
|
getSettingsGroup,
|
|
isItemVisible,
|
|
isItemLocked,
|
|
} from './settings';
|
|
import type {
|
|
PatchableSettings,
|
|
SectionId,
|
|
SettingsItemMeta,
|
|
VisibilityContext,
|
|
} from './settings';
|
|
|
|
interface SettingsModalProps {
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
initialSection?: SectionId;
|
|
onLabelsChanged?: () => void;
|
|
}
|
|
|
|
export function SettingsModal({ isOpen, onClose, initialSection, onLabelsChanged }: SettingsModalProps) {
|
|
const { activeNode } = useNodes();
|
|
const { isAdmin } = useAuth();
|
|
const { license, isPaid } = useLicense();
|
|
const isRemote = activeNode?.type === 'remote';
|
|
const isAdmiral = isPaid && license?.variant === 'admiral';
|
|
const [activeSection, setActiveSection] = useState<SectionId>(initialSection || 'account');
|
|
const [commandOpen, setCommandOpen] = useState(false);
|
|
|
|
const visibility: VisibilityContext = useMemo(
|
|
() => ({ isRemote, isAdmin, isPaid, isAdmiral }),
|
|
[isRemote, isAdmin, isPaid, isAdmiral],
|
|
);
|
|
|
|
const visibleItems = useMemo(
|
|
() => SETTINGS_ITEMS.filter(item => isItemVisible(item, visibility)),
|
|
[visibility],
|
|
);
|
|
|
|
const visibleGroups = useMemo(() => {
|
|
return SETTINGS_GROUPS
|
|
.map(group => ({
|
|
...group,
|
|
items: visibleItems.filter(item => item.group === group.id),
|
|
}))
|
|
.filter(group => group.items.length > 0);
|
|
}, [visibleItems]);
|
|
|
|
const contentViewportRef = useRef<HTMLDivElement | null>(null);
|
|
const scrollPositionsRef = useRef<Partial<Record<SectionId, number>>>({});
|
|
|
|
const switchSection = useCallback((next: SectionId) => {
|
|
if (contentViewportRef.current) {
|
|
scrollPositionsRef.current[activeSection] = contentViewportRef.current.scrollTop;
|
|
}
|
|
setActiveSection(next);
|
|
}, [activeSection]);
|
|
|
|
useLayoutEffect(() => {
|
|
if (contentViewportRef.current) {
|
|
contentViewportRef.current.scrollTop = scrollPositionsRef.current[activeSection] ?? 0;
|
|
}
|
|
}, [activeSection]);
|
|
|
|
useEffect(() => {
|
|
if (isOpen && initialSection) setActiveSection(initialSection);
|
|
}, [isOpen, initialSection]);
|
|
|
|
useEffect(() => {
|
|
const current = getSettingsItem(activeSection);
|
|
if (!current) return;
|
|
if (!isItemVisible(current, visibility) && visibleItems.length > 0) {
|
|
setActiveSection(visibleItems[0].id);
|
|
}
|
|
}, [visibility, visibleItems, activeSection]);
|
|
|
|
const handleDialogKeyDown = useCallback((event: React.KeyboardEvent) => {
|
|
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
setCommandOpen(open => !open);
|
|
}
|
|
}, []);
|
|
|
|
const [authData, setAuthData] = useState({ oldPassword: '', newPassword: '', confirmPassword: '' });
|
|
const [isSavingPassword, setIsSavingPassword] = useState(false);
|
|
|
|
const [settings, setSettings] = useState<PatchableSettings>({ ...DEFAULT_SETTINGS });
|
|
const serverSettingsRef = useRef<PatchableSettings>({ ...DEFAULT_SETTINGS });
|
|
const [isSettingsLoading, setIsSettingsLoading] = useState(false);
|
|
const [isSavingSystem, setIsSavingSystem] = useState(false);
|
|
const [isSavingDeveloper, setIsSavingDeveloper] = useState(false);
|
|
|
|
const hasSystemChanges =
|
|
settings.host_cpu_limit !== serverSettingsRef.current.host_cpu_limit ||
|
|
settings.host_ram_limit !== serverSettingsRef.current.host_ram_limit ||
|
|
settings.host_disk_limit !== serverSettingsRef.current.host_disk_limit ||
|
|
settings.docker_janitor_gb !== serverSettingsRef.current.docker_janitor_gb ||
|
|
settings.global_crash !== serverSettingsRef.current.global_crash;
|
|
|
|
const hasDeveloperChanges =
|
|
settings.developer_mode !== serverSettingsRef.current.developer_mode ||
|
|
settings.metrics_retention_hours !== serverSettingsRef.current.metrics_retention_hours ||
|
|
settings.log_retention_days !== serverSettingsRef.current.log_retention_days ||
|
|
settings.audit_retention_days !== serverSettingsRef.current.audit_retention_days;
|
|
|
|
const sectionDirtyFlags: Partial<Record<SectionId, boolean>> = {
|
|
system: hasSystemChanges,
|
|
developer: hasDeveloperChanges,
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (isOpen) fetchSettings();
|
|
}, [isOpen, activeNode?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
const fetchSettings = async () => {
|
|
setIsSettingsLoading(true);
|
|
try {
|
|
const nodeRes = await apiFetch('/settings');
|
|
const localRes = isRemote ? await apiFetch('/settings', { localOnly: true }) : nodeRes;
|
|
const nodeData: Record<string, string> = nodeRes.ok ? await nodeRes.json() : {};
|
|
const localData: Record<string, string> = (isRemote && localRes.ok)
|
|
? await localRes.json()
|
|
: nodeData;
|
|
|
|
const safe: PatchableSettings = {
|
|
host_cpu_limit: nodeData.host_cpu_limit ?? DEFAULT_SETTINGS.host_cpu_limit,
|
|
host_ram_limit: nodeData.host_ram_limit ?? DEFAULT_SETTINGS.host_ram_limit,
|
|
host_disk_limit: nodeData.host_disk_limit ?? DEFAULT_SETTINGS.host_disk_limit,
|
|
docker_janitor_gb: nodeData.docker_janitor_gb ?? DEFAULT_SETTINGS.docker_janitor_gb,
|
|
global_crash: (nodeData.global_crash as '0' | '1') ?? DEFAULT_SETTINGS.global_crash,
|
|
template_registry_url: nodeData.template_registry_url ?? '',
|
|
developer_mode: (localData.developer_mode as '0' | '1') ?? DEFAULT_SETTINGS.developer_mode,
|
|
metrics_retention_hours: localData.metrics_retention_hours ?? DEFAULT_SETTINGS.metrics_retention_hours,
|
|
log_retention_days: localData.log_retention_days ?? DEFAULT_SETTINGS.log_retention_days,
|
|
audit_retention_days: localData.audit_retention_days ?? DEFAULT_SETTINGS.audit_retention_days,
|
|
};
|
|
setSettings(safe);
|
|
serverSettingsRef.current = { ...safe };
|
|
} catch (e) {
|
|
console.error('Failed to fetch settings', e);
|
|
} finally {
|
|
setIsSettingsLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleSettingChange = <K extends keyof PatchableSettings>(key: K, value: PatchableSettings[K]) => {
|
|
setSettings(prev => ({ ...prev, [key]: value }));
|
|
};
|
|
|
|
const patchSettings = async (payload: PatchableSettings, setLoading: (v: boolean) => void, localOnly = false): Promise<boolean> => {
|
|
setLoading(true);
|
|
try {
|
|
const res = await apiFetch('/settings', {
|
|
method: 'PATCH',
|
|
body: JSON.stringify(payload),
|
|
localOnly,
|
|
});
|
|
if (!res.ok) {
|
|
const err = await res.json().catch(() => ({}));
|
|
toast.error(err?.error || err?.message || 'Failed to save settings.');
|
|
return false;
|
|
}
|
|
serverSettingsRef.current = { ...serverSettingsRef.current, ...payload };
|
|
return true;
|
|
} catch (e: unknown) {
|
|
toast.error((e as Error)?.message || 'Something went wrong.');
|
|
return false;
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const saveSystemSettings = async () => {
|
|
const ok = await patchSettings({
|
|
host_cpu_limit: settings.host_cpu_limit,
|
|
host_ram_limit: settings.host_ram_limit,
|
|
host_disk_limit: settings.host_disk_limit,
|
|
docker_janitor_gb: settings.docker_janitor_gb,
|
|
global_crash: settings.global_crash,
|
|
}, setIsSavingSystem);
|
|
if (ok) toast.success('System limits saved.');
|
|
};
|
|
|
|
const saveDeveloperSettings = async () => {
|
|
const payload = {
|
|
developer_mode: settings.developer_mode,
|
|
metrics_retention_hours: settings.metrics_retention_hours,
|
|
log_retention_days: settings.log_retention_days,
|
|
audit_retention_days: settings.audit_retention_days,
|
|
};
|
|
const ok = await patchSettings(payload, setIsSavingDeveloper, true);
|
|
if (ok) {
|
|
toast.success('Developer settings saved.');
|
|
window.dispatchEvent(new CustomEvent<SenchoSettingsChangedDetail>(SENCHO_SETTINGS_CHANGED, {
|
|
detail: { changedKeys: Object.keys(payload) },
|
|
}));
|
|
}
|
|
};
|
|
|
|
const handlePasswordChange = async () => {
|
|
if (!authData.oldPassword || !authData.newPassword || !authData.confirmPassword) {
|
|
toast.error('All fields are required');
|
|
return;
|
|
}
|
|
if (authData.newPassword !== authData.confirmPassword) {
|
|
toast.error('New passwords do not match');
|
|
return;
|
|
}
|
|
if (authData.newPassword.length < 8) {
|
|
toast.error('New password must be at least 8 characters');
|
|
return;
|
|
}
|
|
setIsSavingPassword(true);
|
|
try {
|
|
const res = await apiFetch('/auth/password', {
|
|
method: 'PUT',
|
|
body: JSON.stringify({ oldPassword: authData.oldPassword, newPassword: authData.newPassword }),
|
|
});
|
|
if (res.ok) {
|
|
toast.success('Password updated successfully');
|
|
setAuthData({ oldPassword: '', newPassword: '', confirmPassword: '' });
|
|
} else {
|
|
const data = await res.json().catch(() => ({}));
|
|
toast.error(data?.error || 'Failed to update password');
|
|
}
|
|
} catch (e: unknown) {
|
|
toast.error((e as Error)?.message || 'Network error during password change');
|
|
} finally {
|
|
setIsSavingPassword(false);
|
|
}
|
|
};
|
|
|
|
const handleRegistrySaved = (key: keyof PatchableSettings, value: string) => {
|
|
serverSettingsRef.current = { ...serverSettingsRef.current, [key]: value };
|
|
};
|
|
|
|
const activeItem = getSettingsItem(activeSection);
|
|
const activeGroup = activeItem ? getSettingsGroup(activeItem.group) : undefined;
|
|
|
|
const renderSection = () => {
|
|
switch (activeSection) {
|
|
case 'account':
|
|
return (
|
|
<AccountSection
|
|
authData={authData}
|
|
onAuthDataChange={setAuthData}
|
|
onPasswordChange={handlePasswordChange}
|
|
isSaving={isSavingPassword}
|
|
/>
|
|
);
|
|
case 'appearance': return <AppearanceSection />;
|
|
case 'license': return <LicenseSection />;
|
|
case 'users': return <UsersSection />;
|
|
case 'sso': return <SSOSection />;
|
|
case 'api-tokens': return <ApiTokensSection />;
|
|
case 'registries': return <RegistriesSection />;
|
|
case 'labels': return <LabelsSection onLabelsChanged={onLabelsChanged} />;
|
|
case 'system':
|
|
return (
|
|
<SystemSection
|
|
settings={settings}
|
|
onSettingChange={handleSettingChange}
|
|
onSave={saveSystemSettings}
|
|
isSaving={isSavingSystem}
|
|
isLoading={isSettingsLoading}
|
|
/>
|
|
);
|
|
case 'notifications': return <NotificationsSection />;
|
|
case 'notification-routing': return <NotificationRoutingSection />;
|
|
case 'webhooks': return <WebhooksSection isPaid={isPaid} />;
|
|
case 'security': return <SecuritySection isPaid={isPaid} />;
|
|
case 'cloud-backup': return <CloudBackupSection />;
|
|
case 'developer':
|
|
return (
|
|
<DeveloperSection
|
|
settings={settings}
|
|
onSettingChange={handleSettingChange}
|
|
onSave={saveDeveloperSettings}
|
|
isSaving={isSavingDeveloper}
|
|
isLoading={isSettingsLoading}
|
|
/>
|
|
);
|
|
case 'nodes': return <NodeManager />;
|
|
case 'appstore':
|
|
return (
|
|
<AppStoreSection
|
|
settings={settings}
|
|
onSettingChange={handleSettingChange}
|
|
isLoading={isSettingsLoading}
|
|
onSaved={handleRegistrySaved}
|
|
/>
|
|
);
|
|
case 'support': return <SupportSection />;
|
|
case 'about': return <AboutSection />;
|
|
default: return null;
|
|
}
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
|
<DialogContent
|
|
showClose={false}
|
|
onKeyDownCapture={handleDialogKeyDown}
|
|
className="sm:max-w-[960px] h-[min(780px,90vh)] flex p-0 font-sans shadow-lg bg-background border-border overflow-hidden gap-0"
|
|
>
|
|
<VisuallyHidden><DialogTitle>Settings Hub</DialogTitle></VisuallyHidden>
|
|
<VisuallyHidden><DialogDescription>Configure Sencho settings</DialogDescription></VisuallyHidden>
|
|
|
|
<aside className="w-[220px] bg-glass border-r border-glass-border flex flex-col shrink-0 min-h-0">
|
|
<div className="px-4 pt-4 pb-3">
|
|
<div className="font-display italic text-xl leading-none text-stat-value">Settings</div>
|
|
<div className="mt-1 font-mono text-[10px] uppercase tracking-[0.22em] text-stat-subtitle">
|
|
{isRemote && activeNode ? `node · ${activeNode.name}` : 'control plane'}
|
|
</div>
|
|
</div>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={() => setCommandOpen(true)}
|
|
className="mx-3 mb-3 flex items-center gap-2 rounded-md border border-card-border border-t-card-border-top bg-card px-2.5 py-1.5 shadow-card-bevel transition-colors hover:border-t-card-border-hover"
|
|
>
|
|
<Search className="h-3 w-3 text-stat-subtitle" strokeWidth={1.5} />
|
|
<span className="flex-1 text-left font-mono text-[11px] text-stat-subtitle">Filter settings</span>
|
|
<kbd className="font-mono text-[10px] text-stat-subtitle/70 tabular-nums">{`\u2318K`}</kbd>
|
|
</button>
|
|
|
|
<ScrollArea className="flex-1 px-3">
|
|
<nav className="flex flex-col gap-4 pb-4">
|
|
{visibleGroups.map(group => (
|
|
<div key={group.id} className="flex flex-col gap-0.5">
|
|
<div className="flex items-baseline gap-1.5 px-2 pb-1 font-mono text-[9px] uppercase tracking-[0.22em] text-stat-subtitle">
|
|
<span>{group.label}</span>
|
|
{group.kicker ? (
|
|
<span className="text-stat-subtitle/60">· {group.kicker}</span>
|
|
) : null}
|
|
</div>
|
|
{group.items.map(item => {
|
|
const locked = isItemLocked(item, visibility);
|
|
const isActive = activeSection === item.id;
|
|
const showDot = sectionDirtyFlags[item.id];
|
|
return (
|
|
<button
|
|
type="button"
|
|
key={item.id}
|
|
onClick={() => switchSection(item.id)}
|
|
className={cn(
|
|
'relative flex items-center gap-2 rounded-sm px-2 py-[var(--density-cell-y)] text-left transition-colors',
|
|
isActive
|
|
? 'bg-gradient-to-r from-brand/10 to-transparent text-stat-value'
|
|
: 'text-stat-subtitle hover:bg-accent/40 hover:text-stat-value',
|
|
)}
|
|
>
|
|
{isActive ? (
|
|
<span className="absolute inset-y-1 left-0 w-[2px] rounded-full bg-brand" />
|
|
) : null}
|
|
<span
|
|
aria-hidden="true"
|
|
className={cn(
|
|
'font-mono text-[11px] leading-none w-3 text-center',
|
|
isActive ? 'text-brand' : 'text-stat-subtitle/70',
|
|
)}
|
|
>
|
|
{group.glyph}
|
|
</span>
|
|
<span className="flex-1 truncate text-sm font-medium">{item.label}</span>
|
|
{item.tier ? (
|
|
<TierChip tier={item.tier} locked={locked} />
|
|
) : null}
|
|
{showDot ? (
|
|
<span className="h-1.5 w-1.5 rounded-full bg-warning" />
|
|
) : null}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
))}
|
|
</nav>
|
|
</ScrollArea>
|
|
</aside>
|
|
|
|
<div className="flex-1 flex flex-col min-h-0 min-w-0">
|
|
<header className="flex items-start justify-between gap-4 px-6 pt-5 pb-4 border-b border-border/60">
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex items-center gap-1.5 font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">
|
|
<span>Settings</span>
|
|
<span className="text-stat-subtitle/50">{`\u203A`}</span>
|
|
<span>{activeGroup?.label ?? ''}</span>
|
|
<span className="text-stat-subtitle/50">{`\u203A`}</span>
|
|
<span className="text-stat-value">{activeItem?.label ?? ''}</span>
|
|
{activeItem?.scope === 'node' ? (
|
|
<span className="ml-2 flex items-center gap-1 text-brand">
|
|
<span className="text-stat-subtitle/50">·</span>
|
|
<span className="truncate max-w-[160px]">{activeNode?.name ?? 'local'}</span>
|
|
<span className="text-stat-subtitle/70">(node-scoped)</span>
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
<h2 className="mt-1.5 font-display italic text-2xl leading-tight text-stat-value truncate">
|
|
{activeItem?.label ?? 'Settings'}
|
|
</h2>
|
|
{activeItem?.description ? (
|
|
<div className="mt-1 text-sm text-stat-subtitle/90 truncate">
|
|
{activeItem.description}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
<DialogClose className="mt-1 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none">
|
|
<X className="h-4 w-4" strokeWidth={1.5} />
|
|
<span className="sr-only">Close</span>
|
|
</DialogClose>
|
|
</header>
|
|
<ScrollArea block viewportRef={contentViewportRef} className="flex-1 min-w-0">
|
|
<div className="px-6 py-5 flex flex-col gap-6 min-w-0">
|
|
{renderSection()}
|
|
</div>
|
|
</ScrollArea>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
<CommandDialog open={commandOpen} onOpenChange={setCommandOpen}>
|
|
<CommandInput placeholder="Jump to a setting..." />
|
|
<CommandList>
|
|
<CommandEmpty>No matching settings.</CommandEmpty>
|
|
{visibleGroups.map(group => (
|
|
<CommandGroup key={group.id} heading={group.label}>
|
|
{group.items.map(item => (
|
|
<CommandSearchItem
|
|
key={item.id}
|
|
item={item}
|
|
glyph={group.glyph}
|
|
visibility={visibility}
|
|
onSelect={() => {
|
|
setCommandOpen(false);
|
|
switchSection(item.id);
|
|
}}
|
|
/>
|
|
))}
|
|
</CommandGroup>
|
|
))}
|
|
</CommandList>
|
|
</CommandDialog>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function CommandSearchItem({
|
|
item,
|
|
glyph,
|
|
visibility,
|
|
onSelect,
|
|
}: {
|
|
item: SettingsItemMeta;
|
|
glyph: string;
|
|
visibility: VisibilityContext;
|
|
onSelect: () => void;
|
|
}) {
|
|
const locked = isItemLocked(item, visibility);
|
|
const searchValue = [item.label, item.description, ...item.keywords].join(' ').toLowerCase();
|
|
return (
|
|
<CommandItem value={searchValue} onSelect={onSelect}>
|
|
<span className="font-mono text-[11px] w-3 text-center text-stat-subtitle/70">{glyph}</span>
|
|
<div className="flex flex-col gap-0.5 min-w-0 flex-1">
|
|
<span className="text-sm font-medium text-stat-value truncate">{item.label}</span>
|
|
<span className="text-xs text-stat-subtitle truncate">{item.description}</span>
|
|
</div>
|
|
{item.tier ? <TierChip tier={item.tier} locked={locked} /> : null}
|
|
</CommandItem>
|
|
);
|
|
}
|
|
|
|
function TierChip({ tier, locked }: { tier: NonNullable<SettingsItemMeta['tier']>; locked: boolean }) {
|
|
const label = tier === 'admiral' ? 'ADMIRAL' : 'SKIPPER';
|
|
if (locked) {
|
|
return (
|
|
<span className="flex items-center gap-1 rounded-sm border border-card-border bg-card px-1.5 py-0.5 font-mono text-[9px] uppercase tracking-[0.18em] text-stat-subtitle/80">
|
|
<Lock className="h-2.5 w-2.5" strokeWidth={1.5} />
|
|
{label}
|
|
</span>
|
|
);
|
|
}
|
|
return (
|
|
<span className="font-mono text-[9px] uppercase tracking-[0.18em] text-brand/70">
|
|
{label}
|
|
</span>
|
|
);
|
|
}
|