generated from Labyricorn/labyricorn-project-template
Initial commit (forked from jamiepine/voicebox)
This commit is contained in:
@@ -0,0 +1,432 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { AlertCircle, ArrowUpRight, Book, Download, Loader2, RefreshCw } from 'lucide-react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
import * as z from 'zod';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Form, FormControl, FormField, FormItem, FormMessage } from '@/components/ui/form';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Toggle } from '@/components/ui/toggle';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import { useAutoUpdater } from '@/hooks/useAutoUpdater';
|
||||
import { useServerHealth } from '@/lib/hooks/useServer';
|
||||
import { usePlatform } from '@/platform/PlatformContext';
|
||||
import { useServerStore } from '@/stores/serverStore';
|
||||
import { CloudSection } from './CloudSection';
|
||||
import { LanguageSelect } from './LanguageSelect';
|
||||
import { SettingRow, SettingSection } from './SettingRow';
|
||||
import { ThemeSelect } from './ThemeSelect';
|
||||
|
||||
function makeConnectionSchema(invalidUrl: string) {
|
||||
return z.object({
|
||||
serverUrl: z.string().url(invalidUrl),
|
||||
});
|
||||
}
|
||||
|
||||
type ConnectionFormValues = { serverUrl: string };
|
||||
|
||||
export function GeneralPage() {
|
||||
const { t } = useTranslation();
|
||||
const platform = usePlatform();
|
||||
const serverUrl = useServerStore((state) => state.serverUrl);
|
||||
const setServerUrl = useServerStore((state) => state.setServerUrl);
|
||||
const keepServerRunningOnClose = useServerStore((state) => state.keepServerRunningOnClose);
|
||||
const setKeepServerRunningOnClose = useServerStore((state) => state.setKeepServerRunningOnClose);
|
||||
const mode = useServerStore((state) => state.mode);
|
||||
const setMode = useServerStore((state) => state.setMode);
|
||||
const { toast } = useToast();
|
||||
const { data: health, isLoading, error: healthError } = useServerHealth();
|
||||
|
||||
const resolver = useMemo(
|
||||
() => zodResolver(makeConnectionSchema(t('settings.general.serverUrl.invalidUrl'))),
|
||||
[t],
|
||||
);
|
||||
const form = useForm<ConnectionFormValues>({
|
||||
resolver,
|
||||
defaultValues: { serverUrl },
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
form.reset({ serverUrl });
|
||||
}, [serverUrl, form]);
|
||||
|
||||
// Re-run validation when the locale changes so existing error messages retranslate.
|
||||
useEffect(() => {
|
||||
if (form.formState.errors.serverUrl) {
|
||||
form.trigger('serverUrl');
|
||||
}
|
||||
}, [t, form]);
|
||||
|
||||
const { isDirty } = form.formState;
|
||||
|
||||
function onSubmit(data: ConnectionFormValues) {
|
||||
setServerUrl(data.serverUrl);
|
||||
form.reset(data);
|
||||
toast({
|
||||
title: t('settings.general.serverUrl.updatedTitle'),
|
||||
description: t('settings.general.serverUrl.updatedDescription', { url: data.serverUrl }),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8 max-w-2xl">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<a
|
||||
href="https://docs.voicebox.sh"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="group flex items-center gap-3 rounded-lg border border-border/60 p-4 transition-colors hover:bg-muted/50"
|
||||
>
|
||||
<Book className="h-5 w-5 shrink-0 text-accent" strokeWidth={2.5} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium">{t('settings.general.docs.title')}</div>
|
||||
<div className="text-xs text-muted-foreground">docs.voicebox.sh</div>
|
||||
</div>
|
||||
<ArrowUpRight className="h-4 w-4 text-muted-foreground/40 group-hover:text-muted-foreground transition-colors" />
|
||||
</a>
|
||||
<a
|
||||
href="https://discord.gg/StkzQasqPS"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="group flex items-center gap-3 rounded-lg border border-border/60 p-4 transition-colors hover:bg-muted/50"
|
||||
>
|
||||
<svg
|
||||
className="h-5 w-5 shrink-0 text-accent"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z" />
|
||||
</svg>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium">{t('settings.general.discord.title')}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t('settings.general.discord.subtitle')}
|
||||
</div>
|
||||
</div>
|
||||
<ArrowUpRight className="h-4 w-4 text-muted-foreground/40 group-hover:text-muted-foreground transition-colors" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<SettingSection>
|
||||
<SettingRow
|
||||
title={t('settings.general.serverUrl.title')}
|
||||
description={t('settings.general.serverUrl.description')}
|
||||
action={
|
||||
<ConnectionStatus health={health} isLoading={isLoading} healthError={healthError} />
|
||||
}
|
||||
>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="flex gap-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="serverUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormControl>
|
||||
<Input placeholder="http://127.0.0.1:17493" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{isDirty && (
|
||||
<Button type="submit" size="sm">
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
)}
|
||||
</form>
|
||||
</Form>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
title={t('settings.general.keepServerRunning.title')}
|
||||
description={t('settings.general.keepServerRunning.description')}
|
||||
htmlFor="keepServerRunning"
|
||||
action={
|
||||
<Toggle
|
||||
id="keepServerRunning"
|
||||
checked={keepServerRunningOnClose}
|
||||
onCheckedChange={(checked: boolean) => {
|
||||
setKeepServerRunningOnClose(checked);
|
||||
platform.lifecycle.setKeepServerRunning(checked).catch((error) => {
|
||||
console.error('Failed to sync setting to Rust:', error);
|
||||
setKeepServerRunningOnClose(!checked);
|
||||
toast({
|
||||
title: t('settings.general.keepServerRunning.failedTitle'),
|
||||
description: t('settings.general.keepServerRunning.failedDescription'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
});
|
||||
toast({
|
||||
title: t('settings.general.keepServerRunning.updatedTitle'),
|
||||
description: checked
|
||||
? t('settings.general.keepServerRunning.runningDescription')
|
||||
: t('settings.general.keepServerRunning.stoppedDescription'),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
{platform.metadata.isTauri && (
|
||||
<SettingRow
|
||||
title={t('settings.general.networkAccess.title')}
|
||||
description={t('settings.general.networkAccess.description')}
|
||||
htmlFor="allowNetworkAccess"
|
||||
action={
|
||||
<Toggle
|
||||
id="allowNetworkAccess"
|
||||
checked={mode === 'remote'}
|
||||
onCheckedChange={(checked: boolean) => {
|
||||
setMode(checked ? 'remote' : 'local');
|
||||
toast({
|
||||
title: t('settings.general.networkAccess.updatedTitle'),
|
||||
description: checked
|
||||
? t('settings.general.networkAccess.enabled')
|
||||
: t('settings.general.networkAccess.disabled'),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<SettingRow
|
||||
title={t('settings.language.label')}
|
||||
description={t('settings.language.description')}
|
||||
action={<LanguageSelect />}
|
||||
/>
|
||||
|
||||
<SettingRow
|
||||
title={t('settings.theme.label')}
|
||||
description={t('settings.theme.description')}
|
||||
action={<ThemeSelect />}
|
||||
/>
|
||||
</SettingSection>
|
||||
|
||||
<CloudSection />
|
||||
|
||||
<ApiReferenceCard serverUrl={serverUrl} />
|
||||
|
||||
{platform.metadata.isTauri && <UpdatesSection />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ConnectionStatus({
|
||||
health,
|
||||
isLoading,
|
||||
healthError,
|
||||
}: {
|
||||
health: ReturnType<typeof useServerHealth>['data'];
|
||||
isLoading: boolean;
|
||||
healthError: ReturnType<typeof useServerHealth>['error'];
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-full border border-border/60 px-3 py-1">
|
||||
<Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t('settings.general.connection.connecting')}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (healthError) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-full border border-destructive/30 px-3 py-1">
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="absolute inline-flex h-full w-full rounded-full bg-destructive/40" />
|
||||
<span className="relative inline-flex h-2 w-2 rounded-full bg-destructive" />
|
||||
</span>
|
||||
<span className="text-xs text-destructive">{t('settings.general.connection.offline')}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (health) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-full border border-accent/30 px-3 py-1">
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-accent/60" />
|
||||
<span className="relative inline-flex h-2 w-2 rounded-full bg-accent shadow-[0_0_6px_1px_hsl(var(--accent)/0.5)]" />
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t('settings.general.connection.online')}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function UpdatesSection() {
|
||||
const { t } = useTranslation();
|
||||
const platform = usePlatform();
|
||||
const { status, checkForUpdates, downloadAndInstall, restartAndInstall } = useAutoUpdater(false);
|
||||
const [currentVersion, setCurrentVersion] = useState<string | null>('');
|
||||
const isDev = !import.meta.env?.PROD;
|
||||
|
||||
useEffect(() => {
|
||||
platform.metadata
|
||||
.getVersion()
|
||||
.then(setCurrentVersion)
|
||||
.catch(() => setCurrentVersion(null));
|
||||
}, [platform]);
|
||||
|
||||
const versionLabel = currentVersion ?? t('common.unknown');
|
||||
|
||||
return (
|
||||
<SettingSection
|
||||
title={t('settings.general.updates.title')}
|
||||
description={`v${versionLabel}${isDev ? t('settings.general.updates.devSuffix') : ''}`}
|
||||
>
|
||||
{isDev ? (
|
||||
<SettingRow
|
||||
title={t('settings.general.updates.devMode.title')}
|
||||
description={t('settings.general.updates.devMode.description')}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<SettingRow
|
||||
title={t('settings.general.updates.check.title')}
|
||||
description={
|
||||
status.available
|
||||
? t('settings.general.updates.check.available', { version: status.version })
|
||||
: status.checking
|
||||
? t('settings.general.updates.check.checking')
|
||||
: t('settings.general.updates.check.upToDate')
|
||||
}
|
||||
action={
|
||||
<Button
|
||||
onClick={checkForUpdates}
|
||||
disabled={status.checking || status.downloading || status.readyToInstall}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
<RefreshCw
|
||||
className={`h-3.5 w-3.5 mr-1.5 ${status.checking ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
{t('settings.general.updates.check.button')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{status.error && (
|
||||
<SettingRow title={t('settings.general.updates.error')}>
|
||||
<div className="flex items-center gap-2 text-sm text-destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
{status.error}
|
||||
</div>
|
||||
</SettingRow>
|
||||
)}
|
||||
|
||||
{status.available && !status.downloading && !status.readyToInstall && (
|
||||
<SettingRow
|
||||
title={t('settings.general.updates.download.title', { version: status.version })}
|
||||
description={t('settings.general.updates.download.description')}
|
||||
action={
|
||||
<Button onClick={downloadAndInstall} size="sm">
|
||||
<Download className="h-3.5 w-3.5 mr-1.5" />
|
||||
{t('settings.general.updates.download.button')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{status.downloading && (
|
||||
<SettingRow title={t('settings.general.updates.downloading')}>
|
||||
<div className="space-y-1.5">
|
||||
<Progress value={status.downloadProgress} />
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
{status.downloadedBytes !== undefined &&
|
||||
status.totalBytes !== undefined &&
|
||||
status.totalBytes > 0 ? (
|
||||
<span>
|
||||
{(status.downloadedBytes / 1024 / 1024).toFixed(1)} MB /{' '}
|
||||
{(status.totalBytes / 1024 / 1024).toFixed(1)} MB
|
||||
</span>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
{status.downloadProgress !== undefined && <span>{status.downloadProgress}%</span>}
|
||||
</div>
|
||||
</div>
|
||||
</SettingRow>
|
||||
)}
|
||||
|
||||
{status.readyToInstall && (
|
||||
<SettingRow
|
||||
title={t('settings.general.updates.ready.title')}
|
||||
description={t('settings.general.updates.ready.description', {
|
||||
version: status.version,
|
||||
})}
|
||||
action={
|
||||
<Button onClick={restartAndInstall} size="sm">
|
||||
<RefreshCw className="h-3.5 w-3.5 mr-1.5" />
|
||||
{t('settings.general.updates.ready.button')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</SettingSection>
|
||||
);
|
||||
}
|
||||
|
||||
function ApiReferenceCard({ serverUrl }: { serverUrl: string }) {
|
||||
const { t } = useTranslation();
|
||||
const endpoints = [
|
||||
{ method: 'POST', path: '/generate', label: t('settings.general.api.endpoints.generate') },
|
||||
{ method: 'GET', path: '/health', label: t('settings.general.api.endpoints.health') },
|
||||
{ method: 'GET', path: '/profiles', label: t('settings.general.api.endpoints.profiles') },
|
||||
{ method: 'GET', path: '/history', label: t('settings.general.api.endpoints.history') },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border/60 p-4 space-y-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium">{t('settings.general.api.title')}</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<Trans
|
||||
i18nKey="settings.general.api.description"
|
||||
values={{ url: serverUrl }}
|
||||
components={{
|
||||
code: <code className="text-xs bg-muted px-1 py-0.5 rounded font-mono" />,
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{endpoints.map((ep) => (
|
||||
<div key={ep.path} className="flex items-center gap-2.5 py-1">
|
||||
<span
|
||||
className={`text-[10px] font-mono font-semibold w-9 text-center rounded px-1 py-px ${
|
||||
ep.method === 'POST' ? 'bg-accent/10 text-accent' : 'bg-muted text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{ep.method}
|
||||
</span>
|
||||
<code className="text-xs font-mono text-muted-foreground">{ep.path}</code>
|
||||
<span className="text-xs text-muted-foreground/50 ml-auto">{ep.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<a
|
||||
href={`${serverUrl}/docs`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-accent hover:underline"
|
||||
>
|
||||
{t('settings.general.api.viewReference')}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user