This commit is contained in:
k
2026-09-21 16:37:40 +02:00
parent ad1629164f
commit 8038e02303
5 changed files with 247 additions and 55 deletions
+34 -26
View File
@@ -517,8 +517,8 @@ export function SettingsModal({
{uploading ? <Spinner size={17} /> : <ImagePlus size={17} />}
</span>
<span className="min-w-0 flex-1 text-sm">
<span className="block font-medium">{uploading ? "Uploading…" : dragOver ? "Drop it!" : "Drop an image or click to upload"}</span>
<span className="block text-xs opacity-60">jpg · png · webp — max 5MB — 5 changes per 2h</span>
<span className="block font-medium">{uploading ? t("settings.uploading") : dragOver ? t("settings.dropActive") : t("settings.dropIdle")}</span>
<span className="block text-xs opacity-60">{t("settings.avatarHint")}</span>
</span>
</div>
<input
@@ -534,10 +534,10 @@ export function SettingsModal({
/>
{uploadError && <p className="mt-1.5 text-sm text-red-600 dark:text-red-400">{uploadError}</p>}
</div>
<Field label="Display name">
<Field label={t("common.displayName")}>
<Input value={displayName} onChange={(e) => setDisplayName(e.target.value)} maxLength={60} required />
</Field>
<Field label="Recovery email" hint="Optional. Used only to send you a fresh access key if you lose it.">
<Field label={t("settings.email")} hint={t("settings.emailHint")}>
<Input value={email} onChange={(e) => setEmail(e.target.value)} placeholder="[email protected]" inputMode="email" />
</Field>
</SettingsPane>
@@ -545,26 +545,29 @@ export function SettingsModal({
{activeCat === "appearance" && (
<SettingsPane>
<Field label="Theme">
<Field label={t("common.theme")}>
<Picker
ariaLabel="Theme"
ariaLabel={t("common.theme")}
value={theme}
onChange={setTheme}
align="left"
options={[
{ value: "auto", label: "Auto (follows system)" },
{ value: "light", label: "Light" },
{ value: "dark", label: "Dark" },
{ value: "auto", label: t("common.themeAutoFull") },
{ value: "light", label: t("common.light") },
{ value: "dark", label: t("common.dark") },
]}
/>
</Field>
<Field label={t("settings.language")} hint={t("settings.languageHint")}>
<LangPicker align="left" />
</Field>
<AccentSection />
</SettingsPane>
)}
{activeCat === "features" && (
<SettingsPane>
<p className="-mb-2 text-xs opacity-60">Turn composer capabilities on or off. Applied instantly, saved on this device.</p>
<p className="-mb-2 text-xs opacity-60">{t("settings.featHint")}</p>
<FeaturesSection />
</SettingsPane>
)}
@@ -578,13 +581,13 @@ export function SettingsModal({
</div>
{search.trim() && visibleCats.length === 0 && (
<p className="px-2 py-4 text-center text-sm opacity-50">No settings match “{search.trim()}”.</p>
<p className="px-2 py-4 text-center text-sm opacity-50">{t("settings.noMatch", { q: search.trim() })}</p>
)}
{error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
<div className="flex justify-end gap-2">
<Button variant="secondary" size="sm" type="button" onClick={onClose}>Cancel</Button>
<Button size="sm" type="submit" disabled={busy}>{busy ? <Spinner size={15} /> : "Save"}</Button>
<Button variant="secondary" size="sm" type="button" onClick={onClose}>{t("common.cancel")}</Button>
<Button size="sm" type="submit" disabled={busy}>{busy ? <Spinner size={15} /> : t("common.save")}</Button>
</div>
</form>
</Modal>
@@ -606,6 +609,7 @@ function SettingsPane({ children }: { children: React.ReactNode }) {
/* ---------- Accent color (settings) ---------- */
function AccentSection() {
const { t } = useT();
const [state, setState] = useState<AccentState>(() => currentAccent());
function pick(presetId: string) {
@@ -622,13 +626,15 @@ function AccentSection() {
return (
<div>
<span className="mb-1.5 block text-sm font-medium opacity-80">{t("settings.accent")}</span>
<p className="mb-2 text-xs opacity-60">{t("settings.accentHint")}</p>
<div className="flex flex-wrap items-center gap-2">
{ACCENT_PRESETS.map((p) => (
<button
key={p.id}
type="button"
title={p.label}
aria-label={`Accent: ${p.label}`}
title={t(`accent.preset.${p.id}` as StringKey)}
aria-label={t("settings.accentAria", { label: t(`accent.preset.${p.id}` as StringKey) })}
onClick={() => pick(p.id)}
className={cn(
"h-8 w-8 rounded-full border-2 transition active:scale-90",
@@ -647,12 +653,12 @@ function AccentSection() {
: "border-transparent hover:scale-110",
)}
style={{ background: state.presetId === "custom" ? state.customHex : undefined }}
title="Custom color"
title={t("settings.customColor")}
>
<Sparkles size={14} className={cn("text-white drop-shadow-[0_1px_2px_rgba(0,0,0,0.6)]", state.presetId === "custom" && "opacity-0")} />
<input
type="color"
aria-label="Custom accent color"
aria-label={t("settings.customAria")}
value={state.presetId === "custom" ? state.customHex : "#10b981"}
onChange={(e) => pickCustom(e.target.value)}
onBlur={() => applyAccent(state)} // persist the last previewed value
@@ -667,6 +673,7 @@ function AccentSection() {
/* ---------- Features (settings) ---------- */
function FeaturesSection() {
const { t } = useT();
const flags = useFeatures();
return (
<div>
@@ -682,15 +689,15 @@ function FeaturesSection() {
<Toggle
checked={!!flags[f.key]}
disabled={!f.ready}
label={f.label}
label={t(`feat.${f.key}.label` as StringKey)}
onChange={(v) => setFeature(f.key, v)}
/>
<span className="min-w-0">
<span className="block text-sm font-medium">
{f.label}
{!f.ready && <span className="ml-1.5 text-xs opacity-60">(soon)</span>}
{t(`feat.${f.key}.label` as StringKey)}
{!f.ready && <span className="ml-1.5 text-xs opacity-60">{t("feat.soon")}</span>}
</span>
<span className="block text-xs opacity-60">{f.hint}</span>
<span className="block text-xs opacity-60">{t(`feat.${f.key}.hint` as StringKey)}</span>
</span>
</label>
))}
@@ -702,6 +709,7 @@ function FeaturesSection() {
/* ---------- Security (settings): key rotation, TOTP, passkeys ---------- */
function SecuritySection({ onKeyRotated }: { onKeyRotated?: () => void }) {
const { t } = useT();
const [totpOn, setTotpOn] = useState<boolean | null>(null);
const [secret, setSecret] = useState<string | null>(null);
const [otpauthUrl, setOtpauthUrl] = useState("");
@@ -728,7 +736,7 @@ function SecuritySection({ onKeyRotated }: { onKeyRotated?: () => void }) {
setRotatedKey(res.key);
setArmingRotate(false);
} catch (err) {
setError(err instanceof Error ? err.message : "Rotation failed");
setError(err instanceof Error ? err.message : t("sec.rotationFailed"));
} finally {
setBusy(false);
}
@@ -743,7 +751,7 @@ function SecuritySection({ onKeyRotated }: { onKeyRotated?: () => void }) {
setOtpauthUrl(res.otpauthUrl);
setCode("");
} catch (err) {
setError(err instanceof Error ? err.message : "Setup failed");
setError(err instanceof Error ? err.message : t("sec.setupFailed"));
} finally {
setBusy(false);
}
@@ -759,7 +767,7 @@ function SecuritySection({ onKeyRotated }: { onKeyRotated?: () => void }) {
setSecret(null);
setCode("");
} catch (err) {
setError(err instanceof Error ? err.message : "Invalid code");
setError(err instanceof Error ? err.message : t("login.totpInvalid"));
} finally {
setBusy(false);
}
@@ -774,7 +782,7 @@ function SecuritySection({ onKeyRotated }: { onKeyRotated?: () => void }) {
setTotpOn(false);
setCode("");
} catch (err) {
setError(err instanceof Error ? err.message : "Invalid code");
setError(err instanceof Error ? err.message : t("login.totpInvalid"));
} finally {
setBusy(false);
}
@@ -788,7 +796,7 @@ function SecuritySection({ onKeyRotated }: { onKeyRotated?: () => void }) {
setConfirmDelete(false);
onKeyRotated?.();
} catch (err) {
setError(err instanceof Error ? err.message : "Delete failed");
setError(err instanceof Error ? err.message : t("sec.deleteFailed"));
} finally {
setBusy(false);
}