profile settings keyed (425 -> 352)

ProfileSettings reaches zero: api keys, profile, voice, dock, languages and the
model defaults.

Language names now come from Intl.DisplayNames rather than the catalog. The runtime
already knows "German" in English and "alemao" in Portuguese, so 28 names would have
been 56 hand-written entries that Intl gets right for free -- including for locales
not shipped yet. Same reasoning D7 gives for dates and numbers. The tag LIST stays
hardcoded: which languages you may claim to speak is a product choice.

ApiKeys had toLocaleDateString(undefined, ...), which followed the BROWSER locale --
the exact contradiction D7 describes. It uses useFormat().date now. Its revoked-key
toggle hand-rolled a plural with a ternary on n === 1; that is a real plural entry now.

Scanner: an Authorization header value like "Bearer <token>" is not a sentence.
Excluded by auth-scheme prefix; verified the delta is exactly the four such
credentials across both remaining areas.
This commit is contained in:
2026-08-23 04:57:48 +00:00
parent 7ce5af88aa
commit ad8ed88519
10 changed files with 301 additions and 156 deletions
+4
View File
@@ -81,6 +81,10 @@ function isHuman(value: string | undefined): value is string {
if (/^[a-z0-9\-_./:]+$/.test(s)) return false;
if (/^(https?:|\/|#|data:|application\/|image\/)/.test(s)) return false;
if (/^[A-Z0-9_]+$/.test(s)) return false;
// `Bearer ${token}` is an Authorization header value, not a sentence. Caught by the template-string
// pattern because it starts with a capital and interpolates; excluded here because an auth scheme
// followed by a credential is never something a person reads.
if (/^(Bearer|Basic|Token|Digest)\s/.test(s)) return false;
// Tailwind, reached via a `description:`/`label:` key in a classNames object — e.g. sonner's
// `description: 'group-[.toast]:text-muted-foreground'`.
//
@@ -13,6 +13,7 @@ import {
} from '@/components/ui/select';
import { useSettings } from 'state/useSettings';
import { useUserVisibleModels, getProviderDisplayName, type ModelOption } from 'state/useModels';
import { useTranslation } from '@/lib/i18n';
function buildGroups(models: ModelOption[]) {
const groups: Record<string, { id: string; name: string }[]> = {};
@@ -29,6 +30,7 @@ function buildGroups(models: ModelOption[]) {
const NONE = '__none__';
export const AIModels = () => {
const { t } = useTranslation();
const { settings, saveSettings } = useSettings();
const piModels = useUserVisibleModels();
const [isSaving, setIsSaving] = useState(false);
@@ -54,9 +56,9 @@ export const AIModels = () => {
chat: { ...settings.chat, defaultModel: chatModel, defaultProjectModel: projectModel },
tasks: { ...settings.tasks, defaultModel: taskModel },
});
toast.success('AI model defaults saved');
toast.success(t('settings.profile.models.aiDefaultsSaved'));
} catch {
toast.error('Failed to save settings');
toast.error(t('settings.profile.models.saveFailed'));
} finally {
setIsSaving(false);
}
@@ -86,25 +88,25 @@ export const AIModels = () => {
return (
<div className="grid gap-5">
<Label className="grid gap-2">
<span className="text-duck-dark/70 dark:text-foreground/70">Default Chat Model</span>
<span className="text-duck-dark/70 dark:text-foreground/70">{t('settings.profile.models.defaultChat')}</span>
<p className="text-xs text-duck-dark/40 dark:text-foreground/40">
Used when starting a new chat from the home screen
{t('settings.profile.models.defaultChatHint')}
</p>
{renderModelSelect(chatModel, setChatModel, 'System default')}
</Label>
<Label className="grid gap-2">
<span className="text-duck-dark/70 dark:text-foreground/70">Default Project Model</span>
<span className="text-duck-dark/70 dark:text-foreground/70">{t('settings.profile.models.defaultProject')}</span>
<p className="text-xs text-duck-dark/40 dark:text-foreground/40">
Used when starting a new chat inside a project dashboard
{t('settings.profile.models.defaultProjectHint')}
</p>
{renderModelSelect(projectModel, setProjectModel, 'Same as chat default')}
</Label>
<Label className="grid gap-2">
<span className="text-duck-dark/70 dark:text-foreground/70">Default Task Model</span>
<span className="text-duck-dark/70 dark:text-foreground/70">{t('settings.profile.models.defaultTask')}</span>
<p className="text-xs text-duck-dark/40 dark:text-foreground/40">
Used when running tasks from the file browser
{t('settings.profile.models.defaultTaskHint')}
</p>
{renderModelSelect(taskModel, setTaskModel, 'Same as chat default')}
</Label>
@@ -2,6 +2,8 @@ import type { FormEvent } from 'react';
import { useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { useFormat } from 'i18n';
import { useTranslation } from '@/lib/i18n';
import { Copy, Ban, Plus, KeyRound, Loader2, Trash2 } from 'lucide-react';
import { useClient } from 'hooks/useClient';
import { Input } from '@/components/ui/input';
@@ -36,19 +38,22 @@ type Minted = { key: string; name: string };
const API_KEYS_QUERY_KEY = ['API_KEYS'];
const formatDate = (value: string | null) =>
value ? new Date(value).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) : null;
// `toLocaleDateString(undefined, …)` followed the BROWSER's locale, so a Portuguese UI showed the
// browser's dates. `useFormat` binds it to the locale the rest of the app is in (D7).
export const ApiKeys = () => {
const { t } = useTranslation();
const { date: formatDate } = useFormat();
const copy = async (text: string) => {
try {
await copyToClipboard(text);
toast.success('Key copied');
toast.success(t('settings.profile.apiKeys.copied'));
} catch {
toast.error('Could not copy — select and copy manually');
toast.error(t('settings.profile.apiKeys.copyFailed'));
}
};
export const ApiKeys = () => {
const client = useClient();
const queryClient = useQueryClient();
const [name, setName] = useState('');
@@ -74,7 +79,7 @@ export const ApiKeys = () => {
setName('');
await refresh();
} catch (ex) {
toast.error(ex instanceof Error ? ex.message : 'Could not create the key');
toast.error(ex instanceof Error ? ex.message : t('settings.profile.apiKeys.createFailed'));
} finally {
setIsCreating(false);
}
@@ -85,17 +90,17 @@ export const ApiKeys = () => {
await client.delete(`/api-keys/${id}/record`);
await refresh();
} catch {
toast.error('Could not remove it');
toast.error(t('settings.profile.apiKeys.forgetFailed'));
}
};
const revoke = async (id: number) => {
try {
await client.delete(`/api-keys/${id}`);
toast.success('Revoked — anything using that key is signed out');
toast.success(t('settings.profile.apiKeys.revokeDone'));
await refresh();
} catch {
toast.error('Could not revoke');
toast.error(t('settings.profile.apiKeys.revokeFailed'));
}
};
@@ -116,7 +121,7 @@ export const ApiKeys = () => {
if (isLoading) {
return (
<div className="flex items-center gap-2 p-6 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" /> Loading keys…
<Loader2 className="h-4 w-4 animate-spin" /> {t('settings.profile.apiKeys.loading')}
</div>
);
}
@@ -124,15 +129,13 @@ export const ApiKeys = () => {
return (
<div className="grid gap-5">
<p className="text-sm text-duck-dark/70 dark:text-foreground/70">
An API key signs in as you, without a password and without expiring. One key works everywhere — a key is not
tied to an app. Giving each device its own is only so you can cut one off without touching the rest. Send it as{' '}
<code className="font-mono text-xs">Authorization: Bearer …</code>.
{t('settings.profile.apiKeys.intro')} <code className="font-mono text-xs">Authorization: Bearer …</code>.
</p>
{minted && (
<div className="grid gap-3 rounded-lg border-2 border-duck-teal/40 bg-duck-teal/5 p-4">
<div className="text-sm font-medium text-duck-dark dark:text-foreground">
Key for “{minted.name}” — shown once
{t('settings.profile.apiKeys.mintedTitle', { name: minted.name })}
</div>
<p className="text-xs text-duck-dark/60 dark:text-foreground/60">
This is the only time it is displayed. It's stored hashed, so it can't be shown again — if you lose it,
@@ -143,30 +146,35 @@ export const ApiKeys = () => {
<code className="min-w-0 flex-1 truncate rounded bg-background/70 px-2 py-1.5 font-mono text-xs">
{minted.key}
</code>
<Button variant="ghost" size="sm" onClick={() => copy(minted.key)} title="Copy key">
<Button
variant="ghost"
size="sm"
onClick={() => copy(minted.key)}
title={t('settings.profile.apiKeys.copyKey')}
>
<Copy className="h-3.5 w-3.5" />
</Button>
</div>
<Button variant="outline" size="sm" className="justify-self-start" onClick={() => setMinted(null)}>
I've saved it
{t('settings.profile.apiKeys.savedIt')}
</Button>
</div>
)}
<form onSubmit={create} className="flex items-end gap-2">
<Label className="grid flex-1 gap-2">
<span className="text-duck-dark/70 dark:text-foreground/70">New key</span>
<span className="text-duck-dark/70 dark:text-foreground/70">{t('settings.profile.apiKeys.newKey')}</span>
<Input
className="h-11 bg-background/60 border-duck-dark/20"
value={name}
onChange={(ev) => setName(ev.target.value)}
placeholder="iPhone, Music app, laptop CLI…"
placeholder={t('settings.profile.apiKeys.namePlaceholder')}
/>
</Label>
<Button type="submit" disabled={!name.trim() || isCreating} className="h-11">
<Plus className="mr-1.5 h-4 w-4" />
Create
<Plus className="me-1.5 h-4 w-4" />
{t('settings.profile.apiKeys.create')}
</Button>
</form>
@@ -174,14 +182,16 @@ export const ApiKeys = () => {
<div className="rounded-lg border border-dashed border-duck-dark/15 dark:border-foreground/15 p-6 text-center text-sm text-duck-dark/40 dark:text-foreground/40">
{/* "No keys yet" would be false on an account whose keys are all revoked, and it is the sentence
that would send somebody looking for a bug in the list. */}
{allKeys.length === 0 ? 'No keys yet.' : 'No keys in use.'}
{allKeys.length === 0 ? t('settings.profile.apiKeys.noneYet') : t('settings.profile.apiKeys.noneInUse')}
</div>
) : (
<div className="grid gap-2">
{keys.map((entry) => {
const expired = !!entry.expiresAt && new Date(entry.expiresAt).getTime() <= Date.now();
const dead = !!entry.revokedAt || expired;
const lastUsed = formatDate(entry.lastUsedAt);
const lastUsed = entry.lastUsedAt
? formatDate(entry.lastUsedAt, { month: 'short', day: 'numeric', year: 'numeric' })
: null;
return (
<div
key={entry.id}
@@ -195,14 +205,20 @@ export const ApiKeys = () => {
<div className="min-w-0 flex-1">
<div className="truncate text-sm text-duck-dark dark:text-foreground">
{entry.name}
{entry.revokedAt && <span className="ml-2 text-xs text-red-500">revoked</span>}
{!entry.revokedAt && expired && <span className="ml-2 text-xs text-red-500">expired</span>}
{entry.revokedAt && (
<span className="ms-2 text-xs text-red-500">{t('settings.profile.apiKeys.revoked')}</span>
)}
{!entry.revokedAt && expired && (
<span className="ms-2 text-xs text-red-500">{t('settings.profile.apiKeys.expired')}</span>
)}
</div>
<div className="text-xs text-duck-dark/40 dark:text-foreground/40">
<code className="font-mono">{entry.prefix}…</code>
{' · '}
{/* "never used" is the tell that an app was configured wrong, so it earns its own wording. */}
{lastUsed ? `last used ${lastUsed}` : 'never used'}
{lastUsed
? t('settings.profile.apiKeys.lastUsed', { date: lastUsed })
: t('settings.profile.apiKeys.neverUsed')}
</div>
</div>
{dead ? (
@@ -212,7 +228,7 @@ export const ApiKeys = () => {
variant="ghost"
size="sm"
onClick={() => void forget(entry.id)}
title="Remove this record"
title={t('settings.profile.apiKeys.forget')}
className="hover:text-red-500"
>
<Trash2 className="h-3.5 w-3.5" />
@@ -222,7 +238,7 @@ export const ApiKeys = () => {
variant="ghost"
size="sm"
onClick={() => void revoke(entry.id)}
title="Revoke"
title={t('settings.profile.apiKeys.revoke')}
className="hover:text-red-500"
>
<Ban className="h-3.5 w-3.5" />
@@ -241,8 +257,8 @@ export const ApiKeys = () => {
className="mt-3 cursor-pointer text-xs text-duck-dark/40 underline underline-offset-2 transition-colors hover:text-duck-dark/70 dark:text-foreground/40 dark:hover:text-foreground/70"
>
{showRevoked
? 'Hide revoked and expired'
: `Show ${deadKeys.length} revoked or expired key${deadKeys.length === 1 ? '' : 's'}`}
? t('settings.profile.apiKeys.hideDead')
: t('settings.profile.apiKeys.showDead', { count: deadKeys.length })}
</button>
)}
</div>
@@ -4,6 +4,7 @@ import { Button } from '@/components/ui/button';
import { useDock } from 'officerdev';
import { usePermissions } from 'hooks/usePermissions';
import { useDockItems, defaultDockPaths } from '@/Screens/Dashboard/Layout/Dock';
import { useTranslation } from '@/lib/i18n';
type DockPillProps = {
label: string;
@@ -104,6 +105,7 @@ const DropZone = ({ label, children, onDrop }: DropZoneProps) => {
};
export const DockSettings = () => {
const { t } = useTranslation();
// Same composition as the dock itself. Offering a pin for an uninstalled feature would let someone
// pin a tile that cannot appear, which reads as the setting being broken.
const { plugins } = usePermissions();
@@ -193,10 +195,10 @@ export const DockSettings = () => {
return (
<div className="grid gap-4">
<DropZone label="Visible" onDrop={onDropVisible}>
<DropZone label={t('settings.profile.dock.visible')} onDrop={onDropVisible}>
{items.length === 0 && (
<span className="text-xs text-duck-dark/30 dark:text-foreground/30 py-1">
Drag items here to show in dock
{t('settings.profile.dock.dragToShow')}
</span>
)}
{items.map((item) => (
@@ -216,9 +218,11 @@ export const DockSettings = () => {
))}
</DropZone>
<DropZone label="Hidden" onDrop={onDropHidden}>
<DropZone label={t('settings.profile.dock.hidden')} onDrop={onDropHidden}>
{hiddenItems.length === 0 && (
<span className="text-xs text-duck-dark/30 dark:text-foreground/30 py-1">All items visible</span>
<span className="text-xs text-duck-dark/30 dark:text-foreground/30 py-1">
{t('settings.profile.dock.allVisible')}
</span>
)}
{hiddenItems.map((item) => (
<DockPill
@@ -235,7 +239,7 @@ export const DockSettings = () => {
<Button type="button" variant="outline" onClick={reset} className="w-full h-9 text-sm cursor-pointer">
<RotateCcw className="h-3.5 w-3.5 mr-1.5" />
Reset to defaults
{t('settings.profile.dock.reset')}
</Button>
</div>
);
@@ -1,46 +1,67 @@
import { useState } from 'react';
import { useMemo, useState } from 'react';
import { X } from 'lucide-react';
import { Label } from '@/components/ui/label';
import { useSettings } from 'state/useSettings';
import { useTranslation } from '@/lib/i18n';
import { SHIPPED_LOCALES } from '@/lib/locales';
const LANGUAGES = [
{ code: 'en', label: 'English' },
{ code: 'pt', label: 'Portuguese' },
{ code: 'es', label: 'Spanish' },
{ code: 'fr', label: 'French' },
{ code: 'de', label: 'German' },
{ code: 'it', label: 'Italian' },
{ code: 'nl', label: 'Dutch' },
{ code: 'ru', label: 'Russian' },
{ code: 'zh', label: 'Chinese' },
{ code: 'ja', label: 'Japanese' },
{ code: 'ko', label: 'Korean' },
{ code: 'ar', label: 'Arabic' },
{ code: 'hi', label: 'Hindi' },
{ code: 'tr', label: 'Turkish' },
{ code: 'pl', label: 'Polish' },
{ code: 'sv', label: 'Swedish' },
{ code: 'da', label: 'Danish' },
{ code: 'no', label: 'Norwegian' },
{ code: 'fi', label: 'Finnish' },
{ code: 'uk', label: 'Ukrainian' },
{ code: 'cs', label: 'Czech' },
{ code: 'ro', label: 'Romanian' },
{ code: 'el', label: 'Greek' },
{ code: 'he', label: 'Hebrew' },
{ code: 'th', label: 'Thai' },
{ code: 'vi', label: 'Vietnamese' },
{ code: 'id', label: 'Indonesian' },
{ code: 'ms', label: 'Malay' },
];
/**
* Language names come from `Intl.DisplayNames`, not from the catalog.
*
* The runtime already knows every language's name in every locale — "German" in English, "alemão" in
* Portuguese — so 28 names would have been 56 hand-written catalog entries that `Intl` gets right for
* free, including for locales we have not shipped yet. Same reasoning as D7 for dates and numbers.
*
* The tags themselves stay a hardcoded list: this is "which languages may I claim to speak", a product
* choice, not something to enumerate from the runtime.
*/
const SPOKEN_LANGUAGE_TAGS = [
'en',
'pt',
'es',
'fr',
'de',
'it',
'nl',
'ru',
'zh',
'ja',
'ko',
'ar',
'hi',
'tr',
'pl',
'sv',
'da',
'no',
'fi',
'uk',
'cs',
'ro',
'el',
'he',
'th',
'vi',
'id',
'ms',
] as const;
const getLabel = (code: string) => LANGUAGES.find((l) => l.code === code)?.label ?? code;
/** `Intl` yields "alemão" in pt; a picker row reads better capitalized. */
const useLanguageName = () => {
const { locale } = useTranslation();
return useMemo(() => {
const names = new Intl.DisplayNames([locale], { type: 'language', fallback: 'code' });
return (code: string) => {
const name = names.of(code) ?? code;
return name.charAt(0).toUpperCase() + name.slice(1);
};
}, [locale]);
};
export const Languages = () => {
const { settings, saveSettings } = useSettings();
const { t } = useTranslation();
const languageName = useLanguageName();
const { spoken, default: defaultLang, translateTo } = settings.languages;
const [addingLang, setAddingLang] = useState('');
@@ -63,7 +84,7 @@ export const Languages = () => {
save(updates);
};
const availableToAdd = LANGUAGES.filter((l) => !spoken.includes(l.code));
const availableToAdd = SPOKEN_LANGUAGE_TAGS.filter((code) => !spoken.includes(code));
return (
<div className="grid gap-5">
@@ -76,7 +97,7 @@ export const Languages = () => {
key={code}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-duck-teal/10 text-duck-teal text-sm font-medium"
>
{getLabel(code)}
{languageName(code)}
{spoken.length > 1 && (
<button
onClick={() => removeSpoken(code)}
@@ -96,9 +117,9 @@ export const Languages = () => {
className="text-sm border border-duck-dark/20 rounded-md px-3 py-1.5 bg-background/60 text-duck-dark cursor-pointer"
>
<option value="">{t('settings.profile.languages.addLanguage')}</option>
{availableToAdd.map((l) => (
<option key={l.code} value={l.code}>
{l.label}
{availableToAdd.map((code) => (
<option key={code} value={code}>
{languageName(code)}
</option>
))}
</select>
@@ -131,9 +152,9 @@ export const Languages = () => {
onChange={(ev) => save({ ...settings.languages, translateTo: ev.target.value })}
className="text-sm border border-duck-dark/20 rounded-md px-3 py-2 bg-background/60 text-duck-dark cursor-pointer"
>
{LANGUAGES.map((l) => (
<option key={l.code} value={l.code}>
{l.label}
{SPOKEN_LANGUAGE_TAGS.map((code) => (
<option key={code} value={code}>
{languageName(code)}
</option>
))}
</select>
@@ -13,8 +13,10 @@ import {
} from '@/components/ui/select';
import { useSettings } from 'state/useSettings';
import { useUserVisibleModels, type ModelOption } from 'state/useModels';
import { useTranslation } from '@/lib/i18n';
export const TaskDefaults = () => {
const { t } = useTranslation();
const { settings, saveSettings } = useSettings();
const models = useUserVisibleModels();
const [isSaving, setIsSaving] = useState(false);
@@ -44,9 +46,9 @@ export const TaskDefaults = () => {
setIsSaving(true);
try {
await saveSettings({ ...settings, tasks: { defaultProvider: 'chat', defaultModel: model } });
toast.success('Task defaults saved');
toast.success(t('settings.profile.models.taskDefaultsSaved'));
} catch {
toast.error('Failed to save settings');
toast.error(t('settings.profile.models.saveFailed'));
} finally {
setIsSaving(false);
}
@@ -55,10 +57,10 @@ export const TaskDefaults = () => {
return (
<div className="grid gap-4">
<Label className="grid gap-2">
<span className="text-duck-dark/70">Default Model</span>
<span className="text-duck-dark/70">{t('settings.profile.models.defaultModel')}</span>
<Select value={model ?? ''} onValueChange={(v) => setModel(v || null)}>
<SelectTrigger className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark">
<SelectValue placeholder="Same as chat default" />
<SelectValue placeholder={t('settings.profile.models.sameAsChat')} />
</SelectTrigger>
<SelectContent className="z-[600] max-h-[300px]">
{modelGroups.map(({ provider, models }) => (
@@ -7,6 +7,7 @@ import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button';
import { useForm } from 'hooks/useForm';
import { useAuth } from 'hooks/useAuth';
import { useTranslation } from '@/lib/i18n';
type ProfileFormState = {
name?: string;
@@ -24,6 +25,7 @@ const readFileAsBase64 = (file: File): Promise<string> => {
};
export const UserData = () => {
const { t } = useTranslation();
const { user, updateUser } = useAuth();
const [isUpdating, setIsUpdating] = useState(false);
const [avatarPreview, setAvatarPreview] = useState<string | null>(null);
@@ -35,12 +37,12 @@ export const UserData = () => {
if (!file) return;
if (!file.type.startsWith('image/')) {
toast.error('Please select an image file');
toast.error(t('settings.profile.userData.selectImage'));
return;
}
if (file.size > MAX_AVATAR_SIZE) {
toast.error('Image must be smaller than 384KB');
toast.error(t('settings.profile.userData.imageTooLarge'));
return;
}
@@ -58,11 +60,11 @@ export const UserData = () => {
name: profileForm.state.name ?? '',
avatar: avatarPreview ?? user?.avatar ?? '',
});
toast.success('Profile updated');
toast.success(t('settings.profile.userData.updated'));
setAvatarPreview(null);
} catch (ex) {
const error = ex as { message?: string };
toast.error(error.message || 'Failed to update profile');
toast.error(error.message || t('settings.profile.userData.updateFailed'));
} finally {
setIsUpdating(false);
}
@@ -93,7 +95,7 @@ export const UserData = () => {
<form ref={profileForm.formRef} onSubmit={handleSubmit} className="grid gap-4">
<Label className="grid gap-2">
<span className="text-duck-dark/70">Email</span>
<span className="text-duck-dark/70">{t('settings.profile.userData.email')}</span>
<Input
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
type="email"
@@ -103,7 +105,7 @@ export const UserData = () => {
</Label>
<Label className="grid gap-2">
<span className="text-duck-dark/70">Username</span>
<span className="text-duck-dark/70">{t('settings.profile.userData.username')}</span>
<Input
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
type="text"
@@ -113,12 +115,12 @@ export const UserData = () => {
</Label>
<Label className="grid gap-2">
<span className="text-duck-dark/70">Name</span>
<span className="text-duck-dark/70">{t('settings.profile.userData.name')}</span>
<Input
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
type="text"
name="name"
placeholder="Your name"
placeholder={t('settings.profile.userData.namePlaceholder')}
autoComplete="name"
/>
</Label>
@@ -128,7 +130,7 @@ export const UserData = () => {
disabled={isUpdating}
className="w-full h-11 bg-duck-yellow hover:bg-duck-yellow/90 text-duck-teal font-bold transition-all duration-200 hover:scale-105 cursor-pointer disabled:opacity-50 disabled:hover:scale-100"
>
{isUpdating ? 'Saving...' : 'Save'}
{isUpdating ? t('common.saving') : t('common.save')}
</Button>
</form>
</div>
@@ -12,6 +12,7 @@ import {
} from '@/components/ui/select';
import { useClient } from 'hooks/useClient';
import { useSettings } from 'state/useSettings';
import { useTranslation } from '@/lib/i18n';
type VoiceGroup = { label: string; voices: string[] };
@@ -26,6 +27,7 @@ type TtsConfig = {
const SERVER_DEFAULT = '__server_default__';
export const VoicePreference = () => {
const { t } = useTranslation();
const client = useClient();
const { settings, saveSettings } = useSettings();
const [voices, setVoices] = useState<string[]>([]);
@@ -128,13 +130,13 @@ export const VoicePreference = () => {
<div className="grid gap-4">
<Label className="grid gap-2">
<div className="flex items-center gap-1.5">
<span className="text-duck-dark/70 dark:text-foreground/70">Voice</span>
<span className="text-duck-dark/70 dark:text-foreground/70">{t('settings.profile.voice.label')}</span>
<button
type="button"
onClick={handleRefresh}
disabled={loading}
className="p-0.5 rounded hover:bg-duck-dark/10 dark:hover:bg-foreground/10 cursor-pointer transition-colors disabled:opacity-40"
title="Refresh voices"
title={t('settings.profile.voice.refresh')}
>
<RefreshCw
className={`h-3 w-3 text-duck-dark/50 dark:text-foreground/50 ${loading ? 'animate-spin' : ''}`}
@@ -144,7 +146,7 @@ export const VoicePreference = () => {
<div className="flex items-center gap-2">
<Select value={selectedValue} onValueChange={handleChange}>
<SelectTrigger className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark dark:text-foreground">
<SelectValue placeholder="Server default" />
<SelectValue placeholder={t('settings.profile.voice.serverDefault')} />
</SelectTrigger>
<SelectContent className="z-[600] max-h-[300px]">
<SelectItem value={SERVER_DEFAULT}>
@@ -175,7 +177,7 @@ export const VoicePreference = () => {
onClick={handleListen}
disabled={listening === 'loading' || !serverConfig}
className="shrink-0 h-11 w-11 flex items-center justify-center rounded-md border border-duck-dark/20 bg-background/60 hover:bg-duck-dark/10 dark:hover:bg-foreground/10 cursor-pointer transition-colors disabled:opacity-40 disabled:cursor-default"
title={listening === 'playing' ? 'Stop' : 'Listen'}
title={listening === 'playing' ? t('settings.profile.voice.stop') : t('settings.profile.voice.listen')}
>
{listening === 'loading' ? (
<Loader2 className="h-4 w-4 text-duck-dark/70 dark:text-foreground/70 animate-spin" />
@@ -186,9 +188,7 @@ export const VoicePreference = () => {
)}
</button>
</div>
<span className="text-xs text-duck-dark/40 dark:text-foreground/40">
Choose a voice for text-to-speech. Leave as server default to use the admin-configured voice.
</span>
<span className="text-xs text-duck-dark/40 dark:text-foreground/40">{t('settings.profile.voice.hint')}</span>
</Label>
</div>
);
+70 -23
View File
@@ -6,26 +6,68 @@
},
"settings": {
"profile": {
"searchPlaceholder": "Search settings...",
"sections": {
"profileTitle": "Profile",
"profileDescription": "Update your name and avatar.",
"changePasswordTitle": "Change Password",
"changePasswordDescription": "Update your account password.",
"tasksTitle": "Tasks",
"tasksDescription": "Default model for file browser tasks.",
"languagesTitle": "Languages",
"languagesDescription": "Set your spoken languages and translation preferences."
},
"userData": {
"emailLabel": "Email",
"nameLabel": "Name",
"email": "Email",
"username": "Username",
"name": "Name",
"namePlaceholder": "Your name",
"profileUpdated": "Profile updated",
"profileUpdateFailed": "Failed to update profile",
"selectImageFile": "Please select an image file",
"updated": "Profile updated",
"updateFailed": "Failed to update profile",
"selectImage": "Please select an image file",
"imageTooLarge": "Image must be smaller than 384KB"
},
"apiKeys": {
"intro": "An API key signs in as you, without a password and without expiring. One key works everywhere — a key is not tied to an app. Giving each device its own is only so you can cut one off without touching the rest. Send it as",
"loading": "Loading keys…",
"mintedTitle": "Key for “{{name}}” — shown once",
"savedIt": "I’ve saved it",
"copyKey": "Copy key",
"copied": "Key copied",
"copyFailed": "Could not copy — select and copy manually",
"newKey": "New key",
"namePlaceholder": "iPhone, Music app, laptop CLI…",
"create": "Create",
"createFailed": "Could not create the key",
"noneYet": "No keys yet.",
"noneInUse": "No keys in use.",
"revoked": "revoked",
"expired": "expired",
"lastUsed": "last used {{date}}",
"neverUsed": "never used",
"forget": "Remove this record",
"forgetFailed": "Could not remove it",
"revoke": "Revoke",
"revokeDone": "Revoked — anything using that key is signed out",
"revokeFailed": "Could not revoke",
"hideDead": "Hide revoked and expired",
"showDead": {
"one": "Show {{count}} revoked or expired key",
"other": "Show {{count}} revoked or expired keys"
}
},
"voice": {
"label": "Voice",
"refresh": "Refresh voices",
"serverDefault": "Server default",
"hint": "Choose a voice for text-to-speech. Leave as server default to use the admin-configured voice.",
"stop": "Stop",
"listen": "Listen"
},
"dock": {
"visible": "Visible",
"hidden": "Hidden",
"dragToShow": "Drag items here to show in dock",
"allVisible": "All items visible",
"reset": "Reset to defaults"
},
"languages": {
"spokenLabel": "Languages you speak",
"addLanguage": "Add a language...",
"defaultLabel": "Default language",
"defaultHint": "Used for UI localization.",
"translateToLabel": "Translate to",
"translateToHint": "Target language when translating content you don't speak."
},
"changePassword": {
"currentPasswordLabel": "Current Password",
"currentPasswordPlaceholder": "Current password",
@@ -38,13 +80,18 @@
"passwordChanged": "Password changed",
"passwordChangeFailed": "Failed to change password"
},
"languages": {
"spokenLabel": "Languages you speak",
"addLanguage": "Add a language...",
"defaultLabel": "Default language",
"defaultHint": "Used for UI localization.",
"translateToLabel": "Translate to",
"translateToHint": "Target language when translating content you don't speak."
"models": {
"defaultModel": "Default Model",
"sameAsChat": "Same as chat default",
"taskDefaultsSaved": "Task defaults saved",
"saveFailed": "Failed to save settings",
"defaultChat": "Default Chat Model",
"defaultChatHint": "Used when starting a new chat from the home screen",
"defaultProject": "Default Project Model",
"defaultProjectHint": "Used when starting a new chat inside a project dashboard",
"defaultTask": "Default Task Model",
"defaultTaskHint": "Used when running tasks from the file browser",
"aiDefaultsSaved": "AI model defaults saved"
}
},
"sections": {
+72 -25
View File
@@ -6,25 +6,67 @@
},
"settings": {
"profile": {
"searchPlaceholder": "Pesquisar definições...",
"sections": {
"profileTitle": "Perfil",
"profileDescription": "Atualiza o teu nome e avatar.",
"changePasswordTitle": "Alterar Palavra-passe",
"changePasswordDescription": "Atualiza a palavra-passe da tua conta.",
"tasksTitle": "Tarefas",
"tasksDescription": "Modelo predefinido para tarefas do explorador de ficheiros.",
"languagesTitle": "Idiomas",
"languagesDescription": "Define os teus idiomas e preferências de tradução."
},
"userData": {
"emailLabel": "Email",
"nameLabel": "Nome",
"namePlaceholder": "O teu nome",
"profileUpdated": "Perfil atualizado",
"profileUpdateFailed": "Falha ao atualizar perfil",
"selectImageFile": "Por favor seleciona um ficheiro de imagem",
"imageTooLarge": "A imagem deve ter menos de 384KB"
"email": "Email",
"username": "Nome de utilizador",
"name": "Nome",
"namePlaceholder": "O seu nome",
"updated": "Perfil atualizado",
"updateFailed": "Falha ao atualizar o perfil",
"selectImage": "Selecione um ficheiro de imagem",
"imageTooLarge": "A imagem tem de ser menor do que 384KB"
},
"apiKeys": {
"intro": "Uma chave de API inicia sessão como você, sem palavra-passe e sem expirar. Uma chave funciona em todo o lado — uma chave não está associada a uma aplicação. Dar a cada dispositivo a sua própria chave serve apenas para poder cortar uma sem mexer nas restantes. Envie-a como",
"loading": "A carregar chaves…",
"mintedTitle": "Chave para “{{name}}” — mostrada uma única vez",
"savedIt": "Já a guardei",
"copyKey": "Copiar chave",
"copied": "Chave copiada",
"copyFailed": "Não foi possível copiar — selecione e copie manualmente",
"newKey": "Nova chave",
"namePlaceholder": "iPhone, aplicação Música, CLI do portátil…",
"create": "Criar",
"createFailed": "Não foi possível criar a chave",
"noneYet": "Ainda não há chaves.",
"noneInUse": "Nenhuma chave em uso.",
"revoked": "revogada",
"expired": "expirada",
"lastUsed": "usada pela última vez em {{date}}",
"neverUsed": "nunca usada",
"forget": "Remover este registo",
"forgetFailed": "Não foi possível remover",
"revoke": "Revogar",
"revokeDone": "Revogada — tudo o que usava essa chave foi desligado",
"revokeFailed": "Não foi possível revogar",
"hideDead": "Ocultar revogadas e expiradas",
"showDead": {
"one": "Mostrar {{count}} chave revogada ou expirada",
"other": "Mostrar {{count}} chaves revogadas ou expiradas"
}
},
"voice": {
"label": "Voz",
"refresh": "Atualizar vozes",
"serverDefault": "Predefinição do servidor",
"hint": "Escolha uma voz para a conversão de texto em fala. Deixe na predefinição do servidor para usar a voz configurada pelo administrador.",
"stop": "Parar",
"listen": "Ouvir"
},
"dock": {
"visible": "Visíveis",
"hidden": "Ocultos",
"dragToShow": "Arraste itens para aqui para os mostrar no dock",
"allVisible": "Todos os itens visíveis",
"reset": "Restaurar predefinições"
},
"languages": {
"spokenLabel": "Idiomas que falas",
"addLanguage": "Adicionar idioma...",
"defaultLabel": "Idioma predefinido",
"defaultHint": "Usado para localização da interface.",
"translateToLabel": "Traduzir para",
"translateToHint": "Idioma alvo ao traduzir conteúdo que não falas."
},
"changePassword": {
"currentPasswordLabel": "Palavra-passe Atual",
@@ -38,13 +80,18 @@
"passwordChanged": "Palavra-passe alterada",
"passwordChangeFailed": "Falha ao alterar palavra-passe"
},
"languages": {
"spokenLabel": "Idiomas que falas",
"addLanguage": "Adicionar idioma...",
"defaultLabel": "Idioma predefinido",
"defaultHint": "Usado para localização da interface.",
"translateToLabel": "Traduzir para",
"translateToHint": "Idioma alvo ao traduzir conteúdo que não falas."
"models": {
"defaultModel": "Modelo predefinido",
"sameAsChat": "Igual à predefinição do chat",
"taskDefaultsSaved": "Predefinições de tarefas guardadas",
"saveFailed": "Falha ao guardar as definições",
"defaultChat": "Modelo predefinido do chat",
"defaultChatHint": "Usado ao iniciar um novo chat a partir do ecrã inicial",
"defaultProject": "Modelo predefinido do projeto",
"defaultProjectHint": "Usado ao iniciar um novo chat dentro de um painel de projeto",
"defaultTask": "Modelo predefinido das tarefas",
"defaultTaskHint": "Usado ao executar tarefas a partir do explorador de ficheiros",
"aiDefaultsSaved": "Predefinições de modelos de IA guardadas"
}
},
"sections": {