feat(settings,admin): every tab and popup in the planner's design

This commit is contained in:
Maurice
2026-10-01 16:00:37 +02:00
parent 4bc7c5cf68
commit 788a58679e
75 changed files with 8134 additions and 8023 deletions
+139 -265
View File
@@ -1,62 +1,105 @@
import { BookOpen, Bug, Coffee, ExternalLink, Heart, Info, Lightbulb } from 'lucide-react';
import React from 'react';
import { BookOpen, Bug, Coffee, ExternalLink, Github, Heart, Info, Lightbulb } from 'lucide-react';
import React, { type CSSProperties, type ReactNode } from 'react';
import { useTranslation } from '../../i18n';
import Section from './Section';
import { StatusPill } from './settingsKit';
import { fs } from '../shared/DialogShell';
import { useAuthStore } from '../../store/authStore';
interface Props {
appVersion: string;
}
const DISCORD_PATH = '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';
// The services' own colours: each tile takes its brand on hover and in its icon square.
const BRAND = {
kofi: '#ff5e5b', // theme-lint-disable: Ko-fi brand colour
bmac: '#ffdd00', // theme-lint-disable: Buy Me a Coffee brand colour
discord: '#5865F2', // theme-lint-disable: Discord brand colour
bug: '#ef4444', // theme-lint-disable: the bug tile's signal red, a fixed brand-like mark
idea: '#f59e0b', // theme-lint-disable: the idea tile's signal amber, a fixed brand-like mark
wiki: '#6366f1', // theme-lint-disable: the wiki tile's signal indigo, a fixed brand-like mark
} as const;
/**
* One outbound link as a tile: a tinted icon square, a title and a hint, the
* external-link mark on the right. The border picks up the brand colour while
* the pointer is on it.
*/
function LinkTile({ href, brand, icon, title, hint }: {
href: string;
brand?: string;
icon?: ReactNode;
title: ReactNode;
hint: ReactNode;
}) {
const tint: CSSProperties | undefined = brand
? { background: `color-mix(in srgb, ${brand} 12%, transparent)`, color: brand }
: undefined;
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="flex min-w-0 items-center gap-3 overflow-hidden rounded-[14px] border border-edge bg-surface-card px-3.5 py-3 no-underline shadow-sm transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
onMouseEnter={brand ? (e) => {
e.currentTarget.style.borderColor = brand;
e.currentTarget.style.boxShadow = `0 0 0 1px ${brand}22`;
} : undefined}
onMouseLeave={brand ? (e) => {
e.currentTarget.style.borderColor = 'var(--border-primary)';
e.currentTarget.style.boxShadow = 'none';
} : undefined}
>
{icon && (
<span
className={`grid h-10 w-10 flex-none place-items-center rounded-[12px] ${brand ? '' : 'bg-surface-tertiary text-content-secondary'}`}
style={tint}
>
{icon}
</span>
)}
<div className="min-w-0 flex-1">
<div className="truncate font-semibold text-content" style={fs(13, 'body')}>{title}</div>
<div className="truncate text-content-faint" style={fs(11.5)}>{hint}</div>
</div>
<ExternalLink size={14} className="flex-none text-content-faint" />
</a>
);
}
export default function AboutTab({ appVersion }: Props): React.ReactElement {
const { t, locale } = useTranslation();
const { t } = useTranslation();
const managed = useAuthStore((s) => s.managed);
return (
<Section title={t('settings.about')} icon={Info}>
<Section title={t('settings.about')} icon={Info} badge={<StatusPill>v{appVersion}</StatusPill>}>
<style>{`
@keyframes heartPulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.15); }
}
`}</style>
<p
className="text-content-secondary"
style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', lineHeight: 1.6, marginBottom: 6, marginTop: -4 }}
>
{/* The stock line calls TREK self-hosted and points at 'your own server'.
Both are true for the reader who set it up and neither is for a customer
of a hosted instance, so the mode picks the sentence rather than the
wording being watered down for everybody. */}
{t(managed ? 'settings.about.descriptionManaged' : 'settings.about.description')}
</p>
<p
className="text-content-faint"
style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', lineHeight: 1.6, marginBottom: 16 }}
>
{t('settings.about.madeWith')}{' '}
<Heart
size={11}
fill="#991b1b"
stroke="#991b1b"
style={{ display: 'inline-block', verticalAlign: '-1px', animation: 'heartPulse 1.5s ease-in-out infinite' }}
/>{' '}
{t('settings.about.madeBy')}{' '}
<span
className="bg-surface-tertiary text-content-faint"
style={{
display: 'inline-flex',
alignItems: 'center',
borderRadius: 99,
padding: '1px 7px',
fontSize: 'calc(10px * var(--fs-scale-caption, 1))',
fontWeight: 600,
verticalAlign: '1px',
}}
>
v{appVersion}
</span>
</p>
<div className="flex flex-col gap-1.5">
<p className="m-0 leading-relaxed text-content-secondary" style={fs(13, 'body')}>
{/* The stock line calls TREK self-hosted and points at 'your own server'.
Both are true for the reader who set it up and neither is for a customer
of a hosted instance, so the mode picks the sentence rather than the
wording being watered down for everybody. */}
{t(managed ? 'settings.about.descriptionManaged' : 'settings.about.description')}
</p>
<p className="m-0 leading-relaxed text-content-faint" style={fs(12, 'body')}>
{t('settings.about.madeWith')}{' '}
<Heart
size={11}
fill="currentColor"
className="inline-block text-danger"
style={{ verticalAlign: '-1px', animation: 'heartPulse 1.5s ease-in-out infinite' }}
/>{' '}
{t('settings.about.madeBy')}
</p>
</div>
{/* Ko-fi, Buy Me a Coffee, Discord, and the issue/discussion links assume
the reader runs this install and can act on it. On a centrally
@@ -65,237 +108,68 @@ export default function AboutTab({ appVersion }: Props): React.ReactElement {
the source link below stay in both modes: AGPL §13 wants the source
offered prominently to the people using it over a network, and that is
not the part being trimmed here. */}
{!managed && (<>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<a
href="https://ko-fi.com/mauriceboe"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-4 overflow-hidden rounded-xl border border-edge bg-surface-card px-5 py-4 no-underline transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = '#ff5e5b';
e.currentTarget.style.boxShadow = '0 0 0 1px #ff5e5b22';
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = 'var(--border-primary)';
e.currentTarget.style.boxShadow = 'none';
}}
>
<div
className="bg-[#ff5e5b15]"
style={{
width: 40,
height: 40,
borderRadius: 10,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
<Coffee size={20} className="text-[#ff5e5b]" />
</div>
<div>
<div className="text-sm font-semibold text-content">Ko-fi</div>
<div className="text-xs text-content-faint">{t('admin.github.support')}</div>
</div>
<ExternalLink size={14} className="ml-auto flex-shrink-0 text-content-faint" />
</a>
<a
href="https://buymeacoffee.com/mauriceboe"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-4 overflow-hidden rounded-xl border border-edge bg-surface-card px-5 py-4 no-underline transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = '#ffdd00';
e.currentTarget.style.boxShadow = '0 0 0 1px #ffdd0022';
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = 'var(--border-primary)';
e.currentTarget.style.boxShadow = 'none';
}}
>
<div
className="bg-[#ffdd0015]"
style={{
width: 40,
height: 40,
borderRadius: 10,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
<Heart size={20} className="text-[#ffdd00]" />
</div>
<div>
<div className="text-sm font-semibold text-content">Buy Me a Coffee</div>
<div className="text-xs text-content-faint">{t('admin.github.support')}</div>
</div>
<ExternalLink size={14} className="ml-auto flex-shrink-0 text-content-faint" />
</a>
<a
href="https://discord.gg/NhZBDSd4qW"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-4 overflow-hidden rounded-xl border border-edge bg-surface-card px-5 py-4 no-underline transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = '#5865F2';
e.currentTarget.style.boxShadow = '0 0 0 1px #5865F222';
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = 'var(--border-primary)';
e.currentTarget.style.boxShadow = 'none';
}}
>
<div
className="bg-[#5865F215]"
style={{
width: 40,
height: 40,
borderRadius: 10,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="#5865F2">
<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>
<div>
<div className="text-sm font-semibold text-content">Discord</div>
<div className="text-xs text-content-faint">Join the community</div>
</div>
<ExternalLink size={14} className="ml-auto flex-shrink-0 text-content-faint" />
</a>
</div>
<div className="mt-3 grid grid-cols-1 gap-3 sm:grid-cols-3">
<a
href="https://github.com/liketrek/TREK/issues/new?template=bug_report.yml"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-4 overflow-hidden rounded-xl border border-edge bg-surface-card px-5 py-4 no-underline transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = '#ef4444';
e.currentTarget.style.boxShadow = '0 0 0 1px #ef444422';
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = 'var(--border-primary)';
e.currentTarget.style.boxShadow = 'none';
}}
>
<div
className="bg-[#ef444415]"
style={{
width: 40,
height: 40,
borderRadius: 10,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
<Bug size={20} className="text-[#ef4444]" />
</div>
<div>
<div className="text-sm font-semibold text-content">{t('settings.about.reportBug')}</div>
<div className="text-xs text-content-faint">{t('settings.about.reportBugHint')}</div>
</div>
<ExternalLink size={14} className="ml-auto flex-shrink-0 text-content-faint" />
</a>
<a
href="https://github.com/liketrek/TREK/discussions/new?category=feature-requests"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-4 overflow-hidden rounded-xl border border-edge bg-surface-card px-5 py-4 no-underline transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = '#f59e0b';
e.currentTarget.style.boxShadow = '0 0 0 1px #f59e0b22';
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = 'var(--border-primary)';
e.currentTarget.style.boxShadow = 'none';
}}
>
<div
className="bg-[#f59e0b15]"
style={{
width: 40,
height: 40,
borderRadius: 10,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
<Lightbulb size={20} className="text-[#f59e0b]" />
</div>
<div>
<div className="text-sm font-semibold text-content">{t('settings.about.featureRequest')}</div>
<div className="text-xs text-content-faint">{t('settings.about.featureRequestHint')}</div>
</div>
<ExternalLink size={14} className="ml-auto flex-shrink-0 text-content-faint" />
</a>
<a
href="https://github.com/liketrek/TREK/wiki"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-4 overflow-hidden rounded-xl border border-edge bg-surface-card px-5 py-4 no-underline transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = '#6366f1';
e.currentTarget.style.boxShadow = '0 0 0 1px #6366f122';
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = 'var(--border-primary)';
e.currentTarget.style.boxShadow = 'none';
}}
>
<div
className="bg-[#6366f115]"
style={{
width: 40,
height: 40,
borderRadius: 10,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
<BookOpen size={20} className="text-[#6366f1]" />
</div>
<div>
<div className="text-sm font-semibold text-content">Wiki</div>
<div className="text-xs text-content-faint">{t('settings.about.wikiHint')}</div>
</div>
<ExternalLink size={14} className="ml-auto flex-shrink-0 text-content-faint" />
</a>
</div>
</>)}
{!managed && (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
<LinkTile
href="https://ko-fi.com/mauriceboe"
brand={BRAND.kofi}
icon={<Coffee size={19} />}
title="Ko-fi"
hint={t('admin.github.support')}
/>
<LinkTile
href="https://buymeacoffee.com/mauriceboe"
brand={BRAND.bmac}
icon={<Heart size={19} />}
title="Buy Me a Coffee"
hint={t('admin.github.support')}
/>
<LinkTile
href="https://discord.gg/NhZBDSd4qW"
brand={BRAND.discord}
icon={(
<svg width="19" height="19" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d={DISCORD_PATH} />
</svg>
)}
title="Discord"
hint="Join the community"
/>
<LinkTile
href="https://github.com/liketrek/TREK/issues/new?template=bug_report.yml"
brand={BRAND.bug}
icon={<Bug size={19} />}
title={t('settings.about.reportBug')}
hint={t('settings.about.reportBugHint')}
/>
<LinkTile
href="https://github.com/liketrek/TREK/discussions/new?category=feature-requests"
brand={BRAND.idea}
icon={<Lightbulb size={19} />}
title={t('settings.about.featureRequest')}
hint={t('settings.about.featureRequestHint')}
/>
<LinkTile
href="https://github.com/liketrek/TREK/wiki"
brand={BRAND.wiki}
icon={<BookOpen size={19} />}
title="Wiki"
hint={t('settings.about.wikiHint')}
/>
</div>
)}
{/* What replaces the grids above. AGPL §13 asks for the source to be
offered prominently to whoever uses the software over a network, and a
customer of a hosted instance is exactly that reader. The support and
bug-report links go; this does not. */}
{managed && (
<a
<LinkTile
href="https://github.com/liketrek/TREK"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-4 overflow-hidden rounded-xl border border-edge bg-surface-card px-5 py-4 no-underline"
>
<div>
<div className="text-sm font-semibold text-content">{t('settings.about.sourceTitle')}</div>
<div className="text-xs text-content-faint">{t('settings.about.sourceHint')}</div>
</div>
<ExternalLink size={14} className="ml-auto flex-shrink-0 text-content-faint" />
</a>
icon={<Github size={19} />}
title={t('settings.about.sourceTitle')}
hint={t('settings.about.sourceHint')}
/>
)}
</Section>
);
@@ -822,7 +822,10 @@ describe('AccountTab – Modals and field edits', () => {
await user.click(screen.getByText('Delete account'));
await screen.findByText('Deletion not possible');
fireEvent.click(backdropOf('Deletion not possible'));
// The shared dialog shell closes only on a press that starts and ends on the backdrop.
const backdrop = backdropOf('Deletion not possible');
fireEvent.mouseDown(backdrop);
fireEvent.click(backdrop);
expect(screen.queryByText('Deletion not possible')).not.toBeInTheDocument();
});
@@ -862,21 +865,20 @@ describe('AccountTab – Modals and field edits', () => {
expect(updateProfile).toHaveBeenCalledWith({ username: 'testuser', email: 'new@example.com' });
});
it('FE-COMP-ACCOUNT-069: hover styling on the password and camera buttons is reverted on leave', () => {
it('FE-COMP-ACCOUNT-069: hovering the password and camera buttons leaves no inline style behind', () => {
render(<AccountTab />);
// Hover is CSS now; nothing is written onto the element that could stick after leave.
const passwordBtn = screen.getByText('Update password').closest('button') as HTMLElement;
fireEvent.mouseEnter(passwordBtn);
expect(passwordBtn.style.background).toBe('var(--bg-hover)');
fireEvent.mouseLeave(passwordBtn);
expect(passwordBtn.style.background).toBe('var(--bg-card)');
expect(passwordBtn.style.background).toBe('');
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
const cameraBtn = fileInput.nextElementSibling as HTMLElement;
expect(cameraBtn).toHaveAccessibleName('Upload Profile Picture');
fireEvent.mouseEnter(cameraBtn);
expect(cameraBtn.style.transform).toBe('scale(1.15)');
fireEvent.mouseLeave(cameraBtn);
expect(cameraBtn.style.transform).toBe('scale(1)');
expect(cameraBtn.style.opacity).toBe('1');
expect(cameraBtn.style.transform).toBe('');
});
});
+350 -365
View File
@@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react'
import { User, Save, Lock, KeyRound, AlertTriangle, Shield, Camera, Trash2, Copy, Download, Printer } from 'lucide-react'
import React, { useState, useEffect, useId } from 'react'
import { User, Save, Lock, KeyRound, AlertTriangle, Shield, Camera, Trash2, Copy, Download, Printer, ShieldCheck } from 'lucide-react'
import { useNavigate, useSearchParams } from 'react-router'
import { useTranslation } from '../../i18n'
import { useAuthStore } from '../../store/authStore'
@@ -12,9 +12,22 @@ import Section from './Section'
import PasskeysSection from './PasskeysSection'
import PasswordChecklist from '../shared/PasswordChecklist'
import { passwordErrorKey } from '../../utils/passwordError'
import ConfirmDialog from '../shared/ConfirmDialog'
import { Tooltip } from '../shared/Tooltip'
import { DialogButton, DialogFooter, DialogHeader, DialogShell, DialogTile, FooterSpacer, NEUTRAL_TINT, fs } from '../shared/DialogShell'
import { EditorField, GRID_2, INPUT, PANEL } from '../shared/dialogParts'
import {
SETTINGS_BUTTON, SETTINGS_BUTTON_DANGER, SETTINGS_BUTTON_PRIMARY, SettingRow, SettingRows, SettingsHint, StatusPill,
} from './settingsKit'
const MFA_BACKUP_SESSION_KEY = 'trek_mfa_backup_codes_pending'
/** The small turning ring a button shows while its request is on the way. */
const SPINNER = 'h-3.5 w-3.5 flex-none animate-spin rounded-full border-2 border-current border-t-transparent'
/** The eyebrow over a block inside a card body. */
const EYEBROW = 'm-0 font-geist font-bold uppercase tracking-[.08em] text-content-faint'
// Drops every trailing slash, like the `/\/+$/` replace it stands in for — as a scan,
// because that pattern re-walks the slash run from each start position.
const stripTrailingSlashes = (value: string): string => {
@@ -30,6 +43,7 @@ export default function AccountTab(): React.ReactElement {
const { t } = useTranslation()
const toast = useToast()
const avatarInputRef = React.useRef<HTMLInputElement>(null)
const blockedLabelId = useId()
const [saving, setSaving] = useState(false)
const [showDeleteConfirm, setShowDeleteConfirm] = useState<boolean | 'blocked'>(false)
@@ -160,105 +174,194 @@ export default function AccountTab(): React.ReactElement {
}
}
const oidcIssuer = (user as UserWithOidc)?.oidc_issuer
return (
<>
<Section title={t('settings.account')} icon={User}>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.username')}</label>
<input
type="text"
value={username}
onChange={e => setUsername(e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.email')}</label>
<input
type="email"
value={email}
onChange={e => setEmail(e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
/>
{/* Who is signed in: the picture with its two actions, the role and the SSO link. */}
<div className="flex items-center gap-4 rounded-[14px] border border-edge-faint bg-surface-card px-4 py-3.5">
<div className="relative flex-none">
{user?.avatar_url ? (
<img src={user.avatar_url} alt="" className="h-16 w-16 rounded-full object-cover ring-1 ring-edge-faint" />
) : (
<div className="grid h-16 w-16 place-items-center rounded-full bg-surface-tertiary font-bold text-content-secondary" style={fs(24, 'subtitle')}>
{user?.username?.charAt(0).toUpperCase()}
</div>
)}
<input ref={avatarInputRef} type="file" accept="image/*" onChange={handleAvatarUpload} className="hidden" />
<Tooltip label={t('settings.uploadAvatar')}>
<button type="button"
onClick={() => avatarInputRef.current?.click()}
aria-label={t('settings.uploadAvatar')}
className="absolute -bottom-1 -right-1 grid h-7 w-7 place-items-center rounded-full border-2 border-[color:var(--bg-card)] bg-[color:var(--text-primary)] text-[color:var(--bg-card)] shadow-sm transition-transform hover:scale-110"
>
<Camera size={13} strokeWidth={2.2} />
</button>
</Tooltip>
{user?.avatar_url && (
<Tooltip label={t('settings.removeAvatar')}>
<button type="button"
onClick={handleAvatarRemove}
aria-label={t('settings.removeAvatar')}
className="absolute -right-1 -top-1 grid h-6 w-6 place-items-center rounded-full border-2 border-[color:var(--bg-card)] bg-danger text-white shadow-sm transition-transform hover:scale-110" // theme-lint-disable: white glyph on the danger fill, as ConfirmDialog draws it
>
<Trash2 size={11} strokeWidth={2.2} />
</button>
</Tooltip>
)}
</div>
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
<div className="truncate font-bold text-content" style={fs(15, 'subtitle')}>{user?.username}</div>
<div className="flex flex-wrap items-center gap-1.5">
{user?.role === 'admin'
? <StatusPill tone="warning" icon={<Shield size={11} strokeWidth={2.4} />}>{t('settings.roleAdmin')}</StatusPill>
: <StatusPill>{t('settings.roleUser')}</StatusPill>}
{oidcIssuer && <StatusPill tone="accent">SSO</StatusPill>}
</div>
{oidcIssuer && (
<p className="m-0 truncate text-content-faint" style={fs(11.5)}>
{t('settings.oidcLinked')} {stripTrailingSlashes(oidcIssuer.replace('https://', ''))}
</p>
)}
</div>
</div>
{/* Change Password */}
{!oidcOnlyMode && (
<div className="pt-4 mt-4 border-t border-edge-secondary">
<label className="block text-sm font-medium text-slate-700 mb-3">{t('settings.changePassword')}</label>
<div className="space-y-3">
<input
type="password"
value={currentPassword}
onChange={e => setCurrentPassword(e.target.value)}
placeholder={t('settings.currentPassword')}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
/>
<div className={GRID_2}>
<EditorField label={t('settings.username')} htmlFor="account-username">
<input
id="account-username"
type="text"
value={username}
onChange={e => setUsername(e.target.value)}
className={INPUT}
/>
</EditorField>
<EditorField label={t('settings.email')} htmlFor="account-email">
<input
id="account-email"
type="email"
value={email}
onChange={e => setEmail(e.target.value)}
className={INPUT}
/>
</EditorField>
</div>
{/* The card's foot: the way out on the left, the save on the right, like a dialog's bar. */}
<div className="-mx-4 -mb-4 flex items-center gap-2 border-t border-edge-faint bg-surface-card px-4 py-3" style={fs(13, 'body')}>
<button type="button"
onClick={async () => {
if (user?.role === 'admin') {
try {
await adminApi.stats()
const adminUsers = (await adminApi.users()).users.filter((u: { role: string }) => u.role === 'admin')
if (adminUsers.length <= 1) {
setShowDeleteConfirm('blocked')
return
}
} catch {}
}
setShowDeleteConfirm(true)
}}
className={SETTINGS_BUTTON_DANGER}
>
<Trash2 size={14} />
<span className="hidden sm:inline">{t('settings.deleteAccount')}</span>
<span className="sm:hidden">{t('common.delete')}</span>
</button>
<span className="flex-1" />
<button type="button"
onClick={saveProfile}
disabled={saving}
className={SETTINGS_BUTTON_PRIMARY}
>
{saving ? <span className={SPINNER} /> : <Save size={14} />}
<span className="hidden sm:inline">{t('settings.saveProfile')}</span>
<span className="sm:hidden">{t('common.save')}</span>
</button>
</div>
</Section>
{/* Change Password */}
{!oidcOnlyMode && (
<Section title={t('settings.changePassword')} icon={Lock}>
<EditorField label={t('settings.currentPassword')} htmlFor="account-current-password">
<input
id="account-current-password"
type="password"
value={currentPassword}
onChange={e => setCurrentPassword(e.target.value)}
placeholder={t('settings.currentPassword')}
className={INPUT}
/>
</EditorField>
<div className={GRID_2}>
<EditorField label={t('settings.newPassword')} htmlFor="account-new-password">
<input
id="account-new-password"
type="password"
value={newPassword}
onChange={e => setNewPassword(e.target.value)}
placeholder={t('settings.newPassword')}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
className={INPUT}
/>
<PasswordChecklist password={newPassword} />
</EditorField>
<EditorField label={t('settings.confirmPassword')} htmlFor="account-confirm-password">
<input
id="account-confirm-password"
type="password"
value={confirmPassword}
onChange={e => setConfirmPassword(e.target.value)}
placeholder={t('settings.confirmPassword')}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
className={INPUT}
/>
<button type="button"
onClick={async () => {
if (!currentPassword) return toast.error(t('settings.currentPasswordRequired'))
if (!newPassword) return toast.error(t('settings.passwordRequired'))
const weak = passwordErrorKey(newPassword)
if (weak) return toast.error(t(weak))
if (newPassword !== confirmPassword) return toast.error(t('settings.passwordMismatch'))
try {
await authApi.changePassword({ current_password: currentPassword, new_password: newPassword })
toast.success(t('settings.passwordChanged'))
setCurrentPassword(''); setNewPassword(''); setConfirmPassword('')
await loadUser({ silent: true })
} catch (err: unknown) {
toast.error(getApiErrorMessage(err, t('common.error')))
}
}}
className="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors border border-edge bg-surface-card text-content-secondary"
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-card)'}
>
<Lock size={14} />
{t('settings.updatePassword')}
</button>
</div>
</EditorField>
</div>
<PasswordChecklist password={newPassword} />
<div className="flex justify-end" style={fs(13, 'body')}>
<button type="button"
onClick={async () => {
if (!currentPassword) return toast.error(t('settings.currentPasswordRequired'))
if (!newPassword) return toast.error(t('settings.passwordRequired'))
const weak = passwordErrorKey(newPassword)
if (weak) return toast.error(t(weak))
if (newPassword !== confirmPassword) return toast.error(t('settings.passwordMismatch'))
try {
await authApi.changePassword({ current_password: currentPassword, new_password: newPassword })
toast.success(t('settings.passwordChanged'))
setCurrentPassword(''); setNewPassword(''); setConfirmPassword('')
await loadUser({ silent: true })
} catch (err: unknown) {
toast.error(getApiErrorMessage(err, t('common.error')))
}
}}
className={SETTINGS_BUTTON}
>
<Lock size={14} />
{t('settings.updatePassword')}
</button>
</div>
</Section>
)}
{/* MFA */}
<Section title={t('settings.mfa.title')} icon={KeyRound}>
{mfaRequiredByPolicy && (
<div className="flex gap-3 rounded-[12px] bg-warning-soft px-3.5 py-3 text-content" style={fs(12.5, 'body')}>
<AlertTriangle size={16} className="mt-px flex-none text-warning" />
<p className="m-0 leading-relaxed">{t('settings.mfa.requiredByPolicy')}</p>
</div>
)}
{/* MFA */}
<div className="pt-4 mt-4 border-t border-edge-secondary">
<div className="flex items-center gap-2 mb-3">
<KeyRound className="w-5 h-5 text-content-secondary" />
<h3 className="font-semibold text-base m-0 text-content">{t('settings.mfa.title')}</h3>
</div>
<div className="space-y-3">
{mfaRequiredByPolicy && (
<div className="flex gap-3 p-3 rounded-lg border text-sm bg-surface-secondary border-edge text-content">
<AlertTriangle className="w-5 h-5 flex-shrink-0 text-amber-600" />
<p className="m-0 leading-relaxed">{t('settings.mfa.requiredByPolicy')}</p>
</div>
)}
<p className="text-sm m-0 text-content-muted" style={{ lineHeight: 1.5 }}>{t('settings.mfa.description')}</p>
{demoMode ? (
<p className="text-sm text-amber-700 m-0">{t('settings.mfa.demoBlocked')}</p>
) : (
<>
<p className="text-sm font-medium m-0 text-content-secondary">
{user?.mfa_enabled ? t('settings.mfa.enabled') : t('settings.mfa.disabled')}
</p>
{!user?.mfa_enabled && !mfaQr && (
<SettingsHint>{t('settings.mfa.description')}</SettingsHint>
{demoMode ? (
<p className="m-0 font-medium text-warning" style={fs(12.5, 'body')}>{t('settings.mfa.demoBlocked')}</p>
) : (
<>
<SettingRows>
<SettingRow
label={user?.mfa_enabled ? t('settings.mfa.enabled') : t('settings.mfa.disabled')}
control={!user?.mfa_enabled && !mfaQr ? (
<button
type="button"
disabled={mfaLoading}
@@ -275,30 +378,50 @@ export default function AccountTab(): React.ReactElement {
setMfaLoading(false)
}
}}
className="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors border border-edge bg-surface-card text-content"
className={SETTINGS_BUTTON}
style={fs(13, 'body')}
>
{mfaLoading ? <div className="w-4 h-4 border-2 border-slate-300 border-t-slate-700 rounded-full animate-spin" /> : <KeyRound size={14} />}
{mfaLoading ? <span className={SPINNER} /> : <KeyRound size={14} />}
{t('settings.mfa.setup')}
</button>
)}
) : user?.mfa_enabled ? (
<span className="grid h-8 w-8 place-items-center rounded-full bg-success-soft text-success">
<ShieldCheck size={16} strokeWidth={2.2} />
</span>
) : undefined}
/>
</SettingRows>
{!user?.mfa_enabled && mfaQr && (
<div className="space-y-3">
<p className="text-sm text-content-muted">{t('settings.mfa.scanQr')}</p>
<div className="rounded-lg border mx-auto block overflow-hidden border-edge" style={{ width: 'fit-content' }} dangerouslySetInnerHTML={{ __html: mfaQr! }} />
<div>
<label className="block text-xs font-medium mb-1 text-content-secondary">{t('settings.mfa.secretLabel')}</label>
<code className="block text-xs p-2 rounded break-all bg-surface-hover text-content">{mfaSecret}</code>
</div>
{!user?.mfa_enabled && mfaQr && (
<div className={PANEL}>
<SettingsHint>{t('settings.mfa.scanQr')}</SettingsHint>
<div className="flex flex-wrap items-start gap-4">
{/* A QR code needs its light quiet zone in either theme to scan. */}
<div
className="flex-none overflow-hidden rounded-[12px] border border-edge-faint p-1.5"
style={{ background: '#ffffff' }} // theme-lint-disable: a QR code needs a white quiet zone to scan
dangerouslySetInnerHTML={{ __html: mfaQr! }}
/>
<div className="flex min-w-0 flex-1 basis-56 flex-col gap-3">
<EditorField label={t('settings.mfa.secretLabel')}>
<code className="block break-all rounded-[10px] border border-edge-faint bg-surface-tertiary px-3 py-2 font-geist tracking-wide text-content" style={fs(12.5, 'body')}>{mfaSecret}</code>
</EditorField>
<input
type="text"
inputMode="numeric"
value={mfaSetupCode}
onChange={e => setMfaSetupCode(e.target.value.replace(/\D/g, '').slice(0, 8))}
placeholder={t('settings.mfa.codePlaceholder')}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
className={`${INPUT} font-geist tabular-nums tracking-[.2em]`}
/>
<div className="flex flex-wrap gap-2">
<div className="flex flex-wrap justify-end gap-2" style={fs(13, 'body')}>
<button
type="button"
onClick={() => { setMfaQr(null); setMfaSecret(null); setMfaSetupCode('') }}
className={SETTINGS_BUTTON}
>
{t('settings.mfa.cancelSetup')}
</button>
<button
type="button"
disabled={mfaLoading || mfaSetupCode.length < 6}
@@ -322,289 +445,151 @@ export default function AccountTab(): React.ReactElement {
setMfaLoading(false)
}
}}
className="px-4 py-2 bg-slate-900 text-white rounded-lg text-sm hover:bg-slate-700 disabled:opacity-50"
className={SETTINGS_BUTTON_PRIMARY}
>
{t('settings.mfa.enable')}
</button>
<button
type="button"
onClick={() => { setMfaQr(null); setMfaSecret(null); setMfaSetupCode('') }}
className="px-4 py-2 rounded-lg text-sm border border-edge text-content-secondary"
>
{t('settings.mfa.cancelSetup')}
</button>
</div>
</div>
)}
{user?.mfa_enabled && (
<div className="space-y-3">
<p className="text-sm font-medium text-content-secondary">{t('settings.mfa.disableTitle')}</p>
<p className="text-xs text-content-muted">{t('settings.mfa.disableHint')}</p>
<input
type="password"
value={mfaDisablePwd}
onChange={e => setMfaDisablePwd(e.target.value)}
placeholder={t('settings.currentPassword')}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
/>
<input
type="text"
inputMode="numeric"
value={mfaDisableCode}
onChange={e => setMfaDisableCode(e.target.value.replace(/\D/g, '').slice(0, 8))}
placeholder={t('settings.mfa.codePlaceholder')}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
/>
<button
type="button"
disabled={mfaLoading || !mfaDisablePwd || mfaDisableCode.length < 6}
onClick={async () => {
setMfaLoading(true)
try {
await authApi.mfaDisable({ password: mfaDisablePwd, code: mfaDisableCode })
toast.success(t('settings.mfa.toastDisabled'))
setMfaDisablePwd('')
setMfaDisableCode('')
sessionStorage.removeItem(MFA_BACKUP_SESSION_KEY)
setBackupCodes(null)
await loadUser({ silent: true })
} catch (err: unknown) {
toast.error(getApiErrorMessage(err, t('common.error')))
} finally {
setMfaLoading(false)
}
}}
className="px-4 py-2 rounded-lg text-sm font-medium text-red-600 border border-red-200 hover:bg-red-50 disabled:opacity-50"
>
{t('settings.mfa.disable')}
</button>
</div>
)}
{backupCodes && backupCodes.length > 0 && (
<div className="space-y-3 p-3 rounded-lg border border-edge bg-surface-hover">
<p className="text-sm font-semibold m-0 text-content">{t('settings.mfa.backupTitle')}</p>
<p className="text-xs m-0 text-content-muted">{t('settings.mfa.backupDescription')}</p>
<pre className="text-xs m-0 p-2 rounded border overflow-auto border-edge bg-surface-card text-content" style={{ maxHeight: 220 }}>{backupCodesText}</pre>
<p className="text-xs m-0 text-[#b45309]">{t('settings.mfa.backupWarning')}</p>
<div className="flex flex-wrap gap-2">
<button type="button" onClick={copyBackupCodes} className="px-3 py-2 rounded-lg text-xs border flex items-center gap-1.5 border-edge text-content-secondary">
<Copy size={13} /> {t('settings.mfa.backupCopy')}
</button>
<button type="button" onClick={downloadBackupCodes} className="px-3 py-2 rounded-lg text-xs border flex items-center gap-1.5 border-edge text-content-secondary">
<Download size={13} /> {t('settings.mfa.backupDownload')}
</button>
<button type="button" onClick={printBackupCodes} className="px-3 py-2 rounded-lg text-xs border flex items-center gap-1.5 border-edge text-content-secondary">
<Printer size={13} /> {t('settings.mfa.backupPrint')}
</button>
<button type="button" onClick={dismissBackupCodes} className="px-3 py-2 rounded-lg text-xs border border-edge text-content-secondary">
{t('common.ok')}
</button>
</div>
</div>
)}
</>
)}
</div>
</div>
{/* Passkeys */}
<PasskeysSection demoMode={demoMode} />
{/* Avatar */}
<div className="flex items-center gap-4">
<div style={{ position: 'relative', flexShrink: 0 }}>
{user?.avatar_url ? (
<img src={user.avatar_url} alt="" style={{ width: 64, height: 64, borderRadius: '50%', objectFit: 'cover' }} />
) : (
<div className="bg-surface-hover text-content-secondary" style={{
width: 64, height: 64, borderRadius: '50%',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 'calc(24px * var(--fs-scale-title, 1))', fontWeight: 700,
}}>
{user?.username?.charAt(0).toUpperCase()}
</div>
</div>
)}
<input ref={avatarInputRef} type="file" accept="image/*" onChange={handleAvatarUpload} style={{ display: 'none' }} />
<button type="button"
onClick={() => avatarInputRef.current?.click()}
style={{
position: 'absolute', bottom: -3, right: -3,
width: 28, height: 28, borderRadius: '50%',
background: 'var(--text-primary)', color: 'var(--bg-card)',
border: '2px solid var(--bg-card)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer', padding: 0, transition: 'transform 0.15s, opacity 0.15s',
}}
onMouseEnter={e => { e.currentTarget.style.transform = 'scale(1.15)'; e.currentTarget.style.opacity = '0.85' }}
onMouseLeave={e => { e.currentTarget.style.transform = 'scale(1)'; e.currentTarget.style.opacity = '1' }}
>
<Camera size={14} />
</button>
{user?.avatar_url && (
<button type="button"
onClick={handleAvatarRemove}
className="bg-[#ef4444] text-white"
style={{
position: 'absolute', top: -2, right: -2,
width: 20, height: 20, borderRadius: '50%',
border: '2px solid var(--bg-card)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer', padding: 0,
}}
>
<Trash2 size={10} />
</button>
)}
</div>
<div className="flex flex-col gap-1">
<div className="text-sm text-content-muted">
<span className="font-medium text-content-secondary" style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
{user?.role === 'admin' ? <><Shield size={13} /> {t('settings.roleAdmin')}</> : t('settings.roleUser')}
</span>
{(user as UserWithOidc)?.oidc_issuer && (
<span className="bg-[#dbeafe] text-[#1d4ed8]" style={{
display: 'inline-flex', alignItems: 'center', gap: 4,
fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 500, padding: '1px 8px', borderRadius: 99,
marginLeft: 6,
}}>
SSO
</span>
)}
</div>
{(user as UserWithOidc)?.oidc_issuer && (
<p className="text-content-faint" style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', marginTop: -2 }}>
{t('settings.oidcLinked')} {stripTrailingSlashes((user as UserWithOidc).oidc_issuer!.replace('https://', ''))}
</p>
)}
</div>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 12 }}>
<button type="button"
onClick={saveProfile}
disabled={saving}
className="flex items-center gap-2 px-4 py-2 bg-slate-900 text-white rounded-lg text-sm hover:bg-slate-700 disabled:bg-slate-400"
>
{saving ? <div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" /> : <Save className="w-4 h-4" />}
<span className="hidden sm:inline">{t('settings.saveProfile')}</span>
<span className="sm:hidden">{t('common.save')}</span>
</button>
<button type="button"
onClick={async () => {
if (user?.role === 'admin') {
try {
await adminApi.stats()
const adminUsers = (await adminApi.users()).users.filter((u: { role: string }) => u.role === 'admin')
if (adminUsers.length <= 1) {
setShowDeleteConfirm('blocked')
return
}
} catch {}
}
setShowDeleteConfirm(true)
}}
className="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors text-red-500 hover:bg-red-50 border border-[#fecaca]"
>
<Trash2 size={14} />
<span className="hidden sm:inline">{t('settings.deleteAccount')}</span>
<span className="sm:hidden">{t('common.delete')}</span>
</button>
</div>
{user?.mfa_enabled && (
<div className={PANEL}>
<div>
<p className={EYEBROW} style={fs(10)}>{t('settings.mfa.disableTitle')}</p>
<SettingsHint className="mt-1">{t('settings.mfa.disableHint')}</SettingsHint>
</div>
<div className={GRID_2}>
<input
type="password"
value={mfaDisablePwd}
onChange={e => setMfaDisablePwd(e.target.value)}
placeholder={t('settings.currentPassword')}
aria-label={t('settings.currentPassword')}
className={INPUT}
/>
<input
type="text"
inputMode="numeric"
value={mfaDisableCode}
onChange={e => setMfaDisableCode(e.target.value.replace(/\D/g, '').slice(0, 8))}
placeholder={t('settings.mfa.codePlaceholder')}
aria-label={t('settings.mfa.codePlaceholder')}
className={`${INPUT} font-geist tabular-nums`}
/>
</div>
<div className="flex justify-end" style={fs(13, 'body')}>
<button
type="button"
disabled={mfaLoading || !mfaDisablePwd || mfaDisableCode.length < 6}
onClick={async () => {
setMfaLoading(true)
try {
await authApi.mfaDisable({ password: mfaDisablePwd, code: mfaDisableCode })
toast.success(t('settings.mfa.toastDisabled'))
setMfaDisablePwd('')
setMfaDisableCode('')
sessionStorage.removeItem(MFA_BACKUP_SESSION_KEY)
setBackupCodes(null)
await loadUser({ silent: true })
} catch (err: unknown) {
toast.error(getApiErrorMessage(err, t('common.error')))
} finally {
setMfaLoading(false)
}
}}
className={SETTINGS_BUTTON_DANGER}
>
{t('settings.mfa.disable')}
</button>
</div>
</div>
)}
{backupCodes && backupCodes.length > 0 && (
<div className={PANEL}>
<div>
<p className="m-0 font-semibold text-content" style={fs(13, 'body')}>{t('settings.mfa.backupTitle')}</p>
<SettingsHint className="mt-0.5">{t('settings.mfa.backupDescription')}</SettingsHint>
</div>
<pre className="m-0 max-h-[220px] overflow-auto rounded-[12px] border border-edge-faint bg-surface-card px-3.5 py-3 font-geist tabular-nums leading-relaxed tracking-wide text-content" style={fs(12.5, 'body')}>{backupCodesText}</pre>
<p className="m-0 flex items-center gap-1.5 font-medium text-warning" style={fs(11.5)}>
<AlertTriangle size={13} className="flex-none" />{t('settings.mfa.backupWarning')}
</p>
<div className="flex flex-wrap items-center gap-2" style={fs(12.5, 'body')}>
<button type="button" onClick={copyBackupCodes} className={SETTINGS_BUTTON}>
<Copy size={13} /> {t('settings.mfa.backupCopy')}
</button>
<button type="button" onClick={downloadBackupCodes} className={SETTINGS_BUTTON}>
<Download size={13} /> {t('settings.mfa.backupDownload')}
</button>
<button type="button" onClick={printBackupCodes} className={SETTINGS_BUTTON}>
<Printer size={13} /> {t('settings.mfa.backupPrint')}
</button>
<span className="flex-1" />
<button type="button" onClick={dismissBackupCodes} className={SETTINGS_BUTTON_PRIMARY}>
{t('common.ok')}
</button>
</div>
</div>
)}
</>
)}
</Section>
{/* Passkeys */}
<PasskeysSection demoMode={demoMode} />
{/* Delete Account Blocked */}
{showDeleteConfirm === 'blocked' && (
<div className="bg-[rgba(0,0,0,0.5)]" style={{
position: 'fixed', inset: 0, zIndex: 9999,
backdropFilter: 'blur(4px)',
display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24,
}} role="presentation" onClick={e => { if (e.target === e.currentTarget) setShowDeleteConfirm(false) }}>
<div className="bg-surface-card" style={{
borderRadius: 16, padding: '28px 24px',
maxWidth: 400, width: '100%', boxShadow: '0 20px 60px rgba(0,0,0,0.3)',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 16 }}>
<div className="bg-[#fef3c7]" style={{ width: 36, height: 36, borderRadius: 10, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Shield size={18} className="text-[#d97706]" />
</div>
<h3 className="text-content" style={{ margin: 0, fontSize: 'calc(16px * var(--fs-scale-subtitle, 1))', fontWeight: 700 }}>{t('settings.deleteBlockedTitle')}</h3>
</div>
<p className="text-content-muted" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', lineHeight: 1.6, margin: '0 0 20px' }}>
{t('settings.deleteBlockedMessage')}
</p>
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<button type="button"
onClick={() => setShowDeleteConfirm(false)}
className="border border-edge bg-surface-card text-content-secondary"
style={{
padding: '8px 16px', borderRadius: 8, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500,
cursor: 'pointer', fontFamily: 'inherit',
}}
>
{t('common.ok') || 'OK'}
</button>
</div>
</div>
</div>
)}
<DialogShell
open={showDeleteConfirm === 'blocked'}
onClose={() => setShowDeleteConfirm(false)}
labelledBy={blockedLabelId}
width="narrow"
header={(
<DialogHeader
tile={<DialogTile><Shield size={20} strokeWidth={1.9} className="text-warning" /></DialogTile>}
tint={NEUTRAL_TINT}
labelId={blockedLabelId}
onClose={() => setShowDeleteConfirm(false)}
title={t('settings.deleteBlockedTitle')}
/>
)}
footer={(
<DialogFooter>
<FooterSpacer />
<DialogButton variant="primary" onClick={() => setShowDeleteConfirm(false)}>
{t('common.ok') || 'OK'}
</DialogButton>
</DialogFooter>
)}
>
<p className="m-0 leading-relaxed text-content-secondary" style={fs(13.5, 'body')}>
{t('settings.deleteBlockedMessage')}
</p>
</DialogShell>
{/* Delete Account Confirm */}
{showDeleteConfirm === true && (
<div className="bg-[rgba(0,0,0,0.5)]" style={{
position: 'fixed', inset: 0, zIndex: 9999,
backdropFilter: 'blur(4px)',
display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24,
}} role="presentation" onClick={e => { if (e.target === e.currentTarget) setShowDeleteConfirm(false) }}>
<div className="bg-surface-card" style={{
borderRadius: 16, padding: '28px 24px',
maxWidth: 400, width: '100%', boxShadow: '0 20px 60px rgba(0,0,0,0.3)',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 16 }}>
<div className="bg-[#fef2f2]" style={{ width: 36, height: 36, borderRadius: 10, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Trash2 size={18} className="text-[#ef4444]" />
</div>
<h3 className="text-content" style={{ margin: 0, fontSize: 'calc(16px * var(--fs-scale-subtitle, 1))', fontWeight: 700 }}>{t('settings.deleteAccountTitle')}</h3>
</div>
<p className="text-content-muted" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', lineHeight: 1.6, margin: '0 0 20px' }}>
{t('settings.deleteAccountWarning')}
</p>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<button type="button"
onClick={() => setShowDeleteConfirm(false)}
className="border border-edge bg-surface-card text-content-secondary"
style={{
padding: '8px 16px', borderRadius: 8, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500,
cursor: 'pointer', fontFamily: 'inherit',
}}
>
{t('common.cancel')}
</button>
<button type="button"
onClick={async () => {
try {
await authApi.deleteOwnAccount()
logout()
navigate('/login', { state: { noRedirect: true } })
} catch (err: unknown) {
toast.error(getApiErrorMessage(err, t('common.error')))
setShowDeleteConfirm(false)
}
}}
className="bg-[#ef4444] text-white"
style={{
padding: '8px 16px', borderRadius: 8, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600,
border: 'none',
cursor: 'pointer', fontFamily: 'inherit',
}}
>
{t('settings.deleteAccountConfirm')}
</button>
</div>
</div>
</div>
)}
<ConfirmDialog
isOpen={showDeleteConfirm === true}
onClose={() => setShowDeleteConfirm(false)}
onConfirm={async () => {
try {
await authApi.deleteOwnAccount()
logout()
navigate('/login', { state: { noRedirect: true } })
} catch (err: unknown) {
toast.error(getApiErrorMessage(err, t('common.error')))
setShowDeleteConfirm(false)
}
}}
title={t('settings.deleteAccountTitle')}
message={t('settings.deleteAccountWarning')}
confirmLabel={t('settings.deleteAccountConfirm')}
danger
/>
</>
)
}
@@ -31,11 +31,9 @@ function renderSection() {
);
}
// The desktop ToggleSwitch has no accessible name, so reach it through the label
// text it sits next to.
// Each switch carries its row's label as its accessible name.
function toggleNextTo(label: string): HTMLElement {
const row = screen.getByText(label).parentElement as HTMLElement;
return row.querySelector('button') as HTMLElement;
return screen.getByRole('button', { name: label });
}
beforeEach(() => {
@@ -1,17 +1,20 @@
import React, { useEffect, useState } from 'react'
import { Plane, Save } from 'lucide-react'
import { Loader2, Plane, Save } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { useToast } from '../shared/Toast'
import { airtrailApi } from '../../api/client'
import AirTrailIcon from '../shared/AirTrailIcon'
import Section from './Section'
import ToggleSwitch from './ToggleSwitch'
import { SETTINGS_BUTTON, SETTINGS_BUTTON_PRIMARY, SettingRow, SettingRows, SettingsHint, StatusPill } from './settingsKit'
import { EditorField, GRID_2, INPUT } from '../shared/dialogParts'
import { fs } from '../shared/DialogShell'
/**
* Settings → Integrations → AirTrail. Per-user connection to a self-hosted
* AirTrail instance (URL + Bearer API key). Mirrors the photo-provider (Immich)
* connection layout: stacked fields, a toggle, then Save / Test-connection with
* a status badge. The key is stored encrypted and never prefilled.
* connection layout: the two fields side by side, the switches as rows, then
* Save / Test-connection, with the connection state as a pill in the card's band. The key is stored encrypted and never prefilled.
*/
export default function AirTrailConnectionSection(): React.ReactElement {
const { t } = useTranslation()
@@ -55,8 +58,9 @@ export default function AirTrailConnectionSection(): React.ReactElement {
setApiKey('')
if (d?.warning) toast.warning(d.warning)
else toast.success(t('settings.airtrail.toast.saved'))
} catch (err: any) {
toast.error(err?.response?.data?.error || t('settings.airtrail.toast.saveError'))
} catch (err) {
const reason = (err as { response?: { data?: { error?: string } } } | null)?.response?.data?.error
toast.error(reason || t('settings.airtrail.toast.saveError'))
} finally {
setSaving(false)
}
@@ -78,81 +82,76 @@ export default function AirTrailConnectionSection(): React.ReactElement {
const canSave = !!url.trim() && (connected || !!apiKey.trim())
const fieldId = (name: string) => `airtrail-${name}`
return (
<Section title={t('settings.airtrail.title')} icon={AirTrailIcon}>
<div className="space-y-3">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.airtrail.url')}</label>
<Section
title={t('settings.airtrail.title')}
icon={AirTrailIcon}
badge={
<StatusPill tone={connected ? 'success' : 'neutral'} icon={<span className="h-1.5 w-1.5 rounded-full bg-current" />}>
{connected ? t('settings.airtrail.connected') : t('settings.airtrail.notConnected')}
</StatusPill>
}
>
<div className={GRID_2}>
<EditorField label={t('settings.airtrail.url')} htmlFor={fieldId('url')}>
<input
id={fieldId('url')}
type="url"
value={url}
onChange={e => setUrl(e.target.value)}
placeholder="https://airtrail.example.com"
className="w-full px-3 py-2.5 border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-slate-300"
className={INPUT}
/>
</div>
</EditorField>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.airtrail.apiKey')}</label>
<EditorField label={t('settings.airtrail.apiKey')} htmlFor={fieldId('key')} hint={t('settings.airtrail.apiKeyHint')}>
<input
id={fieldId('key')}
type="password"
value={apiKey}
onChange={e => setApiKey(e.target.value)}
autoComplete="off"
placeholder={connected && !apiKey ? '••••••••' : t('settings.airtrail.apiKeyPlaceholder')}
className="w-full px-3 py-2.5 border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-slate-300"
className={INPUT}
/>
<p className="mt-1 text-xs text-slate-500">{t('settings.airtrail.apiKeyHint')}</p>
</div>
<div className="flex items-center gap-3">
<ToggleSwitch on={allowInsecureTls} onToggle={() => setAllowInsecureTls(v => !v)} />
<span className="text-sm font-medium text-slate-700">{t('settings.airtrail.allowInsecureTls')}</span>
</div>
<div>
<div className="flex items-center gap-3">
<ToggleSwitch on={writeEnabled} onToggle={() => setWriteEnabled(v => !v)} />
<span className="text-sm font-medium text-slate-700">{t('settings.airtrail.writeBack')}</span>
</div>
<p className="mt-1 text-xs text-slate-500">{t('settings.airtrail.writeBackHint')}</p>
</div>
<div className="flex flex-wrap items-center gap-3">
<button type="button"
onClick={handleSave}
disabled={saving || loading || !canSave}
className="flex items-center gap-2 px-4 py-2 bg-slate-900 text-white rounded-lg text-sm hover:bg-slate-700 disabled:bg-slate-400"
>
<Save className="w-4 h-4" /> {t('common.save')}
</button>
<button type="button"
onClick={handleTest}
disabled={testing || loading || !url.trim()}
className="flex items-center gap-2 px-4 py-2 border border-slate-200 rounded-lg text-sm hover:bg-slate-50"
>
{testing ? (
<div className="w-4 h-4 border-2 border-slate-300 border-t-slate-700 rounded-full animate-spin" />
) : (
<Plane className="w-4 h-4" />
)}
{t('settings.airtrail.test.button')}
</button>
{connected ? (
<span className="basis-full sm:basis-auto text-xs font-medium text-green-600 flex items-center gap-1">
<span className="w-2 h-2 bg-green-500 rounded-full" />
{t('settings.airtrail.connected')}
</span>
) : (
<span className="basis-full sm:basis-auto text-xs font-medium text-slate-400 flex items-center gap-1">
<span className="w-2 h-2 bg-slate-300 rounded-full" />
{t('settings.airtrail.notConnected')}
</span>
)}
</div>
<p className="text-xs text-slate-500">{t('settings.airtrail.hint')}</p>
</EditorField>
</div>
<SettingRows>
<SettingRow
label={t('settings.airtrail.allowInsecureTls')}
control={<ToggleSwitch on={allowInsecureTls} onToggle={() => setAllowInsecureTls(v => !v)} label={t('settings.airtrail.allowInsecureTls')} />}
/>
<SettingRow
label={t('settings.airtrail.writeBack')}
hint={t('settings.airtrail.writeBackHint')}
control={<ToggleSwitch on={writeEnabled} onToggle={() => setWriteEnabled(v => !v)} label={t('settings.airtrail.writeBack')} />}
/>
</SettingRows>
<div className="flex flex-wrap items-center gap-2" style={fs(13, 'body')}>
<button type="button"
onClick={handleSave}
disabled={saving || loading || !canSave}
className={SETTINGS_BUTTON_PRIMARY}
>
<Save size={14} strokeWidth={2.2} /> {t('common.save')}
</button>
<button type="button"
onClick={handleTest}
disabled={testing || loading || !url.trim()}
className={SETTINGS_BUTTON}
>
{testing
? <Loader2 size={14} strokeWidth={2.2} className="animate-spin" />
: <Plane size={14} strokeWidth={2.2} />}
{t('settings.airtrail.test.button')}
</button>
</div>
<SettingsHint>{t('settings.airtrail.hint')}</SettingsHint>
</Section>
)
}
@@ -155,14 +155,14 @@ describe('ApiKeysSection', () => {
const user = userEvent.setup();
renderSection();
await user.click(await screen.findByTitle('Delete key'));
await user.click(await screen.findByRole('button', { name: 'Delete key' }));
expect(deleted).toBe(0);
expect(screen.getByText(/stops working immediately/)).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: /^Cancel$/ }));
expect(deleted).toBe(0);
await user.click(await screen.findByTitle('Delete key'));
await user.click(await screen.findByRole('button', { name: 'Delete key' }));
const [, confirm] = screen.getAllByRole('button', { name: /Delete key/ });
await user.click(confirm);
await waitFor(() => expect(deleted).toBe(1));
+126 -101
View File
@@ -1,12 +1,15 @@
import React, { useEffect, useRef, useState } from 'react'
import React, { useEffect, useId, useRef, useState } from 'react'
import {
KeyRound, Plus, Trash2, Copy, Check, AlertTriangle, Briefcase, CalendarDays, MapPin,
StickyNote, Ticket, Hotel, Users, Star, BarChart3, type LucideIcon,
} from 'lucide-react'
import { PUBLIC_API_SCOPES, type PublicApiScope } from '@trek/shared'
import Section from './Section'
import Modal from '../shared/Modal'
import ConfirmDialog from '../shared/ConfirmDialog'
import { Tooltip } from '../shared/Tooltip'
import { DialogButton, DialogFooter, DialogHeader, DialogShell, DialogTile, FooterSpacer, NEUTRAL_TINT, fs } from '../shared/DialogShell'
import { EditorField, GRID_2, INPUT, LABEL } from '../shared/dialogParts'
import { SETTINGS_BUTTON_PRIMARY, SETTINGS_ICON_BUTTON, SettingsHint, StatusPill } from './settingsKit'
import { useTranslation } from '../../i18n'
import { useToast } from '../shared/Toast'
import { authApi } from '../../api/client'
@@ -69,7 +72,7 @@ function withCode(text: string): React.ReactNode[] {
return text.split(/("[^"]+"|\/api\/v1)/).map((part, i) => {
if (i % 2 === 0) return part
return (
<code key={i} className="rounded px-1 py-px font-mono bg-surface-hover text-content-secondary">
<code key={i} className="rounded-[5px] bg-surface-tertiary px-1 py-px font-geist text-content-secondary">
{part.replace(/^"|"$/g, '')}
</code>
)
@@ -79,6 +82,7 @@ function withCode(text: string): React.ReactNode[] {
export default function ApiKeysSection(): React.ReactElement {
const { t, locale } = useTranslation()
const toast = useToast()
const dialogLabelId = useId()
const [keys, setKeys] = useState<ApiKey[]>([])
/** A failed load must not read as "you have no keys": that sends people off minting duplicates. */
const [loadState, setLoadState] = useState<'loading' | 'ready' | 'failed'>('loading')
@@ -201,144 +205,166 @@ export default function ApiKeysSection(): React.ReactElement {
})
}
const header = (
<DialogHeader
tile={<DialogTile><KeyRound size={20} strokeWidth={1.9} className="text-content-muted" /></DialogTile>}
tint={NEUTRAL_TINT}
labelId={dialogLabelId}
// Once the key is on screen the cross does what Done does: it is a deliberate
// click, unlike the Escape and backdrop the shell ignores below.
onClose={closeModal}
title={created ? t('settings.apiKeys.modal.createdTitle') : t('settings.apiKeys.modal.createTitle')}
/>
)
const footer = created ? (
<DialogFooter>
<FooterSpacer />
<DialogButton variant="primary" onClick={closeModal}>{t('settings.apiKeys.modal.done')}</DialogButton>
</DialogFooter>
) : (
<DialogFooter>
<FooterSpacer />
<DialogButton onClick={closeModal}>{t('common.cancel')}</DialogButton>
<DialogButton variant="primary" onClick={handleCreate} disabled={!canCreate}
icon={creating ? <span aria-hidden className="h-3.5 w-3.5 animate-spin rounded-full border-2 border-current border-t-transparent" /> : undefined}>
{creating ? t('settings.apiKeys.modal.creating') : t('settings.apiKeys.modal.create')}
</DialogButton>
</DialogFooter>
)
return (
<>
<Section title={t('settings.apiKeys.title')} icon={KeyRound}>
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-6">
<p className="text-body text-content-secondary">{t('settings.apiKeys.description')}</p>
<button type="button" onClick={openModal} disabled={atLimit}
className="inline-flex flex-shrink-0 items-center gap-1.5 self-start rounded-lg px-3.5 py-2 text-body font-medium transition-colors bg-accent text-accent-text hover:bg-accent-hover disabled:cursor-not-allowed disabled:opacity-50">
<Plus className="w-4 h-4" /> {t('settings.apiKeys.create')}
<Section
title={t('settings.apiKeys.title')}
icon={KeyRound}
badge={keys.length > 0 ? <StatusPill>{keys.length}/{MAX_KEYS}</StatusPill> : undefined}
action={(
<button type="button" onClick={openModal} disabled={atLimit} className={SETTINGS_BUTTON_PRIMARY} style={fs(12.5, 'body')}>
<Plus size={14} /> {t('settings.apiKeys.create')}
</button>
</div>
)}
>
<SettingsHint>{t('settings.apiKeys.description')}</SettingsHint>
{atLimit && (
<p className="text-caption text-content-muted">{t('settings.apiKeys.limitReached', { max: MAX_KEYS })}</p>
<div className="flex gap-2.5 rounded-[12px] bg-warning-soft px-3.5 py-2.5 text-content" style={fs(12.5, 'body')}>
<AlertTriangle size={15} className="mt-px flex-none text-warning" />
<p className="m-0 leading-snug">{t('settings.apiKeys.limitReached', { max: MAX_KEYS })}</p>
</div>
)}
{loadState === 'loading' && (
<div aria-hidden className="flex flex-col gap-2">
<div className="h-[62px] animate-pulse rounded-[12px] bg-surface-tertiary" />
</div>
)}
{loadState === 'failed' && (
<p role="alert" className="rounded-lg px-3 py-2.5 text-caption bg-danger-soft text-danger">
<p role="alert" className="m-0 rounded-[12px] bg-danger-soft px-3.5 py-2.5 text-danger" style={fs(12.5, 'body')}>
{t('settings.apiKeys.loadFailed')}
</p>
)}
{loadState === 'ready' && keys.length === 0 && (
<div className="flex flex-col items-center gap-2.5 rounded-lg border border-dashed px-4 py-6 text-center border-edge">
<span className="flex h-10 w-10 items-center justify-center rounded-full bg-surface-hover">
<KeyRound className="w-5 h-5 text-content-muted" />
<div className="flex flex-col items-center gap-2.5 rounded-[12px] border border-dashed border-edge bg-surface-card px-4 py-6 text-center">
<span className="grid h-10 w-10 place-items-center rounded-[12px] bg-surface-tertiary text-content-muted">
<KeyRound size={18} strokeWidth={1.9} />
</span>
<p className="text-body text-content-muted">{t('settings.apiKeys.empty')}</p>
<p className="m-0 text-content-muted" style={fs(12.5, 'body')}>{t('settings.apiKeys.empty')}</p>
</div>
)}
{keys.length > 0 && (
<ul className="m-0 list-none space-y-2 p-0">
<ul className="m-0 list-none divide-y divide-edge-faint overflow-hidden rounded-[12px] border border-edge-faint bg-surface-card p-0">
{keys.map(key => (
<ApiKeyRow key={key.id} apiKey={key} locale={locale} onDelete={() => setDeleteId(key.id)} />
))}
</ul>
)}
<div className="space-y-2 border-t pt-4 border-edge-secondary">
<span className="block text-caption font-medium text-content-secondary">{t('settings.apiKeys.endpoint')}</span>
<div className="flex flex-col gap-2 border-t border-edge-faint pt-4">
<span className={LABEL + ' !mb-0'}>{t('settings.apiKeys.endpoint')}</span>
<div className="flex items-center gap-2">
<code className="min-w-0 flex-1 truncate rounded-lg border px-3 py-2 font-mono text-caption border-edge bg-surface-secondary text-content">
<code className="block min-w-0 flex-1 truncate rounded-[10px] border border-edge-faint bg-surface-card px-3 py-2 font-geist text-content" style={fs(12.5, 'body')}>
{endpoint}
</code>
<button type="button" onClick={() => handleCopy(endpoint, 'endpoint')}
className="flex-shrink-0 rounded-lg border p-2 transition-colors border-edge text-content-secondary hover:bg-surface-hover"
title={t('common.copy')} aria-label={t('common.copy')}>
{copied === 'endpoint' ? <Check className="w-4 h-4 text-success" /> : <Copy className="w-4 h-4" />}
</button>
<Tooltip label={t('common.copy')}>
<button type="button" onClick={() => handleCopy(endpoint, 'endpoint')}
className={`${SETTINGS_ICON_BUTTON} !h-9 !w-9`}
aria-label={t('common.copy')}>
{copied === 'endpoint' ? <Check size={15} className="text-success" /> : <Copy size={15} />}
</button>
</Tooltip>
</div>
<p className="text-caption text-content-muted">{withCode(t('settings.apiKeys.docsHint'))}</p>
<SettingsHint>{withCode(t('settings.apiKeys.docsHint'))}</SettingsHint>
</div>
</Section>
<Modal
isOpen={modalOpen}
<DialogShell
open={modalOpen}
// Once the key is on screen, Done is the only way out: a stray Escape or
// backdrop click would throw away the one copy there will ever be.
onClose={created ? () => {} : closeModal}
hideCloseButton={!!created}
size="lg"
title={created ? t('settings.apiKeys.modal.createdTitle') : t('settings.apiKeys.modal.createTitle')}
footer={created ? (
<div className="flex justify-end">
<button type="button" onClick={closeModal}
className="rounded-lg px-4 py-2 text-body font-medium transition-colors bg-accent text-accent-text hover:bg-accent-hover">
{t('settings.apiKeys.modal.done')}
</button>
</div>
) : (
<div className="flex justify-end gap-2">
<button type="button" onClick={closeModal}
className="rounded-lg border px-4 py-2 text-body font-medium transition-colors border-edge text-content-secondary hover:bg-surface-hover">
{t('common.cancel')}
</button>
<button type="button" onClick={handleCreate} disabled={!canCreate}
className="inline-flex items-center gap-2 rounded-lg px-4 py-2 text-body font-medium transition-colors bg-accent text-accent-text hover:bg-accent-hover disabled:cursor-not-allowed disabled:opacity-50">
{creating && <span aria-hidden className="h-3.5 w-3.5 animate-spin rounded-full border-2 border-current border-t-transparent" />}
{creating ? t('settings.apiKeys.modal.creating') : t('settings.apiKeys.modal.create')}
</button>
</div>
)}
blocked={!!created}
labelledBy={dialogLabelId}
width="detail"
header={header}
footer={footer}
>
{created ? (
<div className="space-y-4">
<div className="flex items-start gap-2.5 rounded-lg p-3 bg-warning-soft">
<AlertTriangle className="mt-0.5 h-4 w-4 flex-shrink-0 text-warning" />
<p className="text-caption text-content">{t('settings.apiKeys.modal.createdWarning')}</p>
<>
<div className="flex items-start gap-2.5 rounded-[12px] bg-warning-soft px-3.5 py-3">
<AlertTriangle size={15} className="mt-px flex-none text-warning" />
<p className="m-0 text-content" style={fs(12.5, 'body')}>{t('settings.apiKeys.modal.createdWarning')}</p>
</div>
<div className="flex items-stretch overflow-hidden rounded-lg border border-edge bg-surface-secondary">
<code className="min-w-0 flex-1 select-all break-all px-3 py-2.5 font-mono text-caption text-content">{created}</code>
<div className="flex items-stretch overflow-hidden rounded-[12px] border border-edge-faint bg-surface-secondary">
<code className="min-w-0 flex-1 select-all break-all px-3.5 py-3 font-geist text-content" style={fs(12.5, 'body')}>{created}</code>
<button type="button" onClick={() => handleCopy(created, 'key')} title={t('settings.apiKeys.copy')}
className="flex flex-shrink-0 items-center gap-1.5 border-l px-3 text-caption font-medium transition-colors border-edge text-content-secondary hover:bg-surface-hover">
{copied === 'key' ? <Check className="w-3.5 h-3.5 text-success" /> : <Copy className="w-3.5 h-3.5" />}
className="flex flex-none items-center gap-1.5 border-l border-edge-faint bg-surface-card px-3.5 font-medium text-content-secondary transition-colors hover:text-content"
style={fs(12.5, 'body')}>
{copied === 'key' ? <Check size={14} className="text-success" /> : <Copy size={14} />}
{copied === 'key' ? t('common.copied') : t('settings.apiKeys.copy')}
</button>
</div>
</div>
</>
) : (
<div className="space-y-6">
<div>
<label htmlFor="api-key-name" className="mb-1.5 block text-caption font-medium text-content-secondary">
{t('settings.apiKeys.modal.name')}
</label>
<>
<EditorField label={t('settings.apiKeys.modal.name')} htmlFor="api-key-name" hint={t('settings.apiKeys.modal.nameHint')}>
<input id="api-key-name" type="text" value={newName} onChange={e => setNewName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') void handleCreate() }}
placeholder={t('settings.apiKeys.modal.namePlaceholder')}
maxLength={100}
className="w-full rounded-lg border px-3 py-2.5 text-body focus:outline-none focus:ring-2 ring-accent border-edge bg-surface-input text-content"
className={INPUT}
autoFocus />
<p className="mt-1.5 text-caption text-content-muted">{t('settings.apiKeys.modal.nameHint')}</p>
</div>
</EditorField>
<div role="group" aria-labelledby="api-key-scopes-title">
<div className="mb-1 flex items-baseline justify-between gap-3">
<span id="api-key-scopes-title" className="text-caption font-medium text-content-secondary">
<section role="group" aria-labelledby="api-key-scopes-title">
<div className="mb-2 flex items-center gap-2">
<span id="api-key-scopes-title" className="font-geist font-bold uppercase tracking-[.08em] text-content-faint" style={fs(9.5)}>
{t('settings.apiScopes.title')}
</span>
<button type="button"
onClick={() => setNewScopes(allSelected ? new Set() : new Set(PUBLIC_API_SCOPES))}
className="flex-shrink-0 text-caption font-medium transition-colors text-content-muted hover:text-content">
className="ml-auto flex-none rounded-full px-2 py-0.5 font-semibold text-content-muted transition-colors hover:bg-surface-tertiary hover:text-content"
style={fs(11.5, 'body')}>
{allSelected ? t('common.deselectAll') : t('common.selectAll')}
</button>
</div>
<p className="mb-3 text-caption text-content-muted">{t('settings.apiScopes.hint')}</p>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
<SettingsHint className="mb-3">{t('settings.apiScopes.hint')}</SettingsHint>
<div className={GRID_2}>
{PUBLIC_API_SCOPES.map(scope => (
<ScopeOption key={scope} icon={SCOPE_ICONS[scope]} label={t(`settings.apiScopes.${scope}`)}
checked={newScopes.has(scope)} onToggle={() => toggleScope(scope)} />
))}
</div>
{newScopes.size === 0 && (
<p className="mt-2 text-caption text-danger">{t('settings.apiScopes.noneSelected')}</p>
<p className="m-0 mt-2 text-danger" style={fs(11.5)}>{t('settings.apiScopes.noneSelected')}</p>
)}
</div>
</div>
</section>
</>
)}
</Modal>
</DialogShell>
<ConfirmDialog
isOpen={deleteId !== null}
@@ -356,18 +382,18 @@ function ApiKeyRow({ apiKey, locale, onDelete }: { apiKey: ApiKey; locale: strin
const { t } = useTranslation()
const scopes = apiKey.scope_mode === 'limited' ? apiKey.scopes : undefined
return (
<li className="flex items-start gap-3 rounded-lg border p-3 border-edge">
<span className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-lg bg-surface-hover">
<KeyRound className="w-4 h-4 text-content-secondary" />
<li className="flex items-start gap-3 px-3.5 py-3">
<span className="grid h-9 w-9 flex-none place-items-center rounded-[10px] bg-surface-tertiary text-content-secondary">
<KeyRound size={16} strokeWidth={1.9} />
</span>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
<span className="truncate text-body font-medium text-content">{apiKey.name}</span>
<code className="rounded px-1.5 py-0.5 font-mono text-caption bg-surface-hover text-content-muted">
<span className="min-w-0 max-w-full truncate font-semibold text-content" style={fs(13, 'body')}>{apiKey.name}</span>
<code className="rounded-full border border-edge-faint bg-surface-secondary px-2 py-[1px] font-geist text-content-muted" style={fs(11)}>
{apiKey.token_prefix}…
</code>
</div>
<p className="mt-0.5 text-caption text-content-faint first-letter:uppercase">
<p className="m-0 mt-0.5 tabular-nums text-content-faint first-letter:uppercase" style={fs(11.5)}>
{t('settings.apiKeys.createdAt')} {formatStamp(apiKey.created_at, locale)}
{' · '}
{apiKey.last_used_at
@@ -381,23 +407,22 @@ function ApiKeyRow({ apiKey, locale, onDelete }: { apiKey: ApiKey; locale: strin
{scopes ? scopes.map(scope => {
const Icon = SCOPE_ICONS[scope]
return (
<span key={scope} className="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-caption bg-surface-hover text-content-secondary">
{Icon && <Icon className="w-3 h-3" />}
<StatusPill key={scope} icon={Icon ? <Icon size={11} strokeWidth={2.2} /> : undefined}>
{t(`settings.apiScopes.${scope}`)}
</span>
</StatusPill>
)
}) : (
<span className="inline-flex items-center rounded-full px-2 py-0.5 text-caption bg-surface-hover text-content-secondary">
{t('settings.apiScopes.all')}
</span>
<StatusPill tone="success">{t('settings.apiScopes.all')}</StatusPill>
)}
</div>
</div>
<button type="button" onClick={onDelete}
className="flex-shrink-0 rounded-lg p-1.5 transition-colors text-content-faint hover:bg-danger-soft hover:text-danger"
title={t('settings.apiKeys.deleteTitle')}>
<Trash2 className="w-4 h-4" />
</button>
<Tooltip label={t('settings.apiKeys.deleteTitle')}>
<button type="button" onClick={onDelete}
className={`${SETTINGS_ICON_BUTTON} hover:!bg-danger-soft hover:!text-danger`}
aria-label={t('settings.apiKeys.deleteTitle')}>
<Trash2 size={14} />
</button>
</Tooltip>
</li>
)
}
@@ -414,17 +439,17 @@ function ScopeOption({ icon: Icon, label, checked, onToggle }: {
}): React.ReactElement {
return (
<button type="button" role="checkbox" aria-checked={checked} onClick={onToggle}
className={`flex w-full items-center gap-2.5 rounded-lg border px-3 py-2.5 text-left transition-colors border-edge ${
checked ? 'bg-accent-subtle' : 'hover:bg-surface-hover'
className={`flex w-full min-w-0 items-center gap-2.5 rounded-[10px] border px-3 py-2.5 text-left transition-colors ${
checked ? 'border-[color:var(--text-primary)] bg-surface-card shadow-sm' : 'border-edge-faint bg-surface-secondary hover:bg-surface-card'
}`}>
<span aria-hidden
className={`flex h-4 w-4 flex-shrink-0 items-center justify-center rounded transition-colors ${
className={`grid h-4 w-4 flex-none place-items-center rounded-[5px] transition-colors ${
checked ? 'bg-accent' : 'border-[1.5px] border-edge'
}`}>
{checked && <Check className="w-2.5 h-2.5 text-accent-text" strokeWidth={3} />}
{checked && <Check size={10} strokeWidth={3} className="text-accent-text" />}
</span>
<Icon aria-hidden className={`w-4 h-4 flex-shrink-0 ${checked ? 'text-content-secondary' : 'text-content-faint'}`} />
<span className={`min-w-0 truncate text-body ${checked ? 'text-content' : 'text-content-muted'}`}>{label}</span>
<Icon aria-hidden size={15} className={`flex-none ${checked ? 'text-content-secondary' : 'text-content-faint'}`} />
<span className={`min-w-0 truncate font-medium ${checked ? 'text-content' : 'text-content-muted'}`} style={fs(12.5, 'body')}>{label}</span>
</button>
)
}
@@ -1,10 +1,13 @@
import React, { useEffect, useRef, useState } from 'react'
import React, { useEffect, useRef, useState, type ReactNode } from 'react'
import { Paintbrush, Eye, LayoutDashboard, Sun, Moon, Monitor, RotateCcw } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { useSettingsStore } from '../../store/settingsStore'
import { useToast } from '../shared/Toast'
import { DialogSection, fs } from '../shared/DialogShell'
import { Segmented } from '../shared/dialogParts'
import Section from './Section'
import ToggleSwitch from './ToggleSwitch'
import { SETTINGS_BUTTON, SettingRow, SettingRows, StatusPill } from './settingsKit'
import { applyAppearance } from '../../theme/applyAppearance'
import { APPEARANCE_SCHEMES, CUSTOM_ACCENT_PRESETS } from '../../theme/schemes'
import {
@@ -61,17 +64,14 @@ const MOBILE_GROUPS: { id: string; fallback: string; keys: MobileWidgetKey[] }[]
{ id: 'bottomOfPage', fallback: 'Bottom of page', keys: ['currency', 'collections', 'timezones', 'upcomingReservations'] },
]
// shared segmented-button style (matches DisplaySettingsTab)
function segStyle(active: boolean): React.CSSProperties {
return {
display: 'flex', alignItems: 'center', gap: 6, justifyContent: 'center',
padding: '10px 14px', borderRadius: 10, cursor: 'pointer', flex: '1 1 0', minWidth: 0,
fontFamily: 'inherit', fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 500,
border: active ? '2px solid var(--text-primary)' : '2px solid var(--border-primary)',
background: active ? 'var(--bg-hover)' : 'var(--bg-card)',
color: 'var(--text-primary)', transition: 'all 0.15s',
}
}
/** A tile of the scheme grid: a colour dot and a name, raised and outlined while chosen. */
const SWATCH = 'flex min-w-0 items-center gap-2 rounded-[12px] border bg-surface-card px-3 py-2.5 text-left font-medium text-content transition-colors'
const swatchLook = (active: boolean) => active
? 'border-[color:var(--text-primary)] shadow-sm'
: 'border-edge hover:border-content-faint'
/** The round colour sample in a swatch tile. */
const DOT = 'h-4 w-4 flex-none rounded-full shadow-[inset_0_0_0_1px_var(--border-faint)]'
export default function AppearanceSettingsTab(): React.ReactElement {
const { settings, updateSetting } = useSettingsStore()
@@ -141,284 +141,242 @@ export default function AppearanceSettingsTab(): React.ReactElement {
const accentDark = cfg.accent?.dark ?? '#6366f1'
const customRatio = contrastRatio(isDark ? accentDark : accentLight, '#ffffff')
// The stored mode, read the way the old boolean values meant it.
const cur = settings.dark_mode
const mode = cur === true ? 'dark' : cur === false ? 'light' : String(cur ?? '')
return (
<>
{/* ── Theme ───────────────────────────────────────────────── */}
<Section title={tr('settings.appearance.theme', 'Theme')} icon={Paintbrush}>
{/* Color mode */}
<div>
<label className="block text-sm font-medium mb-2 text-content-secondary">
{tr('settings.colorMode', 'Color mode')}
</label>
<div className="flex gap-3" style={{ flexWrap: 'wrap' }}>
{[
{ value: 'light', label: tr('settings.light', 'Light'), icon: Sun },
{ value: 'dark', label: tr('settings.dark', 'Dark'), icon: Moon },
{ value: 'auto', label: tr('settings.auto', 'Auto'), icon: Monitor },
].map((opt) => {
const cur = settings.dark_mode
const active =
cur === opt.value ||
(opt.value === 'light' && cur === false) ||
(opt.value === 'dark' && cur === true)
return (
<button type="button" key={opt.value} onClick={() => setMode(opt.value)} style={segStyle(active)}>
<span className="hidden sm:inline-flex"><opt.icon size={16} /></span>
{opt.value === 'auto' ? (
<>
<span className="hidden sm:inline">{opt.label}</span>
<span className="sm:hidden">Auto</span>
</>
) : opt.label}
</button>
)
})}
</div>
</div>
<SettingRows>
{/* Color mode */}
<SettingRow
label={tr('settings.colorMode', 'Color mode')}
control={
<Segmented
label={tr('settings.colorMode', 'Color mode')}
value={mode}
onChange={setMode}
options={[
{ value: 'light', label: tr('settings.light', 'Light'), icon: <Sun size={14} strokeWidth={2} /> },
{ value: 'dark', label: tr('settings.dark', 'Dark'), icon: <Moon size={14} strokeWidth={2} /> },
{
value: 'auto',
icon: <Monitor size={14} strokeWidth={2} />,
label: (
<>
<span className="hidden sm:inline">{tr('settings.auto', 'Auto')}</span>
<span className="sm:hidden">Auto</span>
</>
),
},
]}
/>
}
/>
{/* Color scheme swatches */}
<div>
<label className="block text-sm font-medium mb-2 text-content-secondary">
{tr('settings.appearance.scheme', 'Color scheme')}
</label>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
{APPEARANCE_SCHEMES.map((s) => {
const active = cfg.schemeId === s.id
const dot = isDark ? s.swatch.dark : s.swatch.light
return (
<button type="button"
key={s.id}
onClick={() => update({ schemeId: s.id })}
style={{
display: 'flex', alignItems: 'center', gap: 8, padding: '10px 12px',
borderRadius: 10, cursor: 'pointer', fontFamily: 'inherit', fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500,
border: active ? '2px solid var(--text-primary)' : '2px solid var(--border-primary)',
background: active ? 'var(--bg-hover)' : 'var(--bg-card)', color: 'var(--text-primary)',
transition: 'all 0.15s',
}}
>
<span style={{ width: 16, height: 16, borderRadius: '50%', background: dot, flexShrink: 0, boxShadow: 'inset 0 0 0 1px var(--border-faint)' }} />
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{tr(`settings.appearance.scheme.${s.id}`, schemeFallback(s.id))}
</span>
</button>
)
})}
{/* Custom */}
<button type="button"
onClick={() => update({ schemeId: 'custom', accent: cfg.accent ?? { light: accentLight, dark: accentDark } })}
style={{
display: 'flex', alignItems: 'center', gap: 8, padding: '10px 12px', borderRadius: 10,
cursor: 'pointer', fontFamily: 'inherit', fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500,
border: cfg.schemeId === 'custom' ? '2px solid var(--text-primary)' : '2px solid var(--border-primary)',
background: cfg.schemeId === 'custom' ? 'var(--bg-hover)' : 'var(--bg-card)', color: 'var(--text-primary)',
transition: 'all 0.15s',
}}
>
<span style={{ width: 16, height: 16, borderRadius: '50%', flexShrink: 0, background: 'conic-gradient(#ef4444,#f59e0b,#22c55e,#3b82f6,#8b5cf6,#ef4444)' }} />
{tr('settings.appearance.scheme.custom', 'Custom')}
</button>
</div>
</div>
{/* Color scheme swatches */}
<SettingRow label={tr('settings.appearance.scheme', 'Color scheme')} stacked>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
{APPEARANCE_SCHEMES.map((s) => {
const active = cfg.schemeId === s.id
const dot = isDark ? s.swatch.dark : s.swatch.light
return (
<button type="button" key={s.id} aria-pressed={active}
onClick={() => update({ schemeId: s.id })}
className={`${SWATCH} ${swatchLook(active)}`} style={fs(12.5, 'body')}>
<span className={DOT} style={{ background: dot }} />
<span className="min-w-0 truncate">{tr(`settings.appearance.scheme.${s.id}`, schemeFallback(s.id))}</span>
</button>
)
})}
{/* Custom */}
<button type="button" aria-pressed={cfg.schemeId === 'custom'}
onClick={() => update({ schemeId: 'custom', accent: cfg.accent ?? { light: accentLight, dark: accentDark } })}
className={`${SWATCH} ${swatchLook(cfg.schemeId === 'custom')}`} style={fs(12.5, 'body')}>
<span className={DOT} style={{ background: 'conic-gradient(#ef4444,#f59e0b,#22c55e,#3b82f6,#8b5cf6,#ef4444)' }} /> {/* theme-lint-disable: the rainbow that stands for "any colour" */}
<span className="min-w-0 truncate">{tr('settings.appearance.scheme.custom', 'Custom')}</span>
</button>
</div>
</SettingRow>
{/* Custom accent picker */}
{cfg.schemeId === 'custom' && (
<div>
<label className="block text-sm font-medium mb-2 text-content-secondary">
{tr('settings.appearance.customAccent', 'Custom accent')}
</label>
<div className="flex flex-wrap gap-2 mb-3">
{CUSTOM_ACCENT_PRESETS.map((c) => (
<button type="button"
key={c}
aria-label={c}
onClick={() => update({ accent: { light: c, dark: c } })}
style={{ width: 28, height: 28, borderRadius: '50%', background: c, cursor: 'pointer', border: '2px solid var(--border-primary)' }}
/>
))}
</div>
<div className="flex flex-wrap gap-4 items-center">
<label className="flex items-center gap-2 text-sm text-content-secondary">
{tr('settings.light', 'Light')}
<input type="color" value={isHex(accentLight) ? accentLight : '#4f46e5'}
onChange={(e) => update({ accent: { light: e.target.value, dark: accentDark } })}
style={{ width: 36, height: 28, border: 'none', background: 'none', cursor: 'pointer' }} />
</label>
<label className="flex items-center gap-2 text-sm text-content-secondary">
{tr('settings.dark', 'Dark')}
<input type="color" value={isHex(accentDark) ? accentDark : '#6366f1'}
onChange={(e) => update({ accent: { light: accentLight, dark: e.target.value } })}
style={{ width: 36, height: 28, border: 'none', background: 'none', cursor: 'pointer' }} />
</label>
<span
className="text-xs font-medium px-2 py-1 rounded-md"
style={{ background: customRatio >= 4.5 ? 'var(--success-soft)' : 'var(--warning-soft)', color: customRatio >= 4.5 ? 'var(--success)' : 'var(--warning)' }}
>
{customRatio >= 4.5
? `${tr('settings.appearance.contrastOk', 'Good contrast')} (${customRatio.toFixed(1)}:1)`
: `${tr('settings.appearance.contrastLow', 'Low contrast')} (${customRatio.toFixed(1)}:1)`}
</span>
</div>
</div>
)}
{/* Custom accent picker */}
{cfg.schemeId === 'custom' && (
<SettingRow label={tr('settings.appearance.customAccent', 'Custom accent')} stacked>
<div className="flex flex-col gap-3">
<div className="flex flex-wrap gap-2">
{CUSTOM_ACCENT_PRESETS.map((c) => {
const picked = accentLight === c && accentDark === c
return (
<button type="button" key={c} aria-label={c}
onClick={() => update({ accent: { light: c, dark: c } })}
className={`h-7 w-7 flex-none rounded-full ring-offset-2 ring-offset-[color:var(--bg-card)] transition-shadow ${picked ? 'ring-2 ring-[color:var(--text-primary)]' : 'shadow-[inset_0_0_0_1px_var(--border-faint)] hover:ring-2 hover:ring-edge'}`}
style={{ background: c }} />
)
})}
</div>
<div className="flex flex-wrap items-center gap-3">
<ColorField label={tr('settings.light', 'Light')} value={isHex(accentLight) ? accentLight : '#4f46e5'}
onChange={(v) => update({ accent: { light: v, dark: accentDark } })} />
<ColorField label={tr('settings.dark', 'Dark')} value={isHex(accentDark) ? accentDark : '#6366f1'}
onChange={(v) => update({ accent: { light: accentLight, dark: v } })} />
<StatusPill tone={customRatio >= 4.5 ? 'success' : 'warning'}>
{customRatio >= 4.5
? `${tr('settings.appearance.contrastOk', 'Good contrast')} (${customRatio.toFixed(1)}:1)`
: `${tr('settings.appearance.contrastLow', 'Low contrast')} (${customRatio.toFixed(1)}:1)`}
</StatusPill>
</div>
</div>
</SettingRow>
)}
</SettingRows>
</Section>
{/* ── Readability ─────────────────────────────────────────── */}
<Section
title={tr('settings.appearance.readability', 'Readability')}
icon={Eye}
badge={
<span className="text-[10px] font-semibold uppercase tracking-wide px-2 py-0.5 rounded-full bg-warning-soft text-warning">
{tr('settings.appearance.experimental', 'Experimental')}
</span>
}
badge={<StatusPill tone="warning">{tr('settings.appearance.experimental', 'Experimental')}</StatusPill>}
>
<ToggleRow
label={tr('settings.appearance.transparency', 'Transparency')}
hint={tr('settings.appearance.transparencyHint', 'Glassy translucent surfaces. Turn off for solid, higher-contrast backgrounds.')}
on={cfg.transparency}
onToggle={() => update({ transparency: !cfg.transparency })}
/>
<ToggleRow
label={tr('settings.appearance.reduceMotion', 'Reduce motion')}
hint={tr('settings.appearance.reduceMotionHint', 'Minimize animations and transitions.')}
on={cfg.reduceMotion}
onToggle={() => update({ reduceMotion: !cfg.reduceMotion })}
/>
{/* Density */}
<div>
<label className="block text-sm font-medium mb-2 text-content-secondary">
{tr('settings.appearance.density', 'Density')}
</label>
<div className="flex gap-3">
{[
{ value: 'comfortable', label: tr('settings.appearance.comfortable', 'Comfortable') },
{ value: 'compact', label: tr('settings.appearance.compact', 'Compact') },
].map((opt) => (
<button type="button" key={opt.value} onClick={() => update({ density: opt.value as AppearanceConfig['density'] })} style={segStyle(cfg.density === opt.value)}>
{opt.label}
</button>
))}
</div>
<p className="text-xs text-content-faint mt-2">
{tr('settings.appearance.densityHint', 'Compact tightens spacing and padding for a denser layout that fits more on screen.')}
</p>
</div>
{/* Text size — global, plus an always-visible row per size class with a
live sample and an example of what each size affects. */}
<div>
<label className="block text-sm font-medium mb-2 text-content-secondary">
{tr('settings.appearance.textSize', 'Text size')}
</label>
<SliderRow
label={tr('settings.appearance.textSizeAll', 'Everything')}
value={cfg.fontScale}
onChange={(v) => update({ fontScale: v })}
<SettingRows>
<ToggleRow
label={tr('settings.appearance.transparency', 'Transparency')}
hint={tr('settings.appearance.transparencyHint', 'Glassy translucent surfaces. Turn off for solid, higher-contrast backgrounds.')}
on={cfg.transparency}
onToggle={() => update({ transparency: !cfg.transparency })}
/>
<div className="space-y-4 mt-4 pt-4 border-t border-edge-secondary">
<SizeRow
sampleClass="text-title font-bold"
name={tr('settings.appearance.size.large', 'Large')}
example={tr('settings.appearance.example.large', 'Headings, big numbers')}
sample={tr('settings.appearance.preview.large', 'Large heading')}
value={cfg.typeScale.title}
onChange={(v) => update({ typeScale: { ...cfg.typeScale, title: v } })}
<ToggleRow
label={tr('settings.appearance.reduceMotion', 'Reduce motion')}
hint={tr('settings.appearance.reduceMotionHint', 'Minimize animations and transitions.')}
on={cfg.reduceMotion}
onToggle={() => update({ reduceMotion: !cfg.reduceMotion })}
/>
{/* Density */}
<SettingRow
label={tr('settings.appearance.density', 'Density')}
hint={tr('settings.appearance.densityHint', 'Compact tightens spacing and padding for a denser layout that fits more on screen.')}
control={
<Segmented
label={tr('settings.appearance.density', 'Density')}
value={cfg.density}
onChange={(value) => update({ density: value })}
options={[
{ value: 'comfortable', label: tr('settings.appearance.comfortable', 'Comfortable') },
{ value: 'compact', label: tr('settings.appearance.compact', 'Compact') },
]}
/>
}
/>
{/* Text size — global, plus an always-visible row per size class with a
live sample and an example of what each size affects. */}
<SettingRow label={tr('settings.appearance.textSize', 'Text size')} stacked>
<SliderRow
label={tr('settings.appearance.textSizeAll', 'Everything')}
value={cfg.fontScale}
onChange={(v) => update({ fontScale: v })}
/>
<SizeRow
sampleClass="text-subtitle font-semibold"
name={tr('settings.appearance.size.medium', 'Medium')}
example={tr('settings.appearance.example.medium', 'Sub-headings')}
sample={tr('settings.appearance.preview.medium', 'Medium subtitle')}
value={cfg.typeScale.subtitle}
onChange={(v) => update({ typeScale: { ...cfg.typeScale, subtitle: v } })}
/>
<SizeRow
sampleClass="text-body"
name={tr('settings.appearance.size.normal', 'Normal')}
example={tr('settings.appearance.example.normal', 'Place names, descriptions')}
sample={tr('settings.appearance.preview.normal', 'Normal body text')}
value={cfg.typeScale.body}
onChange={(v) => update({ typeScale: { ...cfg.typeScale, body: v } })}
/>
<SizeRow
sampleClass="text-caption"
name={tr('settings.appearance.size.small', 'Small')}
example={tr('settings.appearance.example.small', 'Addresses, labels')}
sample={tr('settings.appearance.preview.small', 'Small caption / address')}
value={cfg.typeScale.caption}
onChange={(v) => update({ typeScale: { ...cfg.typeScale, caption: v } })}
/>
</div>
</div>
<div className="mt-4 flex flex-col gap-3 rounded-[12px] border border-edge-faint bg-surface-secondary p-3">
<SizeRow
sampleClass="text-title font-bold"
name={tr('settings.appearance.size.large', 'Large')}
example={tr('settings.appearance.example.large', 'Headings, big numbers')}
sample={tr('settings.appearance.preview.large', 'Large heading')}
value={cfg.typeScale.title}
onChange={(v) => update({ typeScale: { ...cfg.typeScale, title: v } })}
/>
<SizeRow
sampleClass="text-subtitle font-semibold"
name={tr('settings.appearance.size.medium', 'Medium')}
example={tr('settings.appearance.example.medium', 'Sub-headings')}
sample={tr('settings.appearance.preview.medium', 'Medium subtitle')}
value={cfg.typeScale.subtitle}
onChange={(v) => update({ typeScale: { ...cfg.typeScale, subtitle: v } })}
/>
<SizeRow
sampleClass="text-body"
name={tr('settings.appearance.size.normal', 'Normal')}
example={tr('settings.appearance.example.normal', 'Place names, descriptions')}
sample={tr('settings.appearance.preview.normal', 'Normal body text')}
value={cfg.typeScale.body}
onChange={(v) => update({ typeScale: { ...cfg.typeScale, body: v } })}
/>
<SizeRow
sampleClass="text-caption"
name={tr('settings.appearance.size.small', 'Small')}
example={tr('settings.appearance.example.small', 'Addresses, labels')}
sample={tr('settings.appearance.preview.small', 'Small caption / address')}
value={cfg.typeScale.caption}
onChange={(v) => update({ typeScale: { ...cfg.typeScale, caption: v } })}
/>
</div>
</SettingRow>
</SettingRows>
</Section>
{/* ── Dashboard widgets ───────────────────────────────────── */}
<Section title={tr('settings.appearance.dashboardWidgets', 'Dashboard widgets')} icon={LayoutDashboard}>
<p className="text-xs text-content-faint -mt-1">
{tr('settings.appearance.dashboardWidgetsHint', 'Choose which widgets appear on the dashboard — independently for desktop and mobile.')}
</p>
<div className="text-sm font-semibold text-content">{tr('settings.appearance.desktop', 'Desktop')}</div>
{DESKTOP_GROUPS.map((g) => {
const masterOn = g.master ? cfg.dashboard.desktop[g.master] : true
return (
<div key={g.id} className="rounded-lg border border-edge-secondary px-3 py-2">
{g.master ? (
<Section
title={tr('settings.appearance.dashboardWidgets', 'Dashboard widgets')}
icon={LayoutDashboard}
hint={tr('settings.appearance.dashboardWidgetsHint', 'Choose which widgets appear on the dashboard — independently for desktop and mobile.')}
>
<DialogSection label={tr('settings.appearance.desktop', 'Desktop')}>
<div className="flex flex-col gap-3">
{DESKTOP_GROUPS.map((g) => {
const masterOn = g.master ? cfg.dashboard.desktop[g.master] : true
const rows = g.keys.map((k) => (
<ToggleRow
label={tr(`settings.appearance.widget.${g.master}`, WIDGET_LABELS[g.master])}
hint={tr('settings.appearance.sidebarHint', 'The whole right column. Turn off and the dashboard centers.')}
on={masterOn}
onToggle={() => setWidget('desktop', g.master as string, !masterOn)}
key={k}
label={tr(`settings.appearance.widget.${k}`, WIDGET_LABELS[k])}
on={cfg.dashboard.desktop[k]}
onToggle={() => setWidget('desktop', k, !cfg.dashboard.desktop[k])}
/>
) : (
<div className="text-[11px] font-semibold uppercase tracking-wide text-content-faint mb-1">
{tr(`settings.appearance.group.${g.id}`, g.fallback)}
</div>
)}
<div
className={g.master ? 'mt-1 pl-3 border-l-2 border-edge-secondary' : ''}
style={g.master && !masterOn ? { opacity: 0.4, pointerEvents: 'none' } : undefined}
>
))
if (!g.master) {
return <WidgetGroup key={g.id} caption={tr(`settings.appearance.group.${g.id}`, g.fallback)}>{rows}</WidgetGroup>
}
return (
<SettingRows key={g.id}>
<SettingRow
label={tr(`settings.appearance.widget.${g.master}`, WIDGET_LABELS[g.master])}
hint={tr('settings.appearance.sidebarHint', 'The whole right column. Turn off and the dashboard centers.')}
control={<ToggleSwitch on={masterOn} onToggle={() => setWidget('desktop', g.master as string, !masterOn)}
label={tr(`settings.appearance.widget.${g.master}`, WIDGET_LABELS[g.master])} />}
>
{/* Its widgets only matter while the column is shown: dimmed, not hidden, while it is off. */}
<SettingRows className={`bg-surface-secondary transition-opacity ${masterOn ? '' : 'pointer-events-none opacity-40'}`}>
{rows}
</SettingRows>
</SettingRow>
</SettingRows>
)
})}
</div>
</DialogSection>
<DialogSection label={tr('settings.appearance.mobile', 'Mobile')}>
<div className="flex flex-col gap-3">
{MOBILE_GROUPS.map((g) => (
<WidgetGroup key={g.id} caption={tr(`settings.appearance.group.${g.id}`, g.fallback)}>
{g.keys.map((k) => (
<ToggleRow
key={k}
label={tr(`settings.appearance.widget.${k}`, WIDGET_LABELS[k])}
on={cfg.dashboard.desktop[k]}
onToggle={() => setWidget('desktop', k, !cfg.dashboard.desktop[k])}
on={cfg.dashboard.mobile[k]}
onToggle={() => setWidget('mobile', k, !cfg.dashboard.mobile[k])}
/>
))}
</div>
</div>
)
})}
<div className="text-sm font-semibold text-content mt-3">{tr('settings.appearance.mobile', 'Mobile')}</div>
{MOBILE_GROUPS.map((g) => (
<div key={g.id} className="rounded-lg border border-edge-secondary px-3 py-2">
<div className="text-[11px] font-semibold uppercase tracking-wide text-content-faint mb-1">
{tr(`settings.appearance.group.${g.id}`, g.fallback)}
</div>
{g.keys.map((k) => (
<ToggleRow
key={k}
label={tr(`settings.appearance.widget.${k}`, WIDGET_LABELS[k])}
on={cfg.dashboard.mobile[k]}
onToggle={() => setWidget('mobile', k, !cfg.dashboard.mobile[k])}
/>
</WidgetGroup>
))}
</div>
))}
</DialogSection>
</Section>
<div className="flex justify-end mb-6">
<button type="button"
onClick={resetAll}
className="flex items-center gap-2 text-sm font-medium text-content-muted hover:text-content"
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '6px 4px' }}
>
<RotateCcw size={15} />
<div className="mb-6 flex justify-end">
<button type="button" onClick={resetAll} className={SETTINGS_BUTTON} style={fs(13, 'body')}>
<RotateCcw size={14} strokeWidth={2} />
{tr('settings.appearance.reset', 'Reset to defaults')}
</button>
</div>
@@ -440,23 +398,43 @@ function schemeFallback(id: string): string {
}
function ToggleRow({ label, hint, on, onToggle }: { label: string; hint?: string; on: boolean; onToggle: () => void }) {
return <SettingRow label={label} hint={hint} control={<ToggleSwitch on={on} onToggle={onToggle} label={label} />} />
}
/** Where a set of widgets sits on the dashboard: a caption over its box of switches. */
function WidgetGroup({ caption, children }: { caption: string; children: ReactNode }) {
return (
<div className="flex items-start justify-between gap-4 py-1.5">
<div>
<div className="text-sm font-medium text-content-secondary">{label}</div>
{hint && <div className="text-xs text-content-faint mt-0.5">{hint}</div>}
</div>
<ToggleSwitch on={on} onToggle={onToggle} label={label} />
<div>
<div className="mb-1.5 px-0.5 font-medium text-content-muted" style={fs(12, 'body')}>{caption}</div>
<SettingRows>{children}</SettingRows>
</div>
)
}
/** A colour input with its name, in the box look of the fields. */
function ColorField({ label, value, onChange }: { label: string; value: string; onChange: (v: string) => void }) {
return (
<label className="inline-flex items-center gap-2 rounded-[10px] border border-edge bg-surface-input py-1 pl-3 pr-1 font-medium text-content-secondary" style={fs(12.5, 'body')}>
{label}
<input type="color" value={value} onChange={(e) => onChange(e.target.value)}
className="h-7 w-9 cursor-pointer rounded-[7px] border-0 bg-transparent p-0" />
</label>
)
}
const fill = (value: number) => ({ '--fill': `${((value - APPEARANCE_SCALE_MIN) / (APPEARANCE_SCALE_MAX - APPEARANCE_SCALE_MIN)) * 100}%` } as React.CSSProperties)
/** A percentage in the corner of a slider. */
function Percent({ value }: { value: number }) {
return <span className="flex-none font-geist font-semibold tabular-nums text-content-muted" style={fs(11.5)}>{Math.round(value * 100)}%</span>
}
function SliderRow({ label, value, onChange }: { label: string; value: number; onChange: (v: number) => void }) {
return (
<div>
<div className="flex items-center justify-between mb-1">
<span className="text-sm font-medium text-content-secondary">{label}</span>
<span className="text-xs text-content-muted tabular-nums">{Math.round(value * 100)}%</span>
<div className="mb-1.5 flex items-center justify-between gap-3">
<span className="font-medium text-content-secondary" style={fs(12.5, 'body')}>{label}</span>
<Percent value={value} />
</div>
<input
type="range"
@@ -466,7 +444,7 @@ function SliderRow({ label, value, onChange }: { label: string; value: number; o
value={value}
onChange={(e) => onChange(Number(e.target.value))}
className="trek-range"
style={{ '--fill': `${((value - APPEARANCE_SCALE_MIN) / (APPEARANCE_SCALE_MAX - APPEARANCE_SCALE_MIN)) * 100}%` } as React.CSSProperties}
style={fill(value)}
/>
</div>
)
@@ -475,14 +453,14 @@ function SliderRow({ label, value, onChange }: { label: string; value: number; o
function SizeRow({ sampleClass, name, example, sample, value, onChange }: { sampleClass: string; name: string; example: string; sample: string; value: number; onChange: (v: number) => void }) {
return (
<div>
<div className="flex items-end justify-between gap-3 mb-1.5">
<div className="mb-1.5 flex items-end justify-between gap-3">
<div className="min-w-0 flex-1">
<div className={`${sampleClass} text-content leading-tight truncate`}>{sample}</div>
<div className="text-xs text-content-faint mt-0.5">
<div className={`${sampleClass} truncate leading-tight text-content`}>{sample}</div>
<div className="mt-0.5 truncate text-content-faint" style={fs(11.5)}>
<span className="font-medium text-content-muted">{name}</span> · {example}
</div>
</div>
<span className="text-xs text-content-muted tabular-nums shrink-0">{Math.round(value * 100)}%</span>
<Percent value={value} />
</div>
<input
type="range"
@@ -492,7 +470,7 @@ function SizeRow({ sampleClass, name, example, sample, value, onChange }: { samp
value={value}
onChange={(e) => onChange(Number(e.target.value))}
className="trek-range"
style={{ '--fill': `${((value - APPEARANCE_SCALE_MIN) / (APPEARANCE_SCALE_MAX - APPEARANCE_SCALE_MIN)) * 100}%` } as React.CSSProperties}
style={fill(value)}
/>
</div>
)
@@ -1,19 +1,21 @@
import React from 'react'
import { Save, Plug, RefreshCw, Unplug } from 'lucide-react'
import { Loader2, Save, Plug, RefreshCw, Unplug } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { useDawarichConnection } from '../../hooks/useDawarichConnection'
import DawarichIcon from '../shared/DawarichIcon'
import Section from './Section'
import ToggleSwitch from './ToggleSwitch'
import { SETTINGS_BUTTON, SETTINGS_BUTTON_DANGER, SETTINGS_BUTTON_PRIMARY, SettingRow, SettingRows, SettingsHint, StatusPill } from './settingsKit'
import { EditorField, GRID_2, INPUT } from '../shared/dialogParts'
import { fs } from '../shared/DialogShell'
/**
* Settings → Integrations → Dawarich.
*
* All of the behaviour is in `useDawarichConnection`, shared with the phone
* twin; this file is markup. The layout follows the AirTrail and LLM sections
* so the three read as one shelf, but the classes are the semantic tokens
* rather than the raw `slate-*` those two still carry — a new surface has to
* follow the user's accent and colour scheme.
* so the three read as one shelf: fields side by side, the switches as rows,
* the actions under them and the connection state as a pill in the band.
*
* The status block below the fields is the part that earns its space: a
* connection to somebody else's server fails in ways they can act on — a wrong
@@ -25,28 +27,30 @@ export default function DawarichConnectionSection(): React.ReactElement {
const S = useDawarichConnection()
return (
<Section title={t('dawarich.title')} icon={DawarichIcon}>
<div className="space-y-3">
<p className="text-caption text-content-secondary">{t('dawarich.intro')}</p>
<Section
title={t('dawarich.title')}
icon={DawarichIcon}
badge={
<StatusPill tone={S.connected ? 'success' : 'neutral'} icon={<span className="h-1.5 w-1.5 rounded-full bg-current" />}>
{S.connected ? t('dawarich.connected') : t('dawarich.notConnected')}
</StatusPill>
}
>
<SettingsHint>{t('dawarich.intro')}</SettingsHint>
<div>
<label htmlFor="dawarich-url" className="block text-caption font-medium mb-1.5 text-content-secondary">
{t('dawarich.url')}
</label>
<div className={GRID_2}>
<EditorField label={t('dawarich.url')} htmlFor="dawarich-url">
<input
id="dawarich-url"
type="url"
value={S.url}
onChange={e => S.setUrl(e.target.value)}
placeholder="https://dawarich.example.com"
className="w-full px-3 py-2.5 border rounded-lg text-body focus:outline-none focus:ring-2 ring-accent border-edge bg-surface-input text-content"
className={INPUT}
/>
</div>
</EditorField>
<div>
<label htmlFor="dawarich-key" className="block text-caption font-medium mb-1.5 text-content-secondary">
{t('dawarich.apiKey')}
</label>
<EditorField label={t('dawarich.apiKey')} htmlFor="dawarich-key" hint={t('dawarich.apiKeyHint')}>
<input
id="dawarich-key"
type="password"
@@ -54,82 +58,72 @@ export default function DawarichConnectionSection(): React.ReactElement {
onChange={e => S.setApiKey(e.target.value)}
autoComplete="off"
placeholder={S.connected && !S.apiKey ? '••••••••' : t('dawarich.apiKeyPlaceholder')}
className="w-full px-3 py-2.5 border rounded-lg text-body focus:outline-none focus:ring-2 ring-accent border-edge bg-surface-input text-content"
className={INPUT}
/>
<p className="mt-1 text-caption text-content-muted">{t('dawarich.apiKeyHint')}</p>
</div>
<div>
<div className="flex items-center gap-3">
<ToggleSwitch on={S.syncEnabled} onToggle={S.toggleSync} label={t('dawarich.syncEnabled')} />
<span className="text-body font-medium text-content-secondary">{t('dawarich.syncEnabled')}</span>
</div>
<p className="mt-1 text-caption text-content-muted">{t('dawarich.syncEnabledHint')}</p>
</div>
<div>
<div className="flex items-center gap-3">
<ToggleSwitch on={S.allowInsecureTls} onToggle={S.toggleInsecureTls} label={t('dawarich.allowInsecureTls')} />
<span className="text-body font-medium text-content-secondary">{t('dawarich.allowInsecureTls')}</span>
</div>
<p className="mt-1 text-caption text-content-muted">{t('dawarich.allowInsecureTlsHint')}</p>
</div>
<div className="flex flex-wrap items-center gap-3">
<button
type="button"
onClick={S.save}
disabled={S.saving || S.loading || !S.canSave}
className="flex items-center gap-2 px-4 py-2 rounded-lg text-body font-medium bg-accent text-accent-text hover:bg-accent-hover disabled:opacity-50"
>
<Save className="w-4 h-4" /> {t('common.save')}
</button>
<button
type="button"
onClick={S.test}
disabled={S.testing || S.loading || !S.url.trim()}
className="flex items-center gap-2 px-4 py-2 border rounded-lg text-body border-edge text-content-secondary hover:bg-surface-hover disabled:opacity-50"
>
{S.testing
? <span className="w-4 h-4 border-2 rounded-full animate-spin border-edge border-t-transparent" />
: <Plug className="w-4 h-4" />}
{t('dawarich.test.button')}
</button>
{S.connected && (
<button
type="button"
onClick={S.syncNow}
disabled={S.syncing}
className="flex items-center gap-2 px-4 py-2 border rounded-lg text-body border-edge text-content-secondary hover:bg-surface-hover disabled:opacity-50"
>
<RefreshCw className={`w-4 h-4 ${S.syncing ? 'animate-spin' : ''}`} />
{t('dawarich.syncNow')}
</button>
)}
<span className="basis-full sm:basis-auto text-caption font-medium flex items-center gap-1.5">
<span className={`w-2 h-2 rounded-full ${S.connected ? 'bg-success' : 'bg-surface-tertiary'}`} />
<span className={S.connected ? 'text-success' : 'text-content-muted'}>
{S.connected ? t('dawarich.connected') : t('dawarich.notConnected')}
</span>
</span>
{S.connected && (
<button
type="button"
onClick={S.disconnect}
disabled={S.saving}
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-caption text-danger hover:bg-danger-soft disabled:opacity-50"
>
<Unplug className="w-3.5 h-3.5" /> {t('dawarich.disconnect')}
</button>
)}
</div>
<DawarichConnectionStatus state={S} locale={locale} />
</EditorField>
</div>
<SettingRows>
<SettingRow
label={t('dawarich.syncEnabled')}
hint={t('dawarich.syncEnabledHint')}
control={<ToggleSwitch on={S.syncEnabled} onToggle={S.toggleSync} label={t('dawarich.syncEnabled')} />}
/>
<SettingRow
label={t('dawarich.allowInsecureTls')}
hint={t('dawarich.allowInsecureTlsHint')}
control={<ToggleSwitch on={S.allowInsecureTls} onToggle={S.toggleInsecureTls} label={t('dawarich.allowInsecureTls')} />}
/>
</SettingRows>
<div className="flex flex-wrap items-center gap-2" style={fs(13, 'body')}>
<button
type="button"
onClick={S.save}
disabled={S.saving || S.loading || !S.canSave}
className={SETTINGS_BUTTON_PRIMARY}
>
<Save size={14} strokeWidth={2.2} /> {t('common.save')}
</button>
<button
type="button"
onClick={S.test}
disabled={S.testing || S.loading || !S.url.trim()}
className={SETTINGS_BUTTON}
>
{S.testing
? <Loader2 size={14} strokeWidth={2.2} className="animate-spin" />
: <Plug size={14} strokeWidth={2.2} />}
{t('dawarich.test.button')}
</button>
{S.connected && (
<button
type="button"
onClick={S.syncNow}
disabled={S.syncing}
className={SETTINGS_BUTTON}
>
<RefreshCw size={14} strokeWidth={2.2} className={S.syncing ? 'animate-spin' : ''} />
{t('dawarich.syncNow')}
</button>
)}
{/* The way out sits apart from the everyday actions, on the far right. */}
{S.connected && (
<button
type="button"
onClick={S.disconnect}
disabled={S.saving}
className={`${SETTINGS_BUTTON_DANGER} ml-auto`}
>
<Unplug size={14} strokeWidth={2.2} /> {t('dawarich.disconnect')}
</button>
)}
</div>
<DawarichConnectionStatus state={S} locale={locale} />
</Section>
)
}
@@ -161,11 +155,11 @@ function DawarichConnectionStatus({
}
return (
<div className="rounded-lg border p-3 space-y-1.5 border-edge bg-surface-secondary">
{state.probeMessage && <p className="text-caption text-content">{state.probeMessage}</p>}
<div className="flex flex-col gap-1 rounded-[12px] border border-edge-faint bg-surface-card px-3.5 py-3" style={fs(12, 'body')}>
{state.probeMessage && <p className="m-0 font-medium text-content">{state.probeMessage}</p>}
{state.connected && (
<p className="text-caption text-content-secondary">
<p className="m-0 text-content-secondary">
{state.lastSyncAt
? t('dawarich.lastSync', { when: new Date(state.lastSyncAt).toLocaleString(locale) })
: t('dawarich.neverSynced')}
@@ -174,17 +168,17 @@ function DawarichConnectionStatus({
)}
{state.lastSyncError && (
<p className="text-caption text-danger">{state.lastSyncError}</p>
<p className="m-0 text-danger">{state.lastSyncError}</p>
)}
{state.capabilities?.serverVersion && (
<p className="text-caption text-content-muted">
<p className="m-0 font-geist tabular-nums text-content-muted">
{t('dawarich.serverVersion', { version: state.capabilities.serverVersion })}
</p>
)}
{missing.length > 0 && (
<p className="text-caption text-content-muted">
<p className="m-0 text-content-muted">
{t('dawarich.capability.missing', { features: missing.join(', ') })}
</p>
)}
@@ -10,6 +10,11 @@ import { buildUser, buildSettings } from '../../../tests/helpers/factories';
import DisplaySettingsTab from './DisplaySettingsTab';
import { ToastContainer } from '../shared/Toast';
/** The switch of a yes-or-no row, named by the row's label. */
function toggleFor(label: RegExp): HTMLElement {
return screen.getByRole('button', { name: label });
}
beforeEach(() => {
resetAllStores();
server.use(
@@ -64,11 +69,8 @@ describe('DisplaySettingsTab', () => {
it('FE-COMP-DISPLAY-016: active language button is visually highlighted', () => {
seedStore(useSettingsStore, { settings: buildSettings({ language: 'en' }) });
render(<DisplaySettingsTab />);
// Multiple elements contain "English" (desktop grid button + mobile dropdown trigger).
// The desktop grid button is the one with the active border style.
const englishMatches = screen.getAllByText('English').map(el => el.closest('button')!).filter(Boolean);
const activeBtn = englishMatches.find(btn => (btn.style.border || '').includes('var(--text-primary)'));
expect(activeBtn).toBeDefined();
// "English" is on the chip and on the compact trigger; only the chip is a pressed choice.
expect(screen.getByRole('button', { name: 'English', pressed: true })).toBeInTheDocument();
});
it('FE-COMP-DISPLAY-017: shows Temperature section label', () => {
@@ -80,7 +82,7 @@ describe('DisplaySettingsTab', () => {
seedStore(useSettingsStore, { settings: buildSettings({ temperature_unit: 'celsius' }) });
render(<DisplaySettingsTab />);
const celsiusBtn = screen.getByText('°C Celsius').closest('button')!;
expect(celsiusBtn.style.border).toContain('var(--text-primary)');
expect(celsiusBtn).toHaveAttribute('aria-pressed', 'true');
});
it('FE-COMP-DISPLAY-019: clicking fahrenheit button calls updateSetting with fahrenheit', async () => {
@@ -96,7 +98,7 @@ describe('DisplaySettingsTab', () => {
seedStore(useSettingsStore, { settings: { temperature_unit: 'celsius' } });
render(<DisplaySettingsTab />);
const metricBtn = screen.getByText('km Metric').closest('button')!;
expect(metricBtn.style.border).toContain('var(--text-primary)');
expect(metricBtn).toHaveAttribute('aria-pressed', 'true');
});
it('FE-COMP-DISPLAY-029: clicking imperial distance calls updateSetting with imperial', async () => {
@@ -124,38 +126,33 @@ describe('DisplaySettingsTab', () => {
expect(screen.getByText(/blur booking codes/i)).toBeInTheDocument();
});
it('FE-COMP-DISPLAY-025: blur booking codes On button is active when blur_booking_codes is true', () => {
it('FE-COMP-DISPLAY-025: blur booking codes switch is on when blur_booking_codes is true', () => {
seedStore(useSettingsStore, { settings: buildSettings({ blur_booking_codes: true }) });
render(<DisplaySettingsTab />);
const block = screen.getByText(/blur booking codes/i).closest('div')!;
const blurOnBtn = within(block).getByText(/^On$/i).closest('button')!;
expect(blurOnBtn.style.border).toContain('var(--text-primary)');
expect(toggleFor(/blur booking codes/i)).toHaveAttribute('aria-pressed', 'true');
});
it('FE-COMP-DISPLAY-030: shows Always show booking routes next to Booking route labels', () => {
render(<DisplaySettingsTab />);
const bookingLabels = screen.getByText(/booking route labels/i);
const alwaysShow = screen.getByText(/always show booking routes/i);
expect(alwaysShow).toBeInTheDocument();
// Adjacent siblings within the Travel & Map section: alwaysShow's block
// immediately follows bookingLabels' block.
expect(bookingLabels.closest('div')!.nextElementSibling).toBe(alwaysShow.closest('div'));
expect(screen.getByText(/always show booking routes/i)).toBeInTheDocument();
// Adjacent rows within the Travel & Map section: the always-show switch
// immediately follows the booking-labels switch.
const switches = Array.from(document.querySelectorAll('button[aria-label]'));
const labels = switches.indexOf(toggleFor(/booking route labels/i));
expect(switches.indexOf(toggleFor(/always show booking routes/i))).toBe(labels + 1);
});
it('FE-COMP-DISPLAY-031: always-show-routes Off button is active by default (unset)', () => {
it('FE-COMP-DISPLAY-031: always-show-routes switch is off by default (unset)', () => {
render(<DisplaySettingsTab />);
const block = screen.getByText(/always show booking routes/i).closest('div')!;
const offBtn = within(block).getByText(/^Off$/i).closest('button')!;
expect(offBtn.style.border).toContain('var(--text-primary)');
expect(toggleFor(/always show booking routes/i)).toHaveAttribute('aria-pressed', 'false');
});
it('FE-COMP-DISPLAY-032: clicking On for always-show-routes calls updateSetting with map_always_show_routes true', async () => {
it('FE-COMP-DISPLAY-032: switching always-show-routes on calls updateSetting with map_always_show_routes true', async () => {
const user = userEvent.setup();
const updateSetting = vi.fn().mockResolvedValue(undefined);
seedStore(useSettingsStore, { settings: buildSettings(), updateSetting });
render(<DisplaySettingsTab />);
const block = screen.getByText(/always show booking routes/i).closest('div')!;
await user.click(within(block).getByText(/^On$/i));
await user.click(toggleFor(/always show booking routes/i));
expect(updateSetting).toHaveBeenCalledWith('map_always_show_routes', true);
});
@@ -175,7 +172,7 @@ describe('DisplaySettingsTab', () => {
render(<DisplaySettingsTab />);
await user.click(screen.getByText('°F Fahrenheit'));
const fahrenheitBtn = screen.getByText('°F Fahrenheit').closest('button')!;
expect(fahrenheitBtn.style.border).toContain('var(--text-primary)');
expect(fahrenheitBtn).toHaveAttribute('aria-pressed', 'true');
});
});
@@ -187,9 +184,11 @@ function seedFailing(message = 'Server error') {
return updateSetting;
}
/** The block of buttons belonging to one labelled setting. */
function optionBlock(label: RegExp): HTMLElement {
return screen.getByText(label).closest('div') as HTMLElement;
/** The row of one labelled setting: climbs from its label until the row's control is inside. */
function optionBlock(label: RegExp | string): HTMLElement {
let el: HTMLElement | null = screen.getByText(label);
while (el && !el.querySelector('button')) el = el.parentElement;
return el as HTMLElement;
}
describe('DisplaySettingsTab – Display currency', () => {
@@ -236,7 +235,7 @@ describe('DisplaySettingsTab – Display currency', () => {
// ── Compact language dropdown (035–039) ───────────────────────────────────────
function mobileLangWrap(): HTMLElement {
return screen.getByText('Language').closest('div')!.querySelector('.sm\\:hidden') as HTMLElement;
return optionBlock('Language').querySelector('.sm\\:hidden') as HTMLElement;
}
describe('DisplaySettingsTab – Compact language picker', () => {
@@ -323,7 +322,7 @@ describe('DisplaySettingsTab – Map and privacy toggles', () => {
seedStore(useSettingsStore, { settings: buildSettings({ map_booking_labels: true }), updateSetting });
render(<DisplaySettingsTab />);
await user.click(within(optionBlock(/booking route labels/i)).getByText(/^Off$/));
await user.click(toggleFor(/booking route labels/i));
expect(updateSetting).toHaveBeenCalledWith('map_booking_labels', false);
});
@@ -333,7 +332,7 @@ describe('DisplaySettingsTab – Map and privacy toggles', () => {
seedFailing('Labels locked');
render(<><ToastContainer /><DisplaySettingsTab /></>);
await user.click(within(optionBlock(/booking route labels/i)).getByText(/^On$/));
await user.click(toggleFor(/booking route labels/i));
expect(await screen.findByText('Labels locked')).toBeInTheDocument();
});
@@ -343,7 +342,7 @@ describe('DisplaySettingsTab – Map and privacy toggles', () => {
seedFailing('Routes locked');
render(<><ToastContainer /><DisplaySettingsTab /></>);
await user.click(within(optionBlock(/always show booking routes/i)).getByText(/^On$/));
await user.click(toggleFor(/always show booking routes/i));
expect(await screen.findByText('Routes locked')).toBeInTheDocument();
});
@@ -353,10 +352,8 @@ describe('DisplaySettingsTab – Map and privacy toggles', () => {
const updateSetting = vi.fn().mockResolvedValue(undefined);
seedStore(useSettingsStore, { settings: buildSettings(), updateSetting });
render(<DisplaySettingsTab />);
const block = optionBlock(/explore places on the map/i);
expect(within(block).getByText(/^On$/).closest('button')!.style.border).toContain('var(--text-primary)');
await user.click(within(block).getByText(/^Off$/));
expect(toggleFor(/explore places on the map/i)).toHaveAttribute('aria-pressed', 'true');
await user.click(toggleFor(/explore places on the map/i));
expect(updateSetting).toHaveBeenCalledWith('map_poi_pill_enabled', false);
});
@@ -366,7 +363,7 @@ describe('DisplaySettingsTab – Map and privacy toggles', () => {
seedFailing('POI locked');
render(<><ToastContainer /><DisplaySettingsTab /></>);
await user.click(within(optionBlock(/explore places on the map/i)).getByText(/^Off$/));
await user.click(toggleFor(/explore places on the map/i));
expect(await screen.findByText('POI locked')).toBeInTheDocument();
});
@@ -377,7 +374,7 @@ describe('DisplaySettingsTab – Map and privacy toggles', () => {
seedStore(useSettingsStore, { settings: buildSettings({ blur_booking_codes: false }), updateSetting });
render(<DisplaySettingsTab />);
await user.click(within(optionBlock(/blur booking codes/i)).getByText(/^On$/));
await user.click(toggleFor(/blur booking codes/i));
expect(updateSetting).toHaveBeenCalledWith('blur_booking_codes', true);
});
@@ -387,10 +384,8 @@ describe('DisplaySettingsTab – Map and privacy toggles', () => {
const updateSetting = vi.fn().mockResolvedValue(undefined);
seedStore(useSettingsStore, { settings: buildSettings(), updateSetting });
render(<DisplaySettingsTab />);
const block = optionBlock(/optimize route from accommodation/i);
expect(within(block).getByText(/^On$/).closest('button')!.style.border).toContain('var(--text-primary)');
await user.click(within(block).getByText(/^Off$/));
expect(toggleFor(/optimize route from accommodation/i)).toHaveAttribute('aria-pressed', 'true');
await user.click(toggleFor(/optimize route from accommodation/i));
expect(updateSetting).toHaveBeenCalledWith('optimize_from_accommodation', false);
});
@@ -400,10 +395,10 @@ describe('DisplaySettingsTab – Map and privacy toggles', () => {
seedFailing('Nope');
render(<><ToastContainer /><DisplaySettingsTab /></>);
await user.click(within(optionBlock(/blur booking codes/i)).getByText(/^On$/));
await user.click(toggleFor(/blur booking codes/i));
await screen.findByText('Nope');
await user.click(within(optionBlock(/optimize route from accommodation/i)).getByText(/^Off$/));
await user.click(toggleFor(/optimize route from accommodation/i));
await user.click(screen.getByText('mi Imperial'));
await user.click(screen.getByRole('button', { name: /24h/ }));
@@ -417,7 +412,7 @@ describe('DisplaySettingsTab – startup destination', () => {
render(<DisplaySettingsTab />);
const block = optionBlock(/^Start page$/);
expect(within(block).getByText('Dashboard').closest('button')!.style.border).toContain('var(--text-primary)');
expect(within(block).getByText('Dashboard').closest('button')).toHaveAttribute('aria-pressed', 'true');
// Nothing to pick a tab for while TREK opens on the dashboard.
expect(screen.queryByText('Start tab')).not.toBeInTheDocument();
});
@@ -1,18 +1,27 @@
import React, { useState, useEffect, useRef } from 'react'
import { Languages, Map, ChevronDown, Check, Rocket } from 'lucide-react'
import { Languages, Map, ChevronDown, Rocket } from 'lucide-react'
import { SUPPORTED_LANGUAGES, useTranslation } from '../../i18n'
import { useSettingsStore, DEFAULT_SETTINGS } from '../../store/settingsStore'
import { useToast } from '../shared/Toast'
import CustomSelect from '../shared/CustomSelect'
import { Segmented } from '../shared/dialogParts'
import { fs } from '../shared/DialogShell'
import { POPOVER } from '../Packing/packingPopoverStyles'
import { PopoverItem } from '../Packing/PackingPopover'
import { preferredNavAppOptions } from '../Planner/placeNavigation'
import { SYMBOLS, currenciesWith } from '../Budget/BudgetPanel.constants'
import Section from './Section'
import ToggleSwitch from './ToggleSwitch'
import { ChoiceChips, SettingRow, SettingRows } from './settingsKit'
import { TRIP_TAB_IDS, TRIP_TAB_LABEL_KEYS } from '../../constants/tripTabs'
import { DEFAULT_START_PAGE, DEFAULT_START_TRIP_TAB } from '../../utils/startDestination'
import type { DistanceUnit } from '../../types'
import { DEFAULT_WEEK_START } from '@trek/shared'
import { weekStartOptions } from '../../utils/calendarWeek'
/** A select on the right of a row: wide enough for a currency or a weekday, never wider than the row. */
const SELECT_BOX = 'w-[260px] max-w-full'
export default function DisplaySettingsTab(): React.ReactElement {
const { settings, updateSetting } = useSettingsStore()
const { t, locale } = useTranslation()
@@ -46,322 +55,269 @@ export default function DisplaySettingsTab(): React.ReactElement {
const startPage = settings.start_page === 'active_trip' ? 'active_trip' : DEFAULT_START_PAGE
const startTripTab = settings.start_trip_tab || DEFAULT_START_TRIP_TAB
const currentLanguage = SUPPORTED_LANGUAGES.find(o => o.value === settings.language) || SUPPORTED_LANGUAGES[0]
const pickLanguage = async (value: string) => {
try { await updateSetting('language', value) }
catch (e: unknown) { toast.error(e instanceof Error ? e.message : t('common.error')) }
}
return (
<>
<Section title={t('settings.general.startup')} icon={Rocket}>
{/* Where opening TREK lands */}
<div>
<label className="block text-sm font-medium mb-2 text-content-secondary">{t('settings.startPage')}</label>
<div className="flex gap-3">
{([
{ value: 'dashboard', label: t('settings.startPageDashboard') },
{ value: 'active_trip', label: t('settings.startPageActiveTrip') },
] as const).map(opt => (
<button type="button"
key={opt.value}
onClick={async () => {
try { await updateSetting('start_page', opt.value) }
catch (e: unknown) { toast.error(e instanceof Error ? e.message : t('common.error')) }
}}
style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '10px 20px', borderRadius: 10, cursor: 'pointer',
fontFamily: 'inherit', fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 500,
border: startPage === opt.value ? '2px solid var(--text-primary)' : '2px solid var(--border-primary)',
background: startPage === opt.value ? 'var(--bg-hover)' : 'var(--bg-card)',
color: 'var(--text-primary)',
transition: 'all 0.15s',
}}
>
{opt.label}
</button>
))}
</div>
<p className="text-xs mt-1 text-content-faint">{t('settings.startPageHint')}</p>
</div>
{/* Which planner tab the trip opens on — only meaningful for 'active_trip' */}
{startPage === 'active_trip' && (
<div>
<label className="block text-sm font-medium mb-2 text-content-secondary">{t('settings.startTripTab')}</label>
<CustomSelect
value={startTripTab}
onChange={async v => {
try { await updateSetting('start_trip_tab', String(v)) }
catch (e: unknown) { toast.error(e instanceof Error ? e.message : t('common.error')) }
}}
options={TRIP_TAB_IDS.map(id => ({ value: id, label: t(TRIP_TAB_LABEL_KEYS[id]) }))}
<SettingRows>
{/* Where opening TREK lands */}
<SettingRow
label={t('settings.startPage')}
hint={t('settings.startPageHint')}
control={
<Segmented
label={t('settings.startPage')}
value={startPage}
options={[
{ value: 'dashboard', label: t('settings.startPageDashboard') },
{ value: 'active_trip', label: t('settings.startPageActiveTrip') },
]}
onChange={async value => {
try { await updateSetting('start_page', value) }
catch (e: unknown) { toast.error(e instanceof Error ? e.message : t('common.error')) }
}}
/>
}
/>
<p className="text-xs text-content-faint mt-2">{t('settings.startTripTabHint')}</p>
</div>
)}
{/* Which planner tab the trip opens on — only meaningful for 'active_trip' */}
{startPage === 'active_trip' && (
<SettingRow
label={t('settings.startTripTab')}
hint={t('settings.startTripTabHint')}
control={
<div className={SELECT_BOX}>
<CustomSelect
value={startTripTab}
onChange={async v => {
try { await updateSetting('start_trip_tab', String(v)) }
catch (e: unknown) { toast.error(e instanceof Error ? e.message : t('common.error')) }
}}
options={TRIP_TAB_IDS.map(id => ({ value: id, label: t(TRIP_TAB_LABEL_KEYS[id]) }))}
/>
</div>
}
/>
)}
</SettingRows>
</Section>
<Section title={t('settings.general.languageRegion')} icon={Languages}>
{/* Display currency */}
<div>
<label className="block text-sm font-medium mb-2 text-content-secondary">{t('settings.currency')}</label>
{/* Unset ('') means "no personal preference": Costs then shows each trip in its
own currency, instead of forcing every trip through one display currency. */}
<CustomSelect
value={settings.default_currency || ''}
onChange={async v => {
try { await updateSetting('default_currency', String(v)) }
catch (e: unknown) { toast.error(e instanceof Error ? e.message : t('common.error')) }
}}
options={[
{ value: '', label: t('settings.currencyTrip') },
...currenciesWith(settings.default_currency || '').map(c => ({ value: c, label: `${c} — ${SYMBOLS[c] || c}` })),
]}
searchable
/>
<p className="text-xs text-content-faint mt-2">{t('settings.currencyHint')}</p>
</div>
<SettingRows>
{/* Display currency. Unset ('') means "no personal preference": Costs then shows
each trip in its own currency, instead of forcing every trip through one. */}
<SettingRow
label={t('settings.currency')}
hint={t('settings.currencyHint')}
control={
<div className={SELECT_BOX}>
<CustomSelect
value={settings.default_currency || ''}
onChange={async v => {
try { await updateSetting('default_currency', String(v)) }
catch (e: unknown) { toast.error(e instanceof Error ? e.message : t('common.error')) }
}}
options={[
{ value: '', label: t('settings.currencyTrip') },
...currenciesWith(settings.default_currency || '').map(c => ({ value: c, label: `${c} — ${SYMBOLS[c] || c}` })),
]}
searchable
/>
</div>
}
/>
{/* Language */}
<div>
<label className="block text-sm font-medium mb-2 text-content-secondary">{t('settings.language')}</label>
{/* Desktop: Button grid */}
<div className="hidden sm:flex flex-wrap gap-3">
{SUPPORTED_LANGUAGES.map(opt => (
<button type="button"
key={opt.value}
onClick={async () => {
try { await updateSetting('language', opt.value) }
catch (e: unknown) { toast.error(e instanceof Error ? e.message : t('common.error')) }
}}
style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '10px 20px', borderRadius: 10, cursor: 'pointer',
fontFamily: 'inherit', fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 500,
border: settings.language === opt.value ? '2px solid var(--text-primary)' : '2px solid var(--border-primary)',
background: settings.language === opt.value ? 'var(--bg-hover)' : 'var(--bg-card)',
color: 'var(--text-primary)',
transition: 'all 0.15s',
}}
>
{opt.label}
</button>
))}
</div>
{/* Mobile: Custom dropdown */}
<div ref={langDropdownRef} className="sm:hidden" style={{ position: 'relative' }}>
{(() => {
const current = SUPPORTED_LANGUAGES.find(o => o.value === settings.language) || SUPPORTED_LANGUAGES[0]
return (
{/* Language: every option at a glance on a wide window, a compact list on a narrow one */}
<SettingRow label={t('settings.language')} stacked>
<div className="hidden sm:block">
<ChoiceChips
label={t('settings.language')}
value={settings.language}
options={SUPPORTED_LANGUAGES.map(opt => ({ value: opt.value, label: opt.label }))}
onChange={pickLanguage}
/>
</div>
<div ref={langDropdownRef} className="relative sm:hidden">
<button
type="button"
onClick={() => setLangOpen(v => !v)}
style={{
width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '10px 14px', borderRadius: 10,
border: '2px solid var(--border-primary)',
background: 'var(--bg-card)', color: 'var(--text-primary)',
fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 500, fontFamily: 'inherit', cursor: 'pointer',
}}
aria-expanded={langOpen}
className="flex w-full items-center justify-between gap-2 rounded-[10px] border border-edge bg-surface-input px-3 py-2 text-left font-medium text-content"
style={fs(13, 'body')}
>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{current?.label}</span>
<ChevronDown size={14} className="text-content-faint" style={{ flexShrink: 0, transform: langOpen ? 'rotate(180deg)' : 'none', transition: 'transform 0.15s' }} />
<span className="min-w-0 truncate">{currentLanguage?.label}</span>
<ChevronDown size={14} className={`flex-none text-content-faint transition-transform ${langOpen ? 'rotate-180' : ''}`} />
</button>
)
})()}
{langOpen && (
<div style={{
position: 'absolute', top: 'calc(100% + 4px)', left: 0, right: 0, zIndex: 50,
background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 10,
boxShadow: '0 8px 24px rgba(0,0,0,0.15)', padding: 4, maxHeight: 280, overflowY: 'auto',
}}>
{SUPPORTED_LANGUAGES.map(opt => {
const active = settings.language === opt.value
return (
<button
key={opt.value}
type="button"
onClick={async () => {
setLangOpen(false)
try { await updateSetting('language', opt.value) }
catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.error')) }
}}
style={{
display: 'flex', alignItems: 'center', gap: 8, width: '100%',
padding: '9px 12px', borderRadius: 6, border: 'none', cursor: 'pointer',
background: active ? 'var(--bg-hover)' : 'transparent',
fontFamily: 'inherit', fontSize: 'calc(14px * var(--fs-scale-body, 1))', color: 'var(--text-primary)',
textAlign: 'left', fontWeight: active ? 600 : 500,
}}
>
<span style={{ flex: 1 }}>{opt.label}</span>
{active && <Check size={14} strokeWidth={2.5} color="var(--accent)" />}
</button>
)
})}
{langOpen && (
<div className="absolute inset-x-0 top-[calc(100%+4px)] z-50 max-h-[280px] overflow-y-auto" style={POPOVER}>
{SUPPORTED_LANGUAGES.map(opt => (
<PopoverItem
key={opt.value}
icon={null}
label={opt.label}
active={settings.language === opt.value}
onClick={async () => {
setLangOpen(false)
await pickLanguage(opt.value)
}}
/>
))}
</div>
)}
</div>
)}
</div>
</div>
</SettingRow>
{/* Place names (#1799): searches can answer in another language than the app. */}
<div>
<label className="block text-sm font-medium mb-2 text-content-secondary">{t('settings.placeLanguage')}</label>
<CustomSelect
value={settings.place_language || ''}
onChange={async v => {
try { await updateSetting('place_language', String(v)) }
catch (e: unknown) { toast.error(e instanceof Error ? e.message : t('common.error')) }
}}
options={[
{ value: '', label: t('settings.placeLanguageApp') },
...SUPPORTED_LANGUAGES.map(opt => ({ value: opt.value, label: opt.label })),
]}
searchable
/>
<p className="text-xs text-content-faint mt-2">{t('settings.placeLanguageHint')}</p>
</div>
{/* Place names (#1799): searches can answer in another language than the app. */}
<SettingRow
label={t('settings.placeLanguage')}
hint={t('settings.placeLanguageHint')}
control={
<div className={SELECT_BOX}>
<CustomSelect
value={settings.place_language || ''}
onChange={async v => {
try { await updateSetting('place_language', String(v)) }
catch (e: unknown) { toast.error(e instanceof Error ? e.message : t('common.error')) }
}}
options={[
{ value: '', label: t('settings.placeLanguageApp') },
...SUPPORTED_LANGUAGES.map(opt => ({ value: opt.value, label: opt.label })),
]}
searchable
/>
</div>
}
/>
{/* Temperature */}
<div>
<label className="block text-sm font-medium mb-2 text-content-secondary">{t('settings.temperature')}</label>
<div className="flex gap-3">
{[
{ value: 'celsius', label: '°C Celsius' },
{ value: 'fahrenheit', label: '°F Fahrenheit' },
].map(opt => (
<button type="button"
key={opt.value}
onClick={async () => {
setTempUnit(opt.value)
try { await updateSetting('temperature_unit', opt.value) }
catch (e: unknown) { toast.error(e instanceof Error ? e.message : t('common.error')) }
}}
style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '10px 20px', borderRadius: 10, cursor: 'pointer',
fontFamily: 'inherit', fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 500,
border: tempUnit === opt.value ? '2px solid var(--text-primary)' : '2px solid var(--border-primary)',
background: tempUnit === opt.value ? 'var(--bg-hover)' : 'var(--bg-card)',
color: 'var(--text-primary)',
transition: 'all 0.15s',
}}
>
{opt.label}
</button>
))}
</div>
</div>
{/* Temperature */}
<SettingRow
label={t('settings.temperature')}
control={
<Segmented
label={t('settings.temperature')}
value={tempUnit}
options={[
{ value: 'celsius', label: '°C Celsius' },
{ value: 'fahrenheit', label: '°F Fahrenheit' },
]}
onChange={async value => {
setTempUnit(value)
try { await updateSetting('temperature_unit', value) }
catch (e: unknown) { toast.error(e instanceof Error ? e.message : t('common.error')) }
}}
/>
}
/>
{/* Distance */}
<div>
<label className="block text-sm font-medium mb-2 text-content-secondary">{t('settings.distance')}</label>
<div className="flex gap-3">
{([
{ value: 'metric', label: 'km Metric' },
{ value: 'imperial', label: 'mi Imperial' },
] as const).map(opt => (
<button type="button"
key={opt.value}
onClick={async () => {
setDistanceUnit(opt.value)
try { await updateSetting('distance_unit', opt.value) }
catch (e: unknown) { toast.error(e instanceof Error ? e.message : t('common.error')) }
}}
style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '10px 20px', borderRadius: 10, cursor: 'pointer',
fontFamily: 'inherit', fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 500,
border: distanceUnit === opt.value ? '2px solid var(--text-primary)' : '2px solid var(--border-primary)',
background: distanceUnit === opt.value ? 'var(--bg-hover)' : 'var(--bg-card)',
color: 'var(--text-primary)',
transition: 'all 0.15s',
}}
>
{opt.label}
</button>
))}
</div>
</div>
{/* Distance */}
<SettingRow
label={t('settings.distance')}
control={
<Segmented<DistanceUnit>
label={t('settings.distance')}
value={distanceUnit}
options={[
{ value: 'metric', label: 'km Metric' },
{ value: 'imperial', label: 'mi Imperial' },
]}
onChange={async value => {
setDistanceUnit(value)
try { await updateSetting('distance_unit', value) }
catch (e: unknown) { toast.error(e instanceof Error ? e.message : t('common.error')) }
}}
/>
}
/>
{/* Time Format */}
<div>
<label className="block text-sm font-medium mb-2 text-content-secondary">{t('settings.timeFormat')}</label>
<div className="flex gap-3">
{[
{ value: '24h', short: '24h', example: '14:30' },
{ value: '12h', short: '12h', example: '2:30 PM' },
].map(opt => (
<button type="button"
key={opt.value}
onClick={async () => {
try { await updateSetting('time_format', opt.value) }
catch (e: unknown) { toast.error(e instanceof Error ? e.message : t('common.error')) }
}}
style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '10px 20px', borderRadius: 10, cursor: 'pointer',
fontFamily: 'inherit', fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 500,
border: settings.time_format === opt.value ? '2px solid var(--text-primary)' : '2px solid var(--border-primary)',
background: settings.time_format === opt.value ? 'var(--bg-hover)' : 'var(--bg-card)',
color: 'var(--text-primary)',
transition: 'all 0.15s',
}}
>
{opt.short}
<span className="hidden sm:inline">{` (${opt.example})`}</span>
</button>
))}
</div>
</div>
{/* Time Format */}
<SettingRow
label={t('settings.timeFormat')}
control={
<Segmented
label={t('settings.timeFormat')}
value={settings.time_format || ''}
options={[
{ value: '24h', short: '24h', example: '14:30' },
{ value: '12h', short: '12h', example: '2:30 PM' },
].map(opt => ({
value: opt.value,
label: <>{opt.short}<span className="hidden font-geist tabular-nums text-content-faint sm:inline">{` (${opt.example})`}</span></>,
}))}
onChange={async value => {
try { await updateSetting('time_format', value) }
catch (e: unknown) { toast.error(e instanceof Error ? e.message : t('common.error')) }
}}
/>
}
/>
{/* Week start: the first column of every date picker (#2029) */}
<div>
<label className="block text-sm font-medium mb-2 text-content-secondary">{t('settings.weekStart')}</label>
<CustomSelect
value={settings.week_start || DEFAULT_WEEK_START}
onChange={async v => {
try { await updateSetting('week_start', String(v)) }
catch (e: unknown) { toast.error(e instanceof Error ? e.message : t('common.error')) }
}}
options={weekStartOptions(locale)}
/>
<p className="text-xs text-content-faint mt-2">{t('settings.weekStartHint')}</p>
</div>
{/* Week start: the first column of every date picker (#2029) */}
<SettingRow
label={t('settings.weekStart')}
hint={t('settings.weekStartHint')}
control={
<div className={SELECT_BOX}>
<CustomSelect
value={settings.week_start || DEFAULT_WEEK_START}
onChange={async v => {
try { await updateSetting('week_start', String(v)) }
catch (e: unknown) { toast.error(e instanceof Error ? e.message : t('common.error')) }
}}
options={weekStartOptions(locale)}
/>
</div>
}
/>
</SettingRows>
</Section>
<Section title={t('settings.general.travelMap')} icon={Map}>
{/* Preferred map app (#2423): opt-in, the picker stays the default */}
<div>
<label className="block text-sm font-medium mb-2 text-content-secondary">{t('settings.preferredNavApp')}</label>
<CustomSelect
value={settings.preferred_nav_app || ''}
onChange={async v => {
try { await updateSetting('preferred_nav_app', String(v)) }
catch (e: unknown) { toast.error(e instanceof Error ? e.message : t('common.error')) }
}}
options={preferredNavAppOptions(t)}
/>
<p className="text-xs text-content-faint mt-2">{t('settings.preferredNavAppHint')}</p>
</div>
<SettingRows>
{/* Preferred map app (#2423): opt-in, the picker stays the default */}
<SettingRow
label={t('settings.preferredNavApp')}
hint={t('settings.preferredNavAppHint')}
control={
<div className={SELECT_BOX}>
<CustomSelect
value={settings.preferred_nav_app || ''}
onChange={async v => {
try { await updateSetting('preferred_nav_app', String(v)) }
catch (e: unknown) { toast.error(e instanceof Error ? e.message : t('common.error')) }
}}
options={preferredNavAppOptions(t)}
/>
</div>
}
/>
{/* Date first in day headings (#1953) */}
<OnOffSetting label={t('settings.dayDateFirst')} hint={t('settings.dayDateFirstHint')} on={settings.day_date_first === true} onChange={value => saveOnOff('day_date_first', value)} />
{/* Date first in day headings (#1953) */}
<OnOffSetting label={t('settings.dayDateFirst')} hint={t('settings.dayDateFirstHint')} on={settings.day_date_first === true} onChange={value => saveOnOff('day_date_first', value)} />
{/* Booking route labels */}
<OnOffSetting label={t('settings.bookingLabels')} hint={t('settings.bookingLabelsHint')} on={settings.map_booking_labels === true} onChange={value => saveOnOff('map_booking_labels', value)} />
{/* Booking route labels */}
<OnOffSetting label={t('settings.bookingLabels')} hint={t('settings.bookingLabelsHint')} on={settings.map_booking_labels === true} onChange={value => saveOnOff('map_booking_labels', value)} />
{/* Always show booking routes */}
<OnOffSetting label={t('settings.alwaysShowRoutes')} hint={t('settings.alwaysShowRoutesHint')} on={settings.map_always_show_routes === true} onChange={value => saveOnOff('map_always_show_routes', value)} />
{/* Always show booking routes */}
<OnOffSetting label={t('settings.alwaysShowRoutes')} hint={t('settings.alwaysShowRoutesHint')} on={settings.map_always_show_routes === true} onChange={value => saveOnOff('map_always_show_routes', value)} />
{/* Unplanned places as small markers (#2024) */}
<OnOffSetting label={t('settings.compactUnplanned')} hint={t('settings.compactUnplannedHint')} on={settings.map_compact_unplanned === true} onChange={value => saveOnOff('map_compact_unplanned', value)} />
{/* Unplanned places as small markers (#2024) */}
<OnOffSetting label={t('settings.compactUnplanned')} hint={t('settings.compactUnplannedHint')} on={settings.map_compact_unplanned === true} onChange={value => saveOnOff('map_compact_unplanned', value)} />
{/* Explore places on the map (POI category pill) */}
<OnOffSetting label={t('settings.mapPoiPill')} hint={t('settings.mapPoiPillHint')} on={settings.map_poi_pill_enabled !== false} onChange={value => saveOnOff('map_poi_pill_enabled', value)} />
{/* Explore places on the map (POI category pill) */}
<OnOffSetting label={t('settings.mapPoiPill')} hint={t('settings.mapPoiPillHint')} on={settings.map_poi_pill_enabled !== false} onChange={value => saveOnOff('map_poi_pill_enabled', value)} />
{/* Blur Booking Codes */}
<OnOffSetting label={t('settings.blurBookingCodes')} on={!!settings.blur_booking_codes} onChange={value => saveOnOff('blur_booking_codes', value)} />
{/* Blur Booking Codes */}
<OnOffSetting label={t('settings.blurBookingCodes')} on={!!settings.blur_booking_codes} onChange={value => saveOnOff('blur_booking_codes', value)} />
{/* Optimize route from accommodation */}
<OnOffSetting label={t('settings.optimizeFromAccommodation')} hint={t('settings.optimizeFromAccommodationHint')} on={settings.optimize_from_accommodation !== false} onChange={value => saveOnOff('optimize_from_accommodation', value)} />
{/* Optimize route from accommodation */}
<OnOffSetting label={t('settings.optimizeFromAccommodation')} hint={t('settings.optimizeFromAccommodationHint')} on={settings.optimize_from_accommodation !== false} onChange={value => saveOnOff('optimize_from_accommodation', value)} />
</SettingRows>
</Section>
</>
)
@@ -369,35 +325,13 @@ export default function DisplaySettingsTab(): React.ReactElement {
type OnOffKey = 'day_date_first' | 'map_compact_unplanned' | 'map_booking_labels' | 'map_always_show_routes' | 'map_poi_pill_enabled' | 'blur_booking_codes' | 'optimize_from_accommodation'
/** One preference as a pair of On / Off buttons, the way this tab asks every yes-or-no question. */
/** One yes-or-no preference as a row with a switch, the way the planner's settings dialogs ask it. */
function OnOffSetting({ label, hint, on, onChange }: { label: string; hint?: string; on: boolean; onChange: (value: boolean) => void }) {
const { t } = useTranslation()
return (
<div>
<label className="block text-sm font-medium mb-2 text-content-secondary">{label}</label>
<div className="flex gap-3">
{[
{ value: true, label: t('settings.on') || 'On' },
{ value: false, label: t('settings.off') || 'Off' },
].map(opt => (
<button type="button"
key={String(opt.value)}
onClick={() => onChange(opt.value)}
style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '10px 20px', borderRadius: 10, cursor: 'pointer',
fontFamily: 'inherit', fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 500,
border: on === opt.value ? '2px solid var(--text-primary)' : '2px solid var(--border-primary)',
background: on === opt.value ? 'var(--bg-hover)' : 'var(--bg-card)',
color: 'var(--text-primary)',
transition: 'all 0.15s',
}}
>
{opt.label}
</button>
))}
</div>
{hint && <p className="text-xs mt-1 text-content-faint">{hint}</p>}
</div>
<SettingRow
label={label}
hint={hint}
control={<ToggleSwitch on={on} onToggle={() => onChange(!on)} label={label} />}
/>
)
}
@@ -240,7 +240,7 @@ describe('IntegrationsTab', () => {
await screen.findByText('MCP Configuration');
await user.click(screen.getByRole('button', { name: /API Tokens/i }));
await screen.findByText('Delete Me');
await user.click(screen.getByTitle('Delete Token'));
await user.click(screen.getByLabelText('Delete Token'));
await screen.findByText('This token will stop working immediately. Any MCP client using it will lose access.');
expect(screen.getByRole('button', { name: /^Cancel$/i })).toBeInTheDocument();
});
@@ -266,12 +266,11 @@ describe('IntegrationsTab', () => {
await screen.findByText('MCP Configuration');
await user.click(screen.getByRole('button', { name: /API Tokens/i }));
await screen.findByText('Delete Me');
await user.click(screen.getByTitle('Delete Token'));
// There are two "Delete Token" buttons: the trash icon (title) and the confirm button in modal
await user.click(screen.getByLabelText('Delete Token'));
// There are two "Delete Token" buttons: the trash icon and the confirm button in the modal
const deleteButtons = await screen.findAllByRole('button', { name: /^Delete Token$/i });
// Click the one in the modal (last one, or the standalone one without title attribute)
const confirmBtn = deleteButtons.find(btn => !btn.title);
await user.click(confirmBtn ?? deleteButtons[deleteButtons.length - 1]);
// The modal renders after the list, so its confirm button comes last
await user.click(deleteButtons[deleteButtons.length - 1]);
expect(deleteCalled).toBe(true);
await waitFor(() => {
expect(screen.queryByText('Delete Me')).toBeNull();
@@ -298,9 +297,9 @@ describe('IntegrationsTab', () => {
const { copy } = mcpEndpointRow();
await user.click(copy);
await waitFor(() => {
// After copy, icon changes to Check (green). The button should contain an svg with text-green-500
// After copy, icon changes to Check in the success colour
const svg = copy.querySelector('svg');
expect(svg).toHaveClass('text-green-500');
expect(svg).toHaveClass('text-success');
});
});
@@ -325,7 +324,7 @@ describe('IntegrationsTab', () => {
await screen.findByText('MCP Configuration');
await user.click(screen.getByRole('button', { name: /API Tokens/i }));
await screen.findByText('Cancel Token');
await user.click(screen.getByTitle('Delete Token'));
await user.click(screen.getByLabelText('Delete Token'));
await screen.findByRole('button', { name: /^Cancel$/i });
await user.click(screen.getByRole('button', { name: /^Cancel$/i }));
await waitFor(() => {
@@ -568,7 +567,7 @@ describe('IntegrationsTab', () => {
enableMcp();
render(<><ToastContainer /><IntegrationsTab /></>);
await screen.findByText('Delete Me');
await user.click(screen.getByTitle('Delete Client'));
await user.click(screen.getByLabelText('Delete Client'));
// Confirmation modal
await screen.findByRole('heading', { name: 'Delete Client' });
const confirmBtns = screen.getAllByRole('button', { name: /Delete Client/i });
@@ -596,7 +595,7 @@ describe('IntegrationsTab', () => {
enableMcp();
render(<IntegrationsTab />);
await screen.findByText('Rotate Me');
await user.click(screen.getByTitle('Rotate Secret'));
await user.click(screen.getByLabelText('Rotate Secret'));
await screen.findByText('Rotate Secret');
// Confirm — button text is 'Rotate'
const rotateBtns = screen.getAllByRole('button', { name: /^Rotate$/i });
@@ -768,7 +767,7 @@ describe('IntegrationsTab – copy actions and cancels', () => {
await screen.findByText('Token Created');
const writeSpy = vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue(undefined);
const copyBtns = screen.getAllByTitle('Copy');
const copyBtns = screen.getAllByLabelText('Copy');
await user.click(copyBtns[copyBtns.length - 1]);
expect(writeSpy).toHaveBeenCalledWith('tk_fresh_raw_secret');
@@ -845,7 +844,7 @@ describe('IntegrationsTab – copy actions and cancels', () => {
render(<IntegrationsTab />);
await screen.findByText('Existing Client');
await user.click(screen.getByTitle('Delete Client'));
await user.click(screen.getByLabelText('Delete Client'));
await screen.findByRole('heading', { name: 'Delete Client' });
await user.click(screen.getByRole('button', { name: /^Cancel$/i }));
@@ -860,7 +859,7 @@ describe('IntegrationsTab – copy actions and cancels', () => {
render(<IntegrationsTab />);
await screen.findByText('Existing Client');
await user.click(screen.getByTitle('Rotate Secret'));
await user.click(screen.getByLabelText('Rotate Secret'));
await screen.findByRole('heading', { name: 'Rotate Secret' });
await user.click(screen.getByRole('button', { name: /^Cancel$/i }));
@@ -876,7 +875,7 @@ describe('IntegrationsTab – copy actions and cancels', () => {
enableMcp();
render(<IntegrationsTab />);
await screen.findByText('Existing Client');
await user.click(screen.getByTitle('Rotate Secret'));
await user.click(screen.getByLabelText('Rotate Secret'));
const rotateBtns = await screen.findAllByRole('button', { name: /^Rotate$/i });
await user.click(rotateBtns[rotateBtns.length - 1]);
await screen.findByText('New Secret Generated');
@@ -932,9 +931,9 @@ describe('IntegrationsTab – failure toasts', () => {
await openTokensTab(user);
await screen.findByText('Token One');
await user.click(screen.getByTitle('Delete Token'));
await user.click(screen.getByLabelText('Delete Token'));
const confirmBtns = await screen.findAllByRole('button', { name: /^Delete Token$/i });
await user.click(confirmBtns.find(b => !b.title) ?? confirmBtns[confirmBtns.length - 1]);
await user.click(confirmBtns[confirmBtns.length - 1]);
await screen.findByText('Failed to delete token');
expect(screen.getByText('Token One')).toBeInTheDocument();
@@ -951,12 +950,12 @@ describe('IntegrationsTab – failure toasts', () => {
render(<><ToastContainer /><IntegrationsTab /></>);
await screen.findByText('Existing Client');
await user.click(screen.getByTitle('Delete Client'));
await user.click(screen.getByLabelText('Delete Client'));
const deleteBtns = await screen.findAllByRole('button', { name: /^Delete Client$/i });
await user.click(deleteBtns[deleteBtns.length - 1]);
await screen.findByText('Failed to delete OAuth client');
await user.click(screen.getByTitle('Rotate Secret'));
await user.click(screen.getByLabelText('Rotate Secret'));
const rotateBtns = await screen.findAllByRole('button', { name: /^Rotate$/i });
await user.click(rotateBtns[rotateBtns.length - 1]);
expect(await screen.findByText('Failed to rotate client secret')).toBeInTheDocument();
File diff suppressed because it is too large Load Diff
@@ -39,9 +39,9 @@ function renderSection() {
);
}
// The switch carries its row's label as its accessible name.
function multimodalToggle(): HTMLElement {
const row = screen.getByText('Model reads images').parentElement as HTMLElement;
return row.querySelector('button') as HTMLElement;
return screen.getByRole('button', { name: 'Model reads images' });
}
async function pickProvider(user: ReturnType<typeof userEvent.setup>, current: RegExp, next: string) {
@@ -6,7 +6,9 @@ import { useSettingsStore } from '../../store/settingsStore'
import type { Settings } from '../../types'
import Section from './Section'
import ToggleSwitch from './ToggleSwitch'
import CustomSelect from '../shared/CustomSelect'
import { SETTINGS_BUTTON_PRIMARY, SettingRow, SettingRows, SettingsHint } from './settingsKit'
import { EditorField, GRID_2, INPUT, Segmented } from '../shared/dialogParts'
import { fs } from '../shared/DialogShell'
type Provider = NonNullable<Settings['llm_provider']>
@@ -56,8 +58,8 @@ export default function LlmConnectionSection(): React.ReactElement {
const providerOptions = useMemo(
() => [
{ value: 'openai', label: t('settings.aiParsing.providerOpenai') },
{ value: 'anthropic', label: t('settings.aiParsing.providerAnthropic') },
{ value: 'openai' as const, label: t('settings.aiParsing.providerOpenai') },
{ value: 'anthropic' as const, label: t('settings.aiParsing.providerAnthropic') },
],
[t],
)
@@ -95,60 +97,61 @@ export default function LlmConnectionSection(): React.ReactElement {
return (
<Section title={t('settings.aiParsing.title')} icon={Sparkles}>
<div className="space-y-3">
<p className="text-xs text-content-secondary">{t('settings.aiParsing.hint')}</p>
<SettingsHint>{t('settings.aiParsing.hint')}</SettingsHint>
<div>
<label className="block text-sm font-medium mb-1.5 text-content-secondary">{t('settings.aiParsing.provider')}</label>
<CustomSelect
value={provider}
onChange={v => setProvider(v as Provider)}
options={providerOptions}
/>
<p className="mt-1 text-xs text-content-faint">{t('settings.aiParsing.localAdminOnly')}</p>
</div>
{/* Two hosted providers only (#1772), so a segmented pair rather than a list. */}
<EditorField label={t('settings.aiParsing.provider')} hint={t('settings.aiParsing.localAdminOnly')}>
<Segmented<Provider>
label={t('settings.aiParsing.provider')}
value={provider}
onChange={setProvider}
options={providerOptions}
/>
</EditorField>
<div>
<label className="block text-sm font-medium mb-1.5 text-content-secondary">{t('settings.aiParsing.model')}</label>
<div className={GRID_2}>
<EditorField label={t('settings.aiParsing.model')} htmlFor="llm-model">
<input
id="llm-model"
type="text"
autoComplete="off"
value={model}
onChange={e => setModel(e.target.value)}
placeholder="qwen3:8b"
className="w-full px-3 py-2.5 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-slate-400 border-edge bg-surface-secondary text-content"
className={INPUT}
/>
</div>
</EditorField>
{/* Both remaining providers are hosted and need a key, so this is no
longer conditional (#1772). */}
<div>
<label className="block text-sm font-medium mb-1.5 text-content-secondary">{t('settings.aiParsing.apiKey')}</label>
<EditorField label={t('settings.aiParsing.apiKey')} htmlFor="llm-api-key" hint={t('settings.aiParsing.apiKeyHint')}>
<input
id="llm-api-key"
type="password"
value={apiKey}
onChange={e => setApiKey(e.target.value)}
autoComplete="off"
placeholder={hasStoredKey && !apiKey ? '••••••••' : t('settings.aiParsing.apiKey')}
className="w-full px-3 py-2.5 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-slate-400 border-edge bg-surface-secondary text-content"
className={INPUT}
/>
<p className="mt-1 text-xs text-content-faint">{t('settings.aiParsing.apiKeyHint')}</p>
</div>
</EditorField>
</div>
<div>
<div className="flex items-center gap-3">
<ToggleSwitch on={multimodal} onToggle={() => setMultimodal(v => !v)} />
<span className="text-sm font-medium text-content-secondary">{t('settings.aiParsing.multimodal')}</span>
</div>
<p className="mt-1 text-xs text-content-faint">{t('settings.aiParsing.multimodalHint')}</p>
</div>
<SettingRows>
<SettingRow
label={t('settings.aiParsing.multimodal')}
hint={t('settings.aiParsing.multimodalHint')}
control={<ToggleSwitch on={multimodal} onToggle={() => setMultimodal(v => !v)} label={t('settings.aiParsing.multimodal')} />}
/>
</SettingRows>
<div className="flex" style={fs(13, 'body')}>
<button type="button"
onClick={handleSave}
disabled={saving || !isLoaded}
className="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium text-white bg-slate-900 hover:bg-slate-700 disabled:opacity-50"
className={SETTINGS_BUTTON_PRIMARY}
>
<Save className="w-4 h-4" /> {t('common.save')}
<Save size={14} strokeWidth={2.2} /> {t('common.save')}
</button>
</div>
</Section>
+251 -252
View File
@@ -1,9 +1,12 @@
import React, { useState, useEffect, useMemo, useRef, Suspense } from 'react'
import { Map, Save, Layers, Box, ChevronDown, Check, Globe2 } from 'lucide-react'
import React, { useState, useEffect, useMemo, useRef, useId, Suspense, type ReactNode } from 'react'
import { Map, Save, Layers, Box, ChevronDown, Check, Globe2, type LucideIcon } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { useSettingsStore } from '../../store/settingsStore'
import { useToast } from '../shared/Toast'
import CustomSelect from '../shared/CustomSelect'
import { fs } from '../shared/DialogShell'
import { EditorField, INPUT } from '../shared/dialogParts'
import { POPOVER } from '../Packing/packingPopoverStyles'
import { MapView } from '../Map/MapView'
// The preview loads on demand, and paired with a single engine — a Leaflet-only
// install pays for neither, and a GL install pays for one instead of both.
@@ -11,6 +14,7 @@ import ErrorBoundary from '../shared/ErrorBoundary'
import { GlMapPreviewMapbox, GlMapPreviewMaplibre } from '../Map/glLazy'
import Section from './Section'
import ToggleSwitch from './ToggleSwitch'
import { SETTINGS_BUTTON_PRIMARY, SettingRow, SettingRows, SettingsHint, StatusPill } from './settingsKit'
import { withTileApiKey } from '../../utils/tileUrl'
import { AMAP_ROAD, AMAP_SATELLITE } from '../../constants/mapDefaults'
import type { Place } from '../../types'
@@ -48,30 +52,13 @@ const MAP_PRESETS: MapPreset[] = [
{ name: '高德卫星 (Amap Satellite)', url: AMAP_SATELLITE },
]
// Tag → chip color mapping. Keeps the dropdown readable at a glance so a
// user scanning the list can spot 3D / Satellite / Apple-like styles.
const TAG_STYLES: Record<string, string> = {
'3D': 'bg-indigo-100 text-indigo-800 dark:bg-indigo-900/40 dark:text-indigo-300',
'2D': 'bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300',
'Satellite': 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-300',
'Apple-like': 'bg-sky-100 text-sky-800 dark:bg-sky-900/40 dark:text-sky-300',
'Modern': 'bg-purple-100 text-purple-800 dark:bg-purple-900/40 dark:text-purple-300',
'Dark': 'bg-zinc-800 text-zinc-100 dark:bg-zinc-900 dark:text-zinc-300',
'Minimal': 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300',
'Hillshading': 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300',
'Terrain': 'bg-lime-100 text-lime-800 dark:bg-lime-900/40 dark:text-lime-300',
'Realistic': 'bg-teal-100 text-teal-800 dark:bg-teal-900/40 dark:text-teal-300',
'Navigation': 'bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300',
'Classic': 'bg-stone-100 text-stone-700 dark:bg-stone-800 dark:text-stone-300',
'Hybrid': 'bg-cyan-100 text-cyan-800 dark:bg-cyan-900/40 dark:text-cyan-300',
'No labels': 'bg-neutral-100 text-neutral-700 dark:bg-neutral-800 dark:text-neutral-300',
'OpenFreeMap': 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300',
}
/** A field holding a URL, token or key: the box look in Geist, so the characters read apart. */
const CODE_INPUT = `${INPUT} font-geist`
/** A style's tag (3D, Satellite, Terrain…): a small quiet chip, so a scan of the list finds the kind of map. */
function TagChip({ tag }: { tag: string }) {
const cls = TAG_STYLES[tag] || 'bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300'
return (
<span className={`text-[9px] font-semibold tracking-wide uppercase px-1.5 py-[3px] rounded leading-none ${cls}`}>
<span className="flex-none rounded-[5px] bg-surface-tertiary px-1.5 py-[3px] font-geist font-bold uppercase leading-none tracking-[.06em] text-content-muted" style={fs(9)}>
{tag}
</span>
)
@@ -102,22 +89,24 @@ function StyleDropdown({ value, provider, onChange }: { value: string; provider:
<button
type="button"
onClick={() => setOpen(v => !v)}
className="w-full flex items-center justify-between gap-2 px-3 py-2 border border-slate-300 dark:border-slate-700 rounded-lg text-sm bg-white dark:bg-slate-900 hover:border-slate-400 focus:ring-2 focus:ring-slate-400 focus:border-transparent"
aria-expanded={open}
className="flex w-full items-center justify-between gap-2 rounded-[10px] border border-edge bg-surface-input px-3 py-2 text-left hover:border-content-faint focus:outline-none focus:ring-2 focus:ring-[color:var(--text-primary)]"
style={fs(13, 'body')}
>
<span className="flex items-center gap-2 min-w-0">
<span className="text-slate-900 dark:text-white truncate">
<span className="flex min-w-0 items-center gap-2">
<span className={`truncate ${selected ? 'font-medium text-content' : 'text-content-faint'}`}>
{selected ? selected.name : placeholder}
</span>
{selected && (
<span className="flex items-center gap-1 flex-shrink-0">
<span className="flex flex-none items-center gap-1">
{(selected.tags || []).map(t => <TagChip key={t} tag={t} />)}
</span>
)}
</span>
<ChevronDown size={14} className="flex-shrink-0 text-slate-400" />
<ChevronDown size={14} className={`flex-none text-content-faint transition-transform ${open ? 'rotate-180' : ''}`} />
</button>
{open && (
<div className="absolute z-20 mt-1 w-full max-h-80 overflow-auto rounded-lg border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-900 shadow-lg py-1">
<div className="absolute inset-x-0 top-[calc(100%+4px)] z-20 max-h-64 overflow-y-auto" style={POPOVER}>
{presets.map(preset => {
const isActive = preset.url === value
return (
@@ -125,13 +114,14 @@ function StyleDropdown({ value, provider, onChange }: { value: string; provider:
key={preset.url}
type="button"
onClick={() => { onChange(preset.url); setOpen(false) }}
className={`w-full flex items-center justify-between gap-2 px-3 py-2 text-left text-sm hover:bg-slate-50 dark:hover:bg-slate-800 ${isActive ? 'bg-slate-50 dark:bg-slate-800' : ''}`}
className={`flex w-full items-center justify-between gap-2 rounded-[9px] px-2.5 py-2 text-left hover:bg-surface-tertiary ${isActive ? 'bg-surface-tertiary' : ''}`}
style={fs(12.5, 'body')}
>
<span className="flex items-center gap-2 flex-wrap">
<span className="text-slate-900 dark:text-white font-medium">{preset.name}</span>
<span className="flex min-w-0 flex-wrap items-center gap-1.5">
<span className="font-medium text-content">{preset.name}</span>
{(preset.tags || []).map(t => <TagChip key={t} tag={t} />)}
</span>
{isActive && <Check size={14} className="flex-shrink-0 text-slate-900 dark:text-white" />}
{isActive && <Check size={13} className="flex-none text-content-muted" />}
</button>
)
})}
@@ -141,6 +131,36 @@ function StyleDropdown({ value, provider, onChange }: { value: string; provider:
)
}
/** One engine to pick: an icon tile, its name and what it is, outlined while chosen. */
function ProviderTile({ active, onClick, icon: Icon, name, subtitle, badge }: {
active: boolean
onClick: () => void
icon: LucideIcon
name: ReactNode
subtitle: string
badge?: ReactNode
}) {
return (
<button
type="button"
onClick={onClick}
aria-pressed={active}
className={`flex min-w-0 items-start gap-3 rounded-[12px] border bg-surface-card p-3 text-left transition-colors ${active ? 'border-[color:var(--text-primary)] shadow-sm' : 'border-edge hover:border-content-faint'}`}
>
<span className={`grid h-8 w-8 flex-none place-items-center rounded-[10px] ${active ? 'bg-accent text-accent-text' : 'bg-surface-tertiary text-content-secondary'}`}>
<Icon size={15} strokeWidth={2} />
</span>
<span className="min-w-0 flex-1">
<span className="flex min-w-0 flex-wrap items-center gap-1.5">
<span className="font-semibold text-content" style={fs(13, 'body')}>{name}</span>
{badge}
</span>
<span className="mt-0.5 hidden leading-snug text-content-faint sm:block" style={fs(11.5)}>{subtitle}</span>
</span>
</button>
)
}
type Provider = 'leaflet' | GlMapProvider
function normalizeProvider(value: unknown): Provider {
@@ -183,6 +203,8 @@ export default function MapSettingsTab(): React.ReactElement {
const [mapboxQuality, setMapboxQuality] = useState<boolean>(settings.mapbox_quality_mode === true)
// One chunk per engine — see components/Map/glLazy.tsx.
const GlMapPreview = provider === 'maplibre-gl' ? GlMapPreviewMaplibre : GlMapPreviewMapbox
// Ties each eyebrow label to its field, so a click on the label focuses it.
const fieldId = useId()
useEffect(() => {
const nextProvider = normalizeProvider(settings.map_provider)
@@ -248,262 +270,239 @@ export default function MapSettingsTab(): React.ReactElement {
}
// Only CARTO burns a watermark into keyless tiles, so the nudge is scoped to its hosts.
const cartoNeedsKey = mapTileUrl.includes('basemaps.cartocdn.com') && !cartoKey.trim()
const link = 'font-medium text-content-secondary underline decoration-edge underline-offset-2 hover:text-content'
return (
<Section title={t('settings.map')} icon={Map}>
{/* Provider picker — big cards so the choice is obvious */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">{t('settings.mapProvider')}</label>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
<button
type="button"
onClick={() => changeProvider('leaflet')}
className={`flex items-start gap-3 p-3 rounded-lg border text-left transition-colors ${
provider === 'leaflet'
? 'border-slate-900 bg-slate-50 dark:bg-slate-800 dark:border-slate-200'
: 'border-slate-200 hover:border-slate-400 dark:border-slate-700'
}`}
>
<Layers size={18} className="mt-0.5 flex-shrink-0 text-slate-700 dark:text-slate-300" />
<div>
<div className="text-sm font-medium text-slate-900 dark:text-white">Leaflet</div>
<div className="hidden sm:block text-xs text-slate-500 mt-0.5">{t('settings.mapLeafletSubtitle')}</div>
</div>
</button>
<button
type="button"
onClick={() => changeProvider('mapbox-gl')}
className={`relative flex items-start gap-3 p-3 rounded-lg border text-left transition-colors ${
provider === 'mapbox-gl'
? 'border-slate-900 bg-slate-50 dark:bg-slate-800 dark:border-slate-200'
: 'border-slate-200 hover:border-slate-400 dark:border-slate-700'
}`}
>
<Box size={18} className="mt-0.5 flex-shrink-0 text-slate-700 dark:text-slate-300" />
<div className="min-w-0">
<div className="text-sm font-medium text-slate-900 dark:text-white">
<span className="sm:hidden">Mapbox</span>
<span className="hidden sm:inline">Mapbox GL</span>
</div>
<div className="hidden sm:block text-xs text-slate-500 mt-0.5">{t('settings.mapMapboxSubtitle')}</div>
</div>
{/* Experimental badge only on ≥sm; on mobile there's no room next to the title. */}
<span className="hidden sm:inline-block absolute top-2 right-2 text-[9px] font-semibold tracking-wide uppercase px-1.5 py-[3px] rounded bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300 leading-none">
{t('settings.mapExperimental')}
</span>
</button>
<button
type="button"
onClick={() => changeProvider('maplibre-gl')}
className={`relative flex items-start gap-3 p-3 rounded-lg border text-left transition-colors ${
provider === 'maplibre-gl'
? 'border-slate-900 bg-slate-50 dark:bg-slate-800 dark:border-slate-200'
: 'border-slate-200 hover:border-slate-400 dark:border-slate-700'
}`}
>
<Globe2 size={18} className="mt-0.5 flex-shrink-0 text-slate-700 dark:text-slate-300" />
<div className="min-w-0">
<div className="text-sm font-medium text-slate-900 dark:text-white">
<span className="sm:hidden">MapLibre</span>
<span className="hidden sm:inline">MapLibre GL</span>
</div>
<div className="hidden sm:block text-xs text-slate-500 mt-0.5">{t('settings.mapMapLibreSubtitle')}</div>
</div>
</button>
</div>
<p className="text-xs text-slate-400 mt-2">
{t('settings.mapProviderHint')}
</p>
</div>
{/* Provider picker — big tiles so the choice is obvious */}
<SettingRows>
<SettingRow label={t('settings.mapProvider')} hint={t('settings.mapProviderHint')} stacked>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-3">
<ProviderTile
active={provider === 'leaflet'}
onClick={() => changeProvider('leaflet')}
icon={Layers}
name="Leaflet"
subtitle={t('settings.mapLeafletSubtitle')}
/>
<ProviderTile
active={provider === 'mapbox-gl'}
onClick={() => changeProvider('mapbox-gl')}
icon={Box}
name={<><span className="sm:hidden">Mapbox</span><span className="hidden sm:inline">Mapbox GL</span></>}
subtitle={t('settings.mapMapboxSubtitle')}
// Only on ≥sm; on a narrow window there's no room next to the title.
badge={<span className="hidden sm:inline-flex"><StatusPill tone="warning">{t('settings.mapExperimental')}</StatusPill></span>}
/>
<ProviderTile
active={provider === 'maplibre-gl'}
onClick={() => changeProvider('maplibre-gl')}
icon={Globe2}
name={<><span className="sm:hidden">MapLibre</span><span className="hidden sm:inline">MapLibre GL</span></>}
subtitle={t('settings.mapMapLibreSubtitle')}
/>
</div>
</SettingRow>
</SettingRows>
{/* Leaflet settings */}
{provider === 'leaflet' && (
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.mapTemplate')}</label>
<CustomSelect
value={mapTileUrl}
onChange={(value: string) => { if (value) setMapTileUrl(value) }}
placeholder={t('settings.mapTemplatePlaceholder.select')}
options={MAP_PRESETS.map(p => ({ value: p.url, label: p.name }))}
size="sm"
style={{ marginBottom: 8 }}
/>
<input
type="text"
value={mapTileUrl}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setMapTileUrl(e.target.value)}
placeholder="https://tile.openstreetmap.org/{z}/{x}/{y}.png"
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
/>
<p className="text-xs text-slate-400 mt-1">{t('settings.mapDefaultHint')}</p>
</div>
<EditorField label={t('settings.mapTemplate')} htmlFor={`${fieldId}-tile`} hint={t('settings.mapDefaultHint')}>
<div className="flex flex-col gap-2">
<CustomSelect
value={mapTileUrl}
onChange={(value: string) => { if (value) setMapTileUrl(value) }}
placeholder={t('settings.mapTemplatePlaceholder.select')}
options={MAP_PRESETS.map(p => ({ value: p.url, label: p.name }))}
/>
<input
id={`${fieldId}-tile`}
type="text"
value={mapTileUrl}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setMapTileUrl(e.target.value)}
placeholder="https://tile.openstreetmap.org/{z}/{x}/{y}.png"
className={CODE_INPUT}
/>
</div>
</EditorField>
)}
{/* Same deal as the Mapbox token: a managed install brings its own key. */}
{provider === 'leaflet' && !managed && (
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.mapCartoKey')}</label>
<EditorField
label={t('settings.mapCartoKey')}
htmlFor={`${fieldId}-carto`}
hint={
<>
{t('settings.mapCartoKeyHint')}{' '}
<a href="https://carto.com/basemaps/apikey/" target="_blank" rel="noreferrer" className={link}>
{t('settings.mapCartoKeyLink')}
</a>
{cartoNeedsKey && (
<span className="mt-1 block font-medium text-warning">{t('settings.mapCartoKeyMissing')}</span>
)}
</>
}
>
<input
id={`${fieldId}-carto`}
type="text"
value={cartoKey}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setCartoKey(e.target.value)}
spellCheck={false}
autoComplete="off"
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm font-mono focus:ring-2 focus:ring-slate-400 focus:border-transparent"
className={CODE_INPUT}
/>
<p className="text-xs text-slate-400 mt-1">
{t('settings.mapCartoKeyHint')}{' '}
<a href="https://carto.com/basemaps/apikey/" target="_blank" rel="noreferrer" className="underline">
{t('settings.mapCartoKeyLink')}
</a>
</p>
{cartoNeedsKey && (
<p className="text-xs text-amber-600 dark:text-amber-400 mt-1">{t('settings.mapCartoKeyMissing')}</p>
)}
</div>
</EditorField>
)}
{/* GL settings */}
{provider !== 'leaflet' && (
<div className="space-y-3">
<>
{/* The token comes with the instance on a managed install, injected when the
settings are read. A field here would only let somebody save a worse one. */}
{provider === 'mapbox-gl' && !managed && (
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.mapMapboxToken')}</label>
<input
type="text"
value={mapboxToken}
onChange={(e) => setMapboxToken(e.target.value)}
placeholder="pk.eyJ1Ijoi..."
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm font-mono focus:ring-2 focus:ring-slate-400 focus:border-transparent"
/>
<p className="text-xs text-slate-400 mt-1">
{t('settings.mapMapboxTokenHint')}{' '}
<a href="https://account.mapbox.com/access-tokens/" target="_blank" rel="noreferrer" className="underline">
{t('settings.mapMapboxTokenLink')}
</a>
</p>
</div>
<EditorField
label={t('settings.mapMapboxToken')}
htmlFor={`${fieldId}-token`}
hint={
<>
{t('settings.mapMapboxTokenHint')}{' '}
<a href="https://account.mapbox.com/access-tokens/" target="_blank" rel="noreferrer" className={link}>
{t('settings.mapMapboxTokenLink')}
</a>
</>
}
>
<input
id={`${fieldId}-token`}
type="text"
value={mapboxToken}
onChange={(e) => setMapboxToken(e.target.value)}
placeholder="pk.eyJ1Ijoi..."
className={CODE_INPUT}
/>
</EditorField>
)}
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.mapStyle')}</label>
<div className="mb-2">
<EditorField
label={t('settings.mapStyle')}
htmlFor={`${fieldId}-style`}
hint={provider === 'maplibre-gl' ? t('settings.mapOpenFreeMapStyleHint') : t('settings.mapStyleHint')}
>
<div className="flex flex-col gap-2">
<StyleDropdown value={mapboxStyle} provider={provider} onChange={setMapboxStyle} />
<input
id={`${fieldId}-style`}
type="text"
value={mapboxStyle}
onChange={(e) => setMapboxStyle(e.target.value)}
placeholder={defaultStyleForProvider(provider)}
className={CODE_INPUT}
/>
</div>
<input
type="text"
value={mapboxStyle}
onChange={(e) => setMapboxStyle(e.target.value)}
placeholder={defaultStyleForProvider(provider)}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm font-mono focus:ring-2 focus:ring-slate-400 focus:border-transparent"
/>
<p className="text-xs text-slate-400 mt-1">
{provider === 'maplibre-gl' ? t('settings.mapOpenFreeMapStyleHint') : t('settings.mapStyleHint')}
</p>
</div>
</EditorField>
{provider === 'mapbox-gl' && (
<>
<div className={`flex items-start gap-3 p-3 rounded-lg border transition-colors ${
supports3d
? 'border-slate-200 dark:border-slate-700'
: 'border-slate-200 opacity-60 dark:border-slate-700'
}`}>
<div className="flex-1">
<div className="text-sm font-medium text-slate-900 dark:text-white">{t('settings.map3dBuildings')}</div>
<div className="text-xs text-slate-500 mt-0.5">
{t('settings.map3dHint')}
</div>
</div>
<ToggleSwitch
on={mapbox3d && supports3d}
onToggle={() => { if (supports3d) setMapbox3d(!mapbox3d) }}
/>
</div>
<>
<SettingRows>
<SettingRow
label={t('settings.map3dBuildings')}
hint={t('settings.map3dHint')}
dimmed={!supports3d}
control={
<ToggleSwitch
on={mapbox3d && supports3d}
onToggle={() => { if (supports3d) setMapbox3d(!mapbox3d) }}
label={t('settings.map3dBuildings')}
/>
}
/>
<SettingRow
label={
<span className="inline-flex flex-wrap items-center gap-2">
<span>{t('settings.mapHighQuality')}</span>
<StatusPill tone="warning">{t('settings.mapExperimental')}</StatusPill>
</span>
}
hint={
<>
{t('settings.mapHighQualityHint')}{' '}
<span className="text-warning">{t('settings.mapHighQualityWarning')}</span>
</>
}
control={<ToggleSwitch on={mapboxQuality} onToggle={() => setMapboxQuality(!mapboxQuality)} label={t('settings.mapHighQuality')} />}
/>
</SettingRows>
<div className="flex items-start gap-3 p-3 rounded-lg border border-slate-200 dark:border-slate-700">
<div className="flex-1">
<div className="text-sm font-medium text-slate-900 dark:text-white flex flex-col items-start gap-1 sm:flex-row sm:items-center sm:gap-2">
<span className="order-2 sm:order-1">{t('settings.mapHighQuality')}</span>
<span className="order-1 sm:order-2 text-[9px] font-semibold tracking-wide uppercase px-1.5 py-[3px] rounded bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300 leading-none">
{t('settings.mapExperimental')}
</span>
<div className="rounded-[12px] border border-edge-faint bg-surface-card px-3.5 py-3">
<SettingsHint>
<strong className="font-semibold text-content-secondary">{t('settings.mapTipLabel')}</strong> {t('settings.mapTip')}
</SettingsHint>
</div>
<div className="text-xs text-slate-500 mt-0.5">
{t('settings.mapHighQualityHint')}{' '}
<span className="text-amber-600 dark:text-amber-400">{t('settings.mapHighQualityWarning')}</span>
</div>
</div>
<ToggleSwitch on={mapboxQuality} onToggle={() => setMapboxQuality(!mapboxQuality)} />
</div>
<div className="text-xs text-slate-400 p-3 rounded-lg bg-slate-50 dark:bg-slate-800 border border-slate-200 dark:border-slate-700">
<strong className="text-slate-600 dark:text-slate-300">{t('settings.mapTipLabel')}</strong> {t('settings.mapTip')}
</div>
</>
</>
)}
</div>
</>
)}
<div>
<div style={{ position: 'relative', inset: 0, height: '200px', width: '100%' }}>
{provider !== 'leaflet' ? (
/* A net of its own: the preview is the one place a user flips providers
live, so it is the likeliest chunk to fail — and a broken preview must
not take the rest of the settings tab with it. */
<ErrorBoundary boundaryId="settings:map-preview" resetKeys={[provider]} fallback={<div className="h-full w-full bg-surface-secondary" />}>
<Suspense fallback={<div className="h-full w-full bg-surface-secondary animate-pulse" />}>
<GlMapPreview
provider={provider}
token={mapboxToken}
style={mapboxStyle}
lat={PREVIEW_CENTER[0]}
lng={PREVIEW_CENTER[1]}
// Zoom in close so the style's character (3D buildings,
// satellite texture, label density) is immediately visible.
zoom={PREVIEW_ZOOM}
enable3d={provider === 'mapbox-gl' && mapbox3d && supports3d}
quality={provider === 'mapbox-gl' && mapboxQuality}
/>
</Suspense>
</ErrorBoundary>
) : (
// eslint-disable-next-line @typescript-eslint/no-explicit-any
React.createElement(MapView as any, {
places: previewPlaces,
dayPlaces: [],
route: null,
routeSegments: null,
selectedPlaceId: null,
onMarkerClick: null,
onMapClick: null,
onMapContextMenu: null,
// With the key on it, or the preview resolves the template as a
// keyless CARTO one and quietly shows the app default instead of
// the basemap being configured. The fields hold what the user is
// editing rather than what useTileUrl already resolved, so the key
// has to be put back on here.
tileUrl: withTileApiKey(mapTileUrl, cartoKey),
fitKey: null,
dayOrderMap: [],
leftWidth: 0,
rightWidth: 0,
hasInspector: false,
})
)}
</div>
<div className="relative h-[200px] w-full overflow-hidden rounded-[14px] border border-edge-faint bg-surface-tertiary">
{provider !== 'leaflet' ? (
/* A net of its own: the preview is the one place a user flips providers
live, so it is the likeliest chunk to fail — and a broken preview must
not take the rest of the settings tab with it. */
<ErrorBoundary boundaryId="settings:map-preview" resetKeys={[provider]} fallback={<div className="h-full w-full bg-surface-secondary" />}>
<Suspense fallback={<div className="h-full w-full animate-pulse bg-surface-secondary" />}>
<GlMapPreview
provider={provider}
token={mapboxToken}
style={mapboxStyle}
lat={PREVIEW_CENTER[0]}
lng={PREVIEW_CENTER[1]}
// Zoom in close so the style's character (3D buildings,
// satellite texture, label density) is immediately visible.
zoom={PREVIEW_ZOOM}
enable3d={provider === 'mapbox-gl' && mapbox3d && supports3d}
quality={provider === 'mapbox-gl' && mapboxQuality}
/>
</Suspense>
</ErrorBoundary>
) : (
// eslint-disable-next-line @typescript-eslint/no-explicit-any
React.createElement(MapView as any, {
places: previewPlaces,
dayPlaces: [],
route: null,
routeSegments: null,
selectedPlaceId: null,
onMarkerClick: null,
onMapClick: null,
onMapContextMenu: null,
// With the key on it, or the preview resolves the template as a
// keyless CARTO one and quietly shows the app default instead of
// the basemap being configured. The fields hold what the user is
// editing rather than what useTileUrl already resolved, so the key
// has to be put back on here.
tileUrl: withTileApiKey(mapTileUrl, cartoKey),
fitKey: null,
dayOrderMap: [],
leftWidth: 0,
rightWidth: 0,
hasInspector: false,
})
)}
</div>
<button type="button"
onClick={saveMapSettings}
disabled={saving}
className="flex items-center gap-2 px-4 py-2 bg-slate-900 text-white rounded-lg text-sm hover:bg-slate-700 disabled:bg-slate-400"
>
{saving ? <div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" /> : <Save className="w-4 h-4" />}
{t('settings.saveMap')}
</button>
<div className="flex justify-end">
<button type="button"
onClick={saveMapSettings}
disabled={saving}
className={SETTINGS_BUTTON_PRIMARY}
style={fs(13, 'body')}
>
{saving ? <span className="h-3.5 w-3.5 animate-spin rounded-full border-2 border-current border-t-transparent" /> : <Save size={14} strokeWidth={2} />}
{t('settings.saveMap')}
</button>
</div>
</Section>
)
}
@@ -1,6 +1,7 @@
import { useEffect, useRef } from 'react'
import { isStandardFamily, supportsCustom3d, addCustom3dBuildings, addTerrainAndSky } from '../Map/mapboxSetup'
import { MAPBOX_DEFAULT_STYLE, normalizeStyleForProvider, type GlMapProvider } from '../Map/glProviders'
import { fs } from '../shared/DialogShell'
interface Props {
provider?: GlMapProvider
@@ -83,11 +84,11 @@ export default function GlMapPreview({ provider = 'mapbox-gl', token = '', style
if (!isMapLibre && !token) {
return (
<div className="flex items-center justify-center h-full bg-slate-100 dark:bg-slate-800 text-xs text-slate-500 rounded-lg border border-slate-200 dark:border-slate-700">
<div className="flex h-full items-center justify-center bg-surface-tertiary px-4 text-center font-medium text-content-muted" style={fs(12)}>
Enter a Mapbox access token to preview
</div>
)
}
return <div ref={containerRef} style={{ width: '100%', height: '100%', borderRadius: '8px', overflow: 'hidden' }} />
return <div ref={containerRef} className="h-full w-full overflow-hidden" />
}
@@ -1,6 +1,9 @@
import { Plus, MoreHorizontal } from 'lucide-react'
import type { NavItemDef } from '../Layout/navItems'
/** One circle of the dock: the card surface on a hairline ring, the icon in the secondary ink. */
const SLOT = 'flex h-8 w-8 flex-none items-center justify-center rounded-full bg-surface-card text-content-secondary shadow-[inset_0_0_0_1px_var(--border-faint)]'
/**
* A live, non-interactive mock of the mobile bottom dock that mirrors the
* customizer's current split: Dashboard + bar items around the raised centre
@@ -29,8 +32,7 @@ export default function MobileNavPreview({
<span
key={item.id}
title={item.label}
className="flex h-8 w-8 flex-none items-center justify-center rounded-full"
style={{ background: 'var(--bg-card)', color: 'var(--text-secondary)', boxShadow: 'inset 0 0 0 1px var(--border-faint)' }}
className={SLOT}
>
<Icon size={16} strokeWidth={1.9} />
</span>
@@ -38,16 +40,10 @@ export default function MobileNavPreview({
}
return (
<div
className="flex items-center justify-center gap-1.5 rounded-full px-3 py-2"
style={{ background: 'var(--bg-hover)', border: '1px solid var(--border-faint)' }}
>
<div className="flex items-center justify-center gap-1.5 rounded-full border border-edge-faint bg-surface-hover px-3 py-2">
{left.map(circle)}
<span
className="mx-1 flex h-9 w-9 flex-none items-center justify-center rounded-full"
style={{ background: 'var(--text-primary)', color: 'var(--bg-card)' }}
>
<span className="mx-1 flex h-9 w-9 flex-none items-center justify-center rounded-full bg-content text-surface-card shadow-sm">
<Plus size={18} strokeWidth={2.4} />
</span>
@@ -55,8 +51,7 @@ export default function MobileNavPreview({
{hasMore && (
<span
title={moreLabel}
className="flex h-8 w-8 flex-none items-center justify-center rounded-full"
style={{ background: 'var(--bg-card)', color: 'var(--text-secondary)', boxShadow: 'inset 0 0 0 1px var(--border-faint)' }}
className={SLOT}
>
<MoreHorizontal size={16} strokeWidth={1.9} />
</span>
@@ -1,6 +1,6 @@
import React, { useState, useEffect } from 'react'
import { Link } from 'react-router'
import { Lock } from 'lucide-react'
import { Bell, Lock, Plug, Send, Webhook } from 'lucide-react'
import { WEB_PUSH_CHANNEL_ID } from '@trek/shared'
import { useTranslation } from '../../i18n'
import { notificationsApi, settingsApi } from '../../api/client'
@@ -10,6 +10,9 @@ import Section from './Section'
import WebPushCard from './WebPushCard'
import { EVENT_LABEL_KEYS, channelLabel, isLockedCell } from './notificationLabels'
import { Tooltip } from '../shared/Tooltip'
import { fs } from '../shared/DialogShell'
import { EditorField, GRID_2, INPUT } from '../shared/dialogParts'
import { SettingsCard, SettingsHint, StatusPill, SETTINGS_BUTTON, SETTINGS_BUTTON_DANGER, SETTINGS_BUTTON_PRIMARY } from './settingsKit'
interface ChannelDescriptor {
id: string
@@ -203,199 +206,215 @@ export default function NotificationsTab(): React.ReactElement {
}
}
const renderContent = () => {
if (!matrix) return <p style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', color: 'var(--text-faint)', fontStyle: 'italic' }}>{t('common.loading')}</p>
const renderMatrix = () => {
if (!matrix) return <MatrixSkeleton label={t('common.loading')} />
if (visibleChannels.length === 0) {
return (
<p style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', color: 'var(--text-faint)', fontStyle: 'italic' }}>
{t('settings.notificationPreferences.noChannels')}
</p>
)
return <SettingsHint>{t('settings.notificationPreferences.noChannels')}</SettingsHint>
}
// The event column takes what is left; every channel gets the same narrow
// column, so the switches line up under their headers.
const columns = `minmax(0, 1fr) ${visibleChannels.map(() => '72px').join(' ')}`
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
{saving && <p style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', marginBottom: 8 }}>{t('common.saving')}</p>}
{hasChannel('webhook') && (
<div style={{ marginBottom: 16, padding: '12px', background: 'var(--bg-secondary)', borderRadius: 8, border: '1px solid var(--border-primary)' }}>
<label style={{ display: 'block', fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-secondary)', marginBottom: 4 }}>
{t('settings.webhookUrl.label')}
</label>
<p style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', marginBottom: 8 }}>{t('settings.webhookUrl.hint')}</p>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<input
type="text"
value={webhookUrl}
onChange={e => setWebhookUrl(e.target.value)}
placeholder={webhookIsSet ? '••••••••' : t('settings.webhookUrl.placeholder')}
style={{ flex: 1, fontSize: 'calc(13px * var(--fs-scale-body, 1))', padding: '6px 10px', border: '1px solid var(--border-primary)', borderRadius: 6, background: 'var(--bg-primary)', color: 'var(--text-primary)' }}
/>
<button type="button"
onClick={saveWebhookUrl}
disabled={webhookSaving}
style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', padding: '6px 12px', background: 'var(--text-primary)', color: 'var(--bg-primary)', border: 'none', borderRadius: 6, cursor: webhookSaving ? 'not-allowed' : 'pointer', opacity: webhookSaving ? 0.6 : 1 }}
>
{t('common.save')}
</button>
<button type="button"
onClick={testWebhookUrl}
disabled={(!webhookUrl && !webhookIsSet) || webhookTesting}
style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', padding: '6px 12px', background: 'transparent', color: 'var(--text-secondary)', border: '1px solid var(--border-primary)', borderRadius: 6, cursor: ((!webhookUrl && !webhookIsSet) || webhookTesting) ? 'not-allowed' : 'pointer', opacity: ((!webhookUrl && !webhookIsSet) || webhookTesting) ? 0.5 : 1 }}
>
{t('settings.webhookUrl.test')}
</button>
</div>
<div className="overflow-x-auto rounded-[12px] border border-edge-faint bg-surface-card">
<div className="min-w-fit divide-y divide-edge-faint">
{/* Header row */}
<div className="grid items-center gap-1 bg-surface-secondary px-3.5 py-2.5" style={{ gridTemplateColumns: columns }}>
<span />
{visibleChannels.map(ch => (
<Tooltip key={ch.id} label={t('settings.notificationPreferences.notConfigured')} disabled={ch.configured}>
<span
className={`line-clamp-2 break-words text-center font-geist font-bold uppercase leading-tight tracking-[.08em] ${ch.configured ? 'text-content-faint' : 'text-warning'}`}
style={fs(10)}
>
{channelLabel(ch, t)}
</span>
</Tooltip>
))}
</div>
)}
{hasChannel('ntfy') && (
<div style={{ marginBottom: 16, padding: '12px', background: 'var(--bg-secondary)', borderRadius: 8, border: '1px solid var(--border-primary)' }}>
<label style={{ display: 'block', fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-secondary)', marginBottom: 4 }}>
{t('settings.ntfyUrl.topicLabel')}
</label>
<p style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', marginBottom: 8 }}>{t('settings.ntfyUrl.hint')}</p>
{/* Event rows */}
{matrix.event_types.map(eventType => {
const implementedForEvent = matrix.implemented_combos[eventType] ?? []
const relevantChannels = visibleChannels.filter(ch => implementedForEvent.includes(ch.id))
if (relevantChannels.length === 0) return null
return (
<div key={eventType} className="grid items-center gap-1 px-3.5 py-3" style={{ gridTemplateColumns: columns }}>
<span className="min-w-0 pr-2 font-medium text-content" style={fs(13, 'body')}>
{t(EVENT_LABEL_KEYS[eventType]) || eventType}
</span>
{visibleChannels.map(ch => {
if (!implementedForEvent.includes(ch.id)) {
return <span key={ch.id} className="text-center text-content-faint" style={fs(13, 'body')}>—</span>
}
const isOn = matrix.preferences[eventType]?.[ch.id] ?? true
// Switched off for everyone by the admin (#1536): the same footprint as
// the switch, so the grid keeps its columns, and a lock that says why.
if (isLockedCell(matrix.locked, eventType, ch.id)) {
return (
<div key={ch.id} className="flex justify-center">
<Tooltip label={t('settings.notificationPreferences.lockedByAdmin')}>
<span role="img" aria-label={t('settings.notificationPreferences.lockedByAdmin')} data-locked
className="inline-flex h-6 w-11 items-center justify-center rounded-full bg-surface-tertiary text-content-faint">
<Lock size={12} />
</span>
</Tooltip>
</div>
)
}
return (
<div key={ch.id} className="flex justify-center">
<ToggleSwitch on={isOn} onToggle={() => toggle(eventType, ch.id)} />
</div>
)
})}
</div>
)
})}
</div>
</div>
)
}
const webhookTestDisabled = (!webhookUrl && !webhookIsSet) || webhookTesting
// The channel cards only make sense once there is a matrix to send through.
const ready = !!matrix && visibleChannels.length > 0
return (
<div>
<Section
title={t('settings.notifications')}
icon={Bell}
badge={saving ? <StatusPill>{t('common.saving')}</StatusPill> : undefined}
>
{renderMatrix()}
</Section>
{ready && hasChannel('webhook') && (
<SettingsCard icon={Webhook} title={t('settings.webhookUrl.label')} hint={t('settings.webhookUrl.hint')}>
<div className="flex flex-wrap items-center gap-2">
<input
id="notifications-webhook-url"
type="text"
value={ntfyTopic}
onChange={e => setNtfyTopic(e.target.value)}
placeholder={t('settings.ntfyUrl.topicPlaceholder')}
style={{ width: '100%', boxSizing: 'border-box', fontSize: 'calc(13px * var(--fs-scale-body, 1))', padding: '6px 10px', border: '1px solid var(--border-primary)', borderRadius: 6, background: 'var(--bg-primary)', color: 'var(--text-primary)', marginBottom: 6 }}
value={webhookUrl}
onChange={e => setWebhookUrl(e.target.value)}
placeholder={webhookIsSet ? '••••••••' : t('settings.webhookUrl.placeholder')}
aria-label={t('settings.webhookUrl.label')}
className={`${INPUT} min-w-[220px] flex-1`}
/>
<label style={{ display: 'block', fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-secondary)', marginBottom: 4 }}>
{t('settings.ntfyUrl.serverLabel')}
</label>
<input
type="text"
value={ntfyServer}
onChange={e => setNtfyServer(e.target.value)}
placeholder={matrix.defaults?.ntfyServer || t('settings.ntfyUrl.serverPlaceholder')}
style={{ width: '100%', boxSizing: 'border-box', fontSize: 'calc(13px * var(--fs-scale-body, 1))', padding: '6px 10px', border: '1px solid var(--border-primary)', borderRadius: 6, background: 'var(--bg-primary)', color: 'var(--text-primary)', marginBottom: 6 }}
/>
<label style={{ display: 'block', fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-secondary)', marginBottom: 4 }}>
{t('settings.ntfyUrl.tokenLabel')}
</label>
<p style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', marginBottom: 4 }}>{t('settings.ntfyUrl.tokenHint')}</p>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<button type="button" onClick={testWebhookUrl} disabled={webhookTestDisabled} className={SETTINGS_BUTTON} style={fs(13, 'body')}>
{t('settings.webhookUrl.test')}
</button>
<button type="button" onClick={saveWebhookUrl} disabled={webhookSaving} className={SETTINGS_BUTTON_PRIMARY} style={fs(13, 'body')}>
{t('common.save')}
</button>
</div>
</SettingsCard>
)}
{ready && hasChannel('ntfy') && (
<SettingsCard icon={Send} title={t('settings.notificationPreferences.ntfy')} hint={t('settings.ntfyUrl.hint')}>
<div className={GRID_2}>
<EditorField label={t('settings.ntfyUrl.topicLabel')} htmlFor="notifications-ntfy-topic">
<input
id="notifications-ntfy-topic"
type="text"
value={ntfyTopic}
onChange={e => setNtfyTopic(e.target.value)}
placeholder={t('settings.ntfyUrl.topicPlaceholder')}
className={INPUT}
/>
</EditorField>
<EditorField label={t('settings.ntfyUrl.serverLabel')} htmlFor="notifications-ntfy-server">
<input
id="notifications-ntfy-server"
type="text"
value={ntfyServer}
onChange={e => setNtfyServer(e.target.value)}
placeholder={matrix?.defaults?.ntfyServer || t('settings.ntfyUrl.serverPlaceholder')}
className={INPUT}
/>
</EditorField>
</div>
<EditorField label={t('settings.ntfyUrl.tokenLabel')} htmlFor="notifications-ntfy-token" hint={t('settings.ntfyUrl.tokenHint')}>
<div className="flex items-center gap-2">
<input
id="notifications-ntfy-token"
type="password"
value={ntfyToken}
onChange={e => setNtfyToken(e.target.value)}
placeholder={ntfyTokenIsSet ? '••••••••' : ''}
style={{ flex: 1, fontSize: 'calc(13px * var(--fs-scale-body, 1))', padding: '6px 10px', border: '1px solid var(--border-primary)', borderRadius: 6, background: 'var(--bg-primary)', color: 'var(--text-primary)' }}
className={`${INPUT} flex-1`}
/>
{ntfyTokenIsSet && (
<button type="button"
onClick={clearNtfyToken}
style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', padding: '6px 12px', background: 'transparent', color: 'var(--color-danger, #e53e3e)', border: '1px solid var(--color-danger, #e53e3e)', borderRadius: 6, cursor: 'pointer' }}
>
<button type="button" onClick={clearNtfyToken} className={SETTINGS_BUTTON_DANGER} style={fs(13, 'body')}>
{t('common.clear')}
</button>
)}
<button type="button"
onClick={saveNtfySettings}
disabled={ntfySaving}
style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', padding: '6px 12px', background: 'var(--text-primary)', color: 'var(--bg-primary)', border: 'none', borderRadius: 6, cursor: ntfySaving ? 'not-allowed' : 'pointer', opacity: ntfySaving ? 0.6 : 1 }}
>
{t('common.save')}
</button>
<button type="button"
onClick={testNtfySettings}
disabled={!ntfyTopic || ntfyTesting}
style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', padding: '6px 12px', background: 'transparent', color: 'var(--text-secondary)', border: '1px solid var(--border-primary)', borderRadius: 6, cursor: (!ntfyTopic || ntfyTesting) ? 'not-allowed' : 'pointer', opacity: (!ntfyTopic || ntfyTesting) ? 0.5 : 1 }}
>
{t('settings.ntfyUrl.test')}
</button>
</div>
</EditorField>
<div className="flex flex-wrap items-center justify-end gap-2">
<button type="button" onClick={testNtfySettings} disabled={!ntfyTopic || ntfyTesting} className={SETTINGS_BUTTON} style={fs(13, 'body')}>
{t('settings.ntfyUrl.test')}
</button>
<button type="button" onClick={saveNtfySettings} disabled={ntfySaving} className={SETTINGS_BUTTON_PRIMARY} style={fs(13, 'body')}>
{t('common.save')}
</button>
</div>
)}
{/* Per device rather than per account: the column below picks the events,
this card decides whether this browser is one of the places they go. */}
{hasChannel(WEB_PUSH_CHANNEL_ID) && <WebPushCard />}
{pluginChannels.map(ch => (
<div key={ch.id} style={{ marginBottom: 16, padding: '12px', background: 'var(--bg-secondary)', borderRadius: 8, border: '1px solid var(--border-primary)' }}>
<label style={{ display: 'block', fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-secondary)', marginBottom: 4 }}>
{channelLabel(ch, t)}
</label>
<p style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', marginBottom: 8 }}>
{ch.configured
? t('settings.notificationPreferences.pluginConfigured')
: t('settings.notificationPreferences.notConfigured')}
</p>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
</SettingsCard>
)}
{/* Per device rather than per account: the matrix above picks the events,
this card decides whether this browser is one of the places they go. */}
{ready && hasChannel(WEB_PUSH_CHANNEL_ID) && <WebPushCard />}
{ready && pluginChannels.map(ch => (
<SettingsCard
key={ch.id}
icon={Plug}
title={channelLabel(ch, t)}
hint={ch.configured
? t('settings.notificationPreferences.pluginConfigured')
: t('settings.notificationPreferences.notConfigured')}
action={
<>
{/* Unconfigured is the common case on first use — send them to where the
credentials actually live rather than just naming the place. */}
{!ch.configured && ch.settingsPath && (
<Link
to={ch.settingsPath}
style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', padding: '6px 12px', background: 'var(--text-primary)', color: 'var(--bg-primary)', border: 'none', borderRadius: 6, textDecoration: 'none' }}
>
<Link to={ch.settingsPath} className={`${SETTINGS_BUTTON_PRIMARY} no-underline`} style={fs(13, 'body')}>
{t('settings.notificationPreferences.configure')}
</Link>
)}
<button type="button"
onClick={() => testChannel(ch)}
disabled={!ch.configured || channelTesting === ch.id}
style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', padding: '6px 12px', background: 'transparent', color: 'var(--text-secondary)', border: '1px solid var(--border-primary)', borderRadius: 6, cursor: (!ch.configured || channelTesting === ch.id) ? 'not-allowed' : 'pointer', opacity: (!ch.configured || channelTesting === ch.id) ? 0.5 : 1 }}
className={SETTINGS_BUTTON}
style={fs(13, 'body')}
>
{t('settings.notificationPreferences.sendTest')}
</button>
</div>
</div>
))}
{/* Header row */}
<div style={{ display: 'grid', gridTemplateColumns: `1fr ${visibleChannels.map(() => '64px').join(' ')}`, gap: 4, paddingBottom: 6, marginBottom: 4, borderBottom: '1px solid var(--border-primary)' }}>
<span />
{visibleChannels.map(ch => (
<span key={ch.id} title={!ch.configured ? t('settings.notificationPreferences.notConfigured') : undefined} style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600, color: ch.configured ? 'var(--text-faint)' : 'var(--color-warning, #d69e2e)', textAlign: 'center', textTransform: 'uppercase', letterSpacing: '0.04em' }}>
{channelLabel(ch, t)}
</span>
))}
</div>
{/* Event rows */}
{matrix.event_types.map(eventType => {
const implementedForEvent = matrix.implemented_combos[eventType] ?? []
const relevantChannels = visibleChannels.filter(ch => implementedForEvent.includes(ch.id))
if (relevantChannels.length === 0) return null
return (
<div key={eventType} style={{ display: 'grid', gridTemplateColumns: `1fr ${visibleChannels.map(() => '64px').join(' ')}`, gap: 4, alignItems: 'center', padding: '6px 0', borderBottom: '1px solid var(--border-primary)' }}>
<span style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', color: 'var(--text-primary)' }}>
{t(EVENT_LABEL_KEYS[eventType]) || eventType}
</span>
{visibleChannels.map(ch => {
if (!implementedForEvent.includes(ch.id)) {
return <span key={ch.id} style={{ textAlign: 'center', color: 'var(--text-faint)', fontSize: 'calc(14px * var(--fs-scale-body, 1))' }}>—</span>
}
const isOn = matrix.preferences[eventType]?.[ch.id] ?? true
// Switched off for everyone by the admin (#1536): the same footprint as
// the switch, so the grid keeps its columns, and a lock that says why.
if (isLockedCell(matrix.locked, eventType, ch.id)) {
return (
<div key={ch.id} style={{ display: 'flex', justifyContent: 'center' }}>
<Tooltip label={t('settings.notificationPreferences.lockedByAdmin')}>
<span role="img" aria-label={t('settings.notificationPreferences.lockedByAdmin')} data-locked
className="inline-flex h-6 w-11 items-center justify-center rounded-full bg-surface-tertiary text-content-faint">
<Lock size={12} />
</span>
</Tooltip>
</div>
)
}
return (
<div key={ch.id} style={{ display: 'flex', justifyContent: 'center' }}>
<ToggleSwitch on={isOn} onToggle={() => toggle(eventType, ch.id)} />
</div>
)
})}
</div>
)
})}
</div>
)
}
return (
<Section title={t('settings.notifications')} icon={Lock}>
{renderContent()}
</Section>
</>
}
/>
))}
</div>
)
}
/** A calm placeholder for the matrix while the preferences load. */
function MatrixSkeleton({ label }: { label: string }) {
return (
<div className="flex flex-col gap-2" aria-busy="true">
<SettingsHint>{label}</SettingsHint>
<div className="divide-y divide-edge-faint overflow-hidden rounded-[12px] border border-edge-faint bg-surface-card">
{[0, 1, 2].map(i => (
<div key={i} className="flex items-center gap-4 px-3.5 py-3">
<span className="h-3 flex-1 animate-pulse rounded-full bg-surface-tertiary" />
<span className="h-6 w-11 animate-pulse rounded-full bg-surface-tertiary" />
</div>
))}
</div>
</div>
)
}
@@ -116,7 +116,7 @@ function setOnLine(value: boolean): void {
}
/** The Section wrapper card that carries the given heading. */
const card = (title: string) => screen.getByText(title).closest('div.rounded-xl') as HTMLElement;
const card = (title: string) => screen.getByText(title).closest('section') as HTMLElement;
/** The Stat tile that carries the given label. */
const stat = (label: string) => screen.getByText(label).parentElement as HTMLElement;
@@ -272,12 +272,14 @@ describe('OfflineTab', () => {
render(<OfflineTab />);
await screen.findByText('When a conflict happens');
const select = screen.getByRole('combobox') as HTMLSelectElement;
expect(select.value).toBe('ask');
await user.selectOptions(select, 'server');
// A CustomSelect: the row's label names the trigger, the options open in a portal.
const trigger = screen.getByRole('button', { name: 'When a conflict happens' });
expect(trigger).toHaveTextContent('Ask me each time');
await user.click(trigger);
const choices = await screen.findAllByRole('button', { name: 'Always keep the server version' });
await user.click(choices[choices.length - 1]);
expect(getOfflinePrefs().conflictStrategy).toBe('server');
await waitFor(() => expect(select.value).toBe('server'));
await waitFor(() => expect(trigger).toHaveTextContent('Always keep the server version'));
});
it('FE-COMP-OFFLINETAB-011: Prepare renders live progress and the done marker', async () => {
+167 -185
View File
@@ -14,11 +14,16 @@ import React from 'react'
import { RefreshCw, Trash2, Database, CloudOff, Download, Check, GitMerge, Map as MapIcon, AlertTriangle } from 'lucide-react'
import Section from './Section'
import ToggleSwitch from './ToggleSwitch'
import { SettingRow, SettingRows, SettingsHint, StatusPill, SETTINGS_BUTTON, SETTINGS_BUTTON_DANGER } from './settingsKit'
import { useOfflineSettings, offlineNoticeKey, isOfflineNoticeWarning } from './useOfflineSettings'
import { useTranslation } from '../../i18n'
import CustomSelect from '../shared/CustomSelect'
import { fs } from '../shared/DialogShell'
import type { ConflictStrategy } from '../../sync/offlinePrefs'
import type { QueuedMutation } from '../../db/offlineDb'
const EYEBROW = 'm-0 mb-2 font-geist font-bold uppercase tracking-[.08em] text-content-faint'
function conflictName(m: QueuedMutation): string {
const body = (m.body ?? {}) as { name?: unknown }
const server = (m.conflictServer ?? {}) as { name?: unknown }
@@ -49,242 +54,219 @@ export default function OfflineTab(): React.ReactElement {
<div>
{/* Offline mode + prepare */}
<Section title={t('settings.offline.mode.title')} icon={CloudOff}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<Row
<SettingRows>
<SettingRow
label={t('settings.offline.mode.force')}
hint={t('settings.offline.mode.forceHint')}
control={<ToggleSwitch on={forced} onToggle={handleToggleForce} label={t('settings.offline.mode.force')} />}
/>
{forced && (
<p className="text-content-muted" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', margin: 0 }}>
{t('settings.offline.mode.active')}
</p>
)}
>
{forced && (
<p className="m-0 rounded-[10px] bg-warning-soft px-3 py-2 text-warning" style={fs(12, 'body')}>
{t('settings.offline.mode.active')}
</p>
)}
</SettingRow>
<div style={{ borderTop: '1px solid var(--border-secondary, #e5e7eb)', paddingTop: 16 }}>
<div style={{ fontWeight: 600, fontSize: 'calc(14px * var(--fs-scale-body, 1))', marginBottom: 4 }} className="text-content">
{t('settings.offline.prepare.title')}
</div>
<p className="text-content-muted" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', marginTop: 0, marginBottom: 12 }}>
{t('settings.offline.prepare.hint')}
</p>
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
<button type="button"
onClick={runPrepare}
disabled={preparing || offline}
className="border border-edge bg-surface-secondary text-content"
style={btnStyle(preparing || offline)}
>
{preparing
? <RefreshCw size={14} style={{ animation: 'spin 1s linear infinite' }} />
: <Download size={14} />}
{preparing ? t('settings.offline.prepare.running') : t('settings.offline.prepare.button')}
</button>
<button type="button"
onClick={handleResync}
disabled={syncing || offline}
className="border border-edge bg-surface-secondary text-content"
style={btnStyle(syncing || offline)}
>
<RefreshCw size={14} style={syncing ? { animation: 'spin 1s linear infinite' } : {}} />
{syncing ? t('settings.offline.resyncing') : t('settings.offline.resync')}
</button>
</div>
{preparing && progress && (
<div style={{ marginTop: 12 }}>
<div style={{ height: 6, borderRadius: 3, overflow: 'hidden', background: 'var(--border-primary, #e5e7eb)' }}>
<div style={{
height: '100%', borderRadius: 3, background: 'var(--accent, #4F46E5)',
width: `${progress.total ? Math.round((progress.current / progress.total) * 100) : 100}%`,
transition: 'width 0.2s',
}} />
</div>
<div className="text-content-muted" style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', marginTop: 4 }}>
{progressLabel}{progress.label ? ` · ${progress.label}` : ''}
</div>
<SettingRow label={t('settings.offline.prepare.title')} hint={t('settings.offline.prepare.hint')} stacked>
<div className="flex flex-col gap-3">
<div className="flex flex-wrap items-center gap-2">
<button type="button"
onClick={runPrepare}
disabled={preparing || offline}
className={SETTINGS_BUTTON}
style={fs(13, 'body')}
>
{preparing
? <RefreshCw size={14} className="animate-spin" />
: <Download size={14} />}
{preparing ? t('settings.offline.prepare.running') : t('settings.offline.prepare.button')}
</button>
<button type="button"
onClick={handleResync}
disabled={syncing || offline}
className={SETTINGS_BUTTON}
style={fs(13, 'body')}
>
<RefreshCw size={14} className={syncing ? 'animate-spin' : undefined} />
{syncing ? t('settings.offline.resyncing') : t('settings.offline.resync')}
</button>
</div>
)}
{!preparing && notice && notice.kind !== 'load-failed' && (
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 'calc(12px * var(--fs-scale-body, 1))', marginTop: 10, color: isOfflineNoticeWarning(notice) ? 'var(--warning)' : 'var(--success)' }}>
{isOfflineNoticeWarning(notice) ? <AlertTriangle size={14} /> : <Check size={14} />}
{t(offlineNoticeKey(notice), notice.kind === 'stored' ? { count: notice.trips } : undefined)}
</div>
)}
</div>
</div>
{preparing && progress && (
<div>
<div className="h-1.5 overflow-hidden rounded-full bg-surface-tertiary">
<div
className="h-full rounded-full bg-accent transition-[width] duration-200"
style={{ width: `${progress.total ? Math.round((progress.current / progress.total) * 100) : 100}%` }}
/>
</div>
<div className="mt-1.5 font-geist tabular-nums text-content-muted" style={fs(11)}>
{progressLabel}{progress.label ? ` · ${progress.label}` : ''}
</div>
</div>
)}
{!preparing && notice && notice.kind !== 'load-failed' && (
<div
className={`flex items-center gap-1.5 ${isOfflineNoticeWarning(notice) ? 'text-warning' : 'text-success'}`}
style={fs(12, 'body')}
>
{isOfflineNoticeWarning(notice) ? <AlertTriangle size={14} className="flex-none" /> : <Check size={14} className="flex-none" />}
{t(offlineNoticeKey(notice), notice.kind === 'stored' ? { count: notice.trips } : undefined)}
</div>
)}
</div>
</SettingRow>
</SettingRows>
</Section>
{/* Conflicts (only when there are any) */}
{conflicts.length > 0 && (
<Section title={t('settings.offline.conflicts.title')} icon={GitMerge}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<p className="text-content-muted" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', margin: 0 }}>
{t('settings.offline.conflicts.hint')}
</p>
<Section
title={t('settings.offline.conflicts.title')}
icon={GitMerge}
badge={<StatusPill tone="danger">{conflicts.length}</StatusPill>}
>
<SettingsHint>{t('settings.offline.conflicts.hint')}</SettingsHint>
<SettingRows>
{conflicts.map(c => (
<div key={c.id} className="border border-edge bg-surface-secondary" style={{ padding: '10px 14px', borderRadius: 8, display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
<span className="text-content" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500 }}>
<div key={c.id} className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2 px-3.5 py-3">
<span className="min-w-0 flex-1 basis-48 truncate font-medium text-content" style={fs(13, 'body')}>
{t('settings.offline.conflicts.item', { name: conflictName(c) })}
</span>
<div style={{ display: 'flex', gap: 8 }}>
<button type="button" onClick={() => resolveConflict(c.id, true)} className="border border-edge bg-surface-card text-content" style={smallBtnStyle()}>
<div className="flex flex-none gap-2">
<button type="button" onClick={() => resolveConflict(c.id, true)} className={SETTINGS_BUTTON} style={fs(12.5, 'body')}>
{t('settings.offline.conflicts.keepMine')}
</button>
<button type="button" onClick={() => resolveConflict(c.id, false)} className="border border-edge bg-surface-card text-content" style={smallBtnStyle()}>
<button type="button" onClick={() => resolveConflict(c.id, false)} className={SETTINGS_BUTTON} style={fs(12.5, 'body')}>
{t('settings.offline.conflicts.keepServer')}
</button>
</div>
</div>
))}
<Row
</SettingRows>
<SettingRows>
<SettingRow
label={t('settings.offline.conflicts.strategyTitle')}
htmlFor="offline-conflict-strategy"
control={
<select
<CustomSelect
id="offline-conflict-strategy"
value={prefs.conflictStrategy}
onChange={e => handleConflictStrategy(e.target.value as ConflictStrategy)}
className="border border-edge bg-surface-secondary text-content"
style={{ padding: '6px 10px', borderRadius: 8, fontSize: 'calc(13px * var(--fs-scale-body, 1))' }}
>
<option value="ask">{t('settings.offline.conflicts.strategy.ask')}</option>
<option value="mine">{t('settings.offline.conflicts.strategy.mine')}</option>
<option value="server">{t('settings.offline.conflicts.strategy.server')}</option>
</select>
onChange={v => handleConflictStrategy(v as ConflictStrategy)}
options={[
{ value: 'ask', label: t('settings.offline.conflicts.strategy.ask') },
{ value: 'mine', label: t('settings.offline.conflicts.strategy.mine') },
{ value: 'server', label: t('settings.offline.conflicts.strategy.server') },
]}
menuFit="content"
style={{ minWidth: 240 }}
/>
}
/>
</div>
</SettingRows>
</Section>
)}
{/* What to store offline */}
<Section title={t('settings.offline.storage.title')} icon={MapIcon}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<Row
<SettingRows>
<SettingRow
label={t('settings.offline.storage.tiles')}
hint={t('settings.offline.storage.tilesHint')}
control={<ToggleSwitch on={prefs.cacheTiles} onToggle={handleToggleTiles} label={t('settings.offline.storage.tiles')} />}
/>
{allTrips.length > 0 && (
<div style={{ borderTop: '1px solid var(--border-secondary, #e5e7eb)', paddingTop: 16 }}>
<div style={{ fontWeight: 600, fontSize: 'calc(13px * var(--fs-scale-body, 1))', marginBottom: 8 }} className="text-content">
{t('settings.offline.storage.tripsTitle')}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{allTrips.map((trip) => {
const { on, dateEligible } = tripStorageState(trip)
return (
<div key={trip.id} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12 }}>
<div style={{ minWidth: 0 }}>
<div className="text-content" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{trip.title}
</div>
<div className="text-content-muted" style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))' }}>
{on ? t('settings.offline.storage.tripOn') : t('settings.offline.storage.tripOff')}
{!dateEligible && ` · ${t('settings.offline.storage.tripFinished')}`}
</div>
</SettingRows>
{allTrips.length > 0 && (
<div>
<p className={EYEBROW} style={fs(10)}>{t('settings.offline.storage.tripsTitle')}</p>
<SettingRows>
{allTrips.map((trip) => {
const { on, dateEligible } = tripStorageState(trip)
return (
<div key={trip.id} className="flex items-center justify-between gap-4 px-3.5 py-3">
<div className="min-w-0">
<div className="truncate font-medium text-content" style={fs(13, 'body')}>
{trip.title}
</div>
<div className={on ? 'text-success' : 'text-content-faint'} style={fs(11.5)}>
{on ? t('settings.offline.storage.tripOn') : t('settings.offline.storage.tripOff')}
{!dateEligible && ` · ${t('settings.offline.storage.tripFinished')}`}
</div>
<ToggleSwitch on={on} onToggle={() => handleToggleTrip(trip)} label={trip.title} />
</div>
)
})}
</div>
</div>
)}
</div>
<ToggleSwitch on={on} onToggle={() => handleToggleTrip(trip)} label={trip.title} />
</div>
)
})}
</SettingRows>
</div>
)}
</Section>
{/* Cache stats + list + clear */}
<Section title={t('settings.offline.cache.title')} icon={Database}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
<Stat label={t('settings.offline.stats.trips')} value={rows.length} />
<Stat label={t('settings.offline.stats.pending')} value={pendingCount} />
{conflicts.length > 0 && <Stat label={t('settings.offline.stats.conflicts')} value={conflicts.length} danger />}
{failedCount > 0 && <Stat label={t('settings.offline.stats.failed')} value={failedCount} danger />}
</div>
<Section
title={t('settings.offline.cache.title')}
icon={Database}
action={
<button type="button"
// ConfirmDialog would be the planner's way; the tab's tests pin window.confirm.
onClick={() => { if (window.confirm(t('settings.offline.clearConfirm'))) void handleClear() }}
disabled={clearing || !canClear}
className={SETTINGS_BUTTON_DANGER}
style={fs(12.5, 'body')}
>
<Trash2 size={14} />
{t('settings.offline.clear')}
</button>
}
>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<Stat label={t('settings.offline.stats.trips')} value={rows.length} />
<Stat label={t('settings.offline.stats.pending')} value={pendingCount} />
{conflicts.length > 0 && <Stat label={t('settings.offline.stats.conflicts')} value={conflicts.length} danger />}
{failedCount > 0 && <Stat label={t('settings.offline.stats.failed')} value={failedCount} danger />}
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button type="button"
onClick={() => { if (window.confirm(t('settings.offline.clearConfirm'))) void handleClear() }}
disabled={clearing || !canClear}
className="border border-edge bg-surface-secondary text-[#ef4444]"
style={btnStyle(clearing || !canClear)}
>
<Trash2 size={14} />
{t('settings.offline.clear')}
</button>
{loading ? (
<div className="flex items-center gap-2 text-content-faint" style={fs(12, 'body')}>
<RefreshCw size={13} className="animate-spin" />
<span>{t('settings.offline.loading')}</span>
</div>
{loading ? (
<p className="text-content-muted" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))' }}>{t('settings.offline.loading')}</p>
) : rows.length === 0 ? (
<p className="text-content-muted" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))' }}>
{t(notice?.kind === 'load-failed' ? 'settings.offline.notice.loadFailed' : 'settings.offline.empty')}
</p>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{rows.map(({ trip, meta, placeCount, fileCount }) => (
<div
key={trip.id}
className="border border-edge bg-surface-secondary"
style={{ padding: '10px 14px', borderRadius: 8, display: 'flex', flexDirection: 'column', gap: 2 }}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span className="text-content" style={{ fontWeight: 600, fontSize: 'calc(14px * var(--fs-scale-body, 1))' }}>
{trip.title}
</span>
<span className="text-content-muted" style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))' }}>
{meta.lastSyncedAt
? new Date(meta.lastSyncedAt).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })
: '—'}
</span>
</div>
<span className="text-content-muted" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))' }}>
) : rows.length === 0 ? (
<SettingsHint>
{t(notice?.kind === 'load-failed' ? 'settings.offline.notice.loadFailed' : 'settings.offline.empty')}
</SettingsHint>
) : (
<SettingRows>
{rows.map(({ trip, meta, placeCount, fileCount }) => (
<div key={trip.id} className="flex items-center justify-between gap-4 px-3.5 py-3">
<div className="flex min-w-0 flex-col gap-0.5">
<span className="truncate font-semibold text-content" style={fs(13, 'body')}>
{trip.title}
</span>
<span className="truncate font-geist tabular-nums text-content-faint" style={fs(11.5)}>
{formatDate(trip.start_date)} – {formatDate(trip.end_date)}
{' · '}{placeCount}{' · '}{fileCount}
</span>
</div>
))}
</div>
)}
</div>
<span className="flex-none font-geist tabular-nums text-content-muted" style={fs(11.5)}>
{meta.lastSyncedAt
? new Date(meta.lastSyncedAt).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })
: '—'}
</span>
</div>
))}
</SettingRows>
)}
</Section>
</div>
)
}
function btnStyle(disabled: boolean): React.CSSProperties {
return {
display: 'flex', alignItems: 'center', gap: 6, padding: '8px 14px', borderRadius: 8,
cursor: disabled ? 'not-allowed' : 'pointer',
fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500, opacity: disabled ? 0.5 : 1,
}
}
function smallBtnStyle(): React.CSSProperties {
return {
padding: '6px 12px', borderRadius: 8, cursor: 'pointer',
fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 500,
}
}
function Row({ label, hint, control }: { label: string; hint?: string; control: React.ReactNode }) {
return (
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 16 }}>
<div style={{ minWidth: 0 }}>
<div className="text-content" style={{ fontWeight: 500, fontSize: 'calc(14px * var(--fs-scale-body, 1))' }}>{label}</div>
{hint && <div className="text-content-muted" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', marginTop: 2 }}>{hint}</div>}
</div>
<div style={{ flexShrink: 0 }}>{control}</div>
</div>
)
}
/** One counter tile: the number big, what it counts under it. */
function Stat({ label, value, danger }: { label: string; value: number; danger?: boolean }) {
return (
<div className="border border-edge bg-surface-secondary" style={{ padding: '8px 14px', borderRadius: 8, minWidth: 100 }}>
<div style={{ fontSize: 'calc(20px * var(--fs-scale-title, 1))', fontWeight: 700, color: danger ? '#ef4444' : undefined }}
className={danger ? undefined : 'text-content'}>{value}</div>
<div className="text-content-muted" style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))' }}>{label}</div>
<div className="min-w-0 rounded-[12px] border border-edge-faint bg-surface-card px-3.5 py-3">
<div className={`font-geist font-bold tabular-nums ${danger ? 'text-danger' : 'text-content'}`} style={fs(20, 'subtitle')}>{value}</div>
<div className="truncate text-content-faint" style={fs(11.5)}>{label}</div>
</div>
)
}
+154 -122
View File
@@ -1,10 +1,18 @@
import React, { useEffect, useState } from 'react'
import { Fingerprint, Plus, Trash2, Pencil, Check, X } from 'lucide-react'
import { Fingerprint, Plus, Trash2, Pencil, Check, X, AlertTriangle } from 'lucide-react'
import { startRegistration } from '@simplewebauthn/browser'
import { useTranslation } from '../../i18n'
import { useToast } from '../shared/Toast'
import { authApi, type PasskeyCredential } from '../../api/client'
import { getApiErrorMessage } from '../../types'
import Section from './Section'
import { Tooltip } from '../shared/Tooltip'
import { fs } from '../shared/DialogShell'
import { GRID_2, INPUT, PANEL } from '../shared/dialogParts'
import { SETTINGS_BUTTON, SETTINGS_BUTTON_PRIMARY, SETTINGS_ICON_BUTTON, SettingsHint, StatusPill } from './settingsKit'
/** The step-up's final answer: the danger fill, white on red like ConfirmDialog's. */
const DANGER_FILL = 'inline-flex items-center justify-center gap-1.5 rounded-[10px] bg-danger px-4 py-2 font-medium text-white hover:opacity-90 disabled:cursor-default disabled:opacity-50' // theme-lint-disable: white on the danger fill, as ConfirmDialog draws it
/** Parse a SQLite UTC timestamp ("YYYY-MM-DD HH:MM:SS") into a local date string. */
function fmtDate(ts: string | null): string | null {
@@ -113,159 +121,183 @@ export default function PasskeysSection({ demoMode }: { demoMode?: boolean }): R
if (!loading && !enabled && creds.length === 0) return null
return (
<div className="pt-4 mt-4 border-t border-edge-secondary">
<div className="flex items-center gap-2 mb-3">
<Fingerprint className="w-5 h-5 text-content-secondary" />
<h3 className="font-semibold text-base m-0 text-content">{t('settings.passkey.title')}</h3>
</div>
<div className="space-y-3">
<p className="text-sm m-0 text-content-muted" style={{ lineHeight: 1.5 }}>{t('settings.passkey.description')}</p>
<Section
title={t('settings.passkey.title')}
icon={Fingerprint}
badge={creds.length > 0 ? <StatusPill>{creds.length}</StatusPill> : undefined}
// The way in sits on the band, like the planner's "add" actions; the step-up opens in the body.
action={canAdd && !addOpen ? (
<button type="button" onClick={() => setAddOpen(true)} className={SETTINGS_BUTTON} style={fs(12.5, 'body')}>
<Plus size={14} />
{t('settings.passkey.add')}
</button>
) : undefined}
>
<SettingsHint>{t('settings.passkey.description')}</SettingsHint>
{enabled && !configured && (
<p className="text-sm m-0 text-amber-700">{t('settings.passkey.notConfigured')}</p>
)}
{enabled && !configured && (
<div className="flex gap-2.5 rounded-[12px] bg-warning-soft px-3.5 py-2.5 text-content" style={fs(12.5, 'body')}>
<AlertTriangle size={15} className="mt-px flex-none text-warning" />
<p className="m-0 leading-snug">{t('settings.passkey.notConfigured')}</p>
</div>
)}
{creds.length > 0 && (
<ul className="space-y-2 list-none p-0 m-0">
{creds.map(c => (
<li key={c.id} className="flex items-center gap-3 p-3 rounded-lg border border-edge bg-surface-card">
<Fingerprint className="w-4 h-4 flex-shrink-0 text-content-secondary" />
<div className="flex-1 min-w-0">
{renamingId === c.id ? (
<div className="flex items-center gap-2">
<input
autoFocus
type="text"
value={renameVal}
onChange={e => setRenameVal(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') void handleRename(c.id); if (e.key === 'Escape') setRenamingId(null) }}
className="flex-1 px-2 py-1 border border-slate-300 rounded text-sm"
/>
<button type="button" onClick={() => handleRename(c.id)} className="p-1 text-emerald-600" aria-label={t('common.save')}><Check size={16} /></button>
<button type="button" onClick={() => setRenamingId(null)} className="p-1 text-content-muted" aria-label={t('common.cancel')}><X size={16} /></button>
{creds.length > 0 && (
<ul className="m-0 list-none divide-y divide-edge-faint overflow-hidden rounded-[12px] border border-edge-faint bg-surface-card p-0">
{creds.map(c => (
<li key={c.id} className="flex items-center gap-3 px-3.5 py-3">
<span className="grid h-9 w-9 flex-none place-items-center rounded-[10px] bg-surface-tertiary text-content-secondary">
<Fingerprint size={16} strokeWidth={1.9} />
</span>
<div className="min-w-0 flex-1">
{renamingId === c.id ? (
<div className="flex items-center gap-2">
<input
autoFocus
type="text"
value={renameVal}
onChange={e => setRenameVal(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') void handleRename(c.id); if (e.key === 'Escape') setRenamingId(null) }}
aria-label={t('settings.passkey.rename')}
className={`${INPUT} flex-1`}
/>
<Tooltip label={t('common.save')}>
<button type="button" onClick={() => handleRename(c.id)} className={`${SETTINGS_ICON_BUTTON} !text-success`} aria-label={t('common.save')}>
<Check size={15} strokeWidth={2.4} />
</button>
</Tooltip>
<Tooltip label={t('common.cancel')}>
<button type="button" onClick={() => setRenamingId(null)} className={SETTINGS_ICON_BUTTON} aria-label={t('common.cancel')}>
<X size={15} />
</button>
</Tooltip>
</div>
) : (
<>
<div className="flex min-w-0 items-center gap-2">
<span className="min-w-0 truncate font-semibold text-content" style={fs(13, 'body')}>{c.name || t('settings.passkey.defaultName')}</span>
<StatusPill tone={c.backed_up ? 'success' : 'neutral'}>
{c.backed_up ? t('settings.passkey.synced') : t('settings.passkey.deviceBound')}
</StatusPill>
</div>
) : (
<>
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-content truncate">{c.name || t('settings.passkey.defaultName')}</span>
<span className="text-[10px] font-medium px-2 py-0.5 rounded-full bg-surface-hover text-content-secondary">
{c.backed_up ? t('settings.passkey.synced') : t('settings.passkey.deviceBound')}
</span>
</div>
<p className="text-xs m-0 mt-0.5 text-content-faint">
{t('settings.passkey.added')}: {fmtDate(c.created_at) || '—'}
{' · '}
{c.last_used_at
? `${t('settings.passkey.lastUsed')}: ${fmtDate(c.last_used_at)}`
: t('settings.passkey.neverUsed')}
</p>
</>
)}
</div>
{renamingId !== c.id && (
<div className="flex items-center gap-1 flex-shrink-0">
<p className="m-0 mt-0.5 truncate tabular-nums text-content-faint" style={fs(11.5)}>
{t('settings.passkey.added')}: {fmtDate(c.created_at) || '—'}
{' · '}
{c.last_used_at
? `${t('settings.passkey.lastUsed')}: ${fmtDate(c.last_used_at)}`
: t('settings.passkey.neverUsed')}
</p>
</>
)}
</div>
{renamingId !== c.id && (
<div className="flex flex-none items-center gap-1.5">
<Tooltip label={t('settings.passkey.rename')}>
<button
type="button"
onClick={() => { setRenamingId(c.id); setRenameVal(c.name || '') }}
className="p-1.5 rounded text-content-muted hover:text-content"
className={SETTINGS_ICON_BUTTON}
aria-label={t('settings.passkey.rename')}
>
<Pencil size={14} />
</button>
</Tooltip>
<Tooltip label={t('common.delete')}>
<button
type="button"
onClick={() => { setDeletingId(c.id); setDeletePwd('') }}
className="p-1.5 rounded text-red-500 hover:bg-red-50"
className={`${SETTINGS_ICON_BUTTON} hover:!bg-danger-soft hover:!text-danger`}
aria-label={t('common.delete')}
>
<Trash2 size={14} />
</button>
</div>
)}
</li>
))}
</ul>
)}
</Tooltip>
</div>
)}
</li>
))}
</ul>
)}
{/* Delete confirmation (password step-up) */}
{deletingId !== null && (
<div className="space-y-2 p-3 rounded-lg border border-red-200 bg-red-50/40">
<p className="text-sm font-medium m-0 text-content">{t('settings.passkey.deleteConfirm')}</p>
<input
type="password"
value={deletePwd}
onChange={e => setDeletePwd(e.target.value)}
placeholder={t('settings.currentPassword')}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
/>
<div className="flex gap-2">
<button
type="button"
disabled={busy || !deletePwd}
onClick={() => handleDelete(deletingId)}
className="px-4 py-2 rounded-lg text-sm font-medium text-red-600 border border-red-200 hover:bg-red-50 disabled:opacity-50"
>
{t('common.delete')}
</button>
<button
type="button"
onClick={() => { setDeletingId(null); setDeletePwd('') }}
className="px-4 py-2 rounded-lg text-sm border border-edge text-content-secondary"
>
{t('common.cancel')}
</button>
</div>
{/* Delete confirmation (password step-up) */}
{deletingId !== null && (
<div className="flex flex-col gap-3 rounded-[14px] border border-edge-faint bg-danger-soft p-3.5">
<p className="m-0 font-semibold text-content" style={fs(13, 'body')}>{t('settings.passkey.deleteConfirm')}</p>
<input
type="password"
value={deletePwd}
onChange={e => setDeletePwd(e.target.value)}
placeholder={t('settings.currentPassword')}
aria-label={t('settings.currentPassword')}
className={INPUT}
/>
<div className="flex justify-end gap-2" style={fs(13, 'body')}>
<button
type="button"
onClick={() => { setDeletingId(null); setDeletePwd('') }}
className={SETTINGS_BUTTON}
>
{t('common.cancel')}
</button>
<button
type="button"
disabled={busy || !deletePwd}
onClick={() => handleDelete(deletingId)}
className={DANGER_FILL}
>
<Trash2 size={14} />
{t('common.delete')}
</button>
</div>
)}
</div>
)}
{/* Add a passkey */}
{canAdd && (addOpen ? (
<div className="space-y-2 p-3 rounded-lg border border-edge bg-surface-hover">
<p className="text-sm font-medium m-0 text-content">{t('settings.passkey.addTitle')}</p>
<p className="text-xs m-0 text-content-muted">{t('settings.passkey.passwordPrompt')}</p>
{/* Add a passkey */}
{canAdd && addOpen && (
<div className={PANEL}>
<div>
<p className="m-0 font-semibold text-content" style={fs(13, 'body')}>{t('settings.passkey.addTitle')}</p>
<SettingsHint className="mt-0.5">{t('settings.passkey.passwordPrompt')}</SettingsHint>
</div>
<div className={GRID_2}>
<input
type="password"
value={addPwd}
onChange={e => setAddPwd(e.target.value)}
placeholder={t('settings.currentPassword')}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
aria-label={t('settings.currentPassword')}
className={INPUT}
/>
<input
type="text"
value={addName}
onChange={e => setAddName(e.target.value)}
placeholder={t('settings.passkey.namePlaceholder')}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
aria-label={t('settings.passkey.namePlaceholder')}
className={INPUT}
/>
<div className="flex gap-2">
<button
type="button"
disabled={busy || !addPwd}
onClick={handleAdd}
className="px-4 py-2 bg-slate-900 text-white rounded-lg text-sm hover:bg-slate-700 disabled:opacity-50"
>
{busy ? <div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" /> : t('settings.passkey.add')}
</button>
<button
type="button"
onClick={() => { setAddOpen(false); setAddPwd(''); setAddName('') }}
className="px-4 py-2 rounded-lg text-sm border border-edge text-content-secondary"
>
{t('common.cancel')}
</button>
</div>
</div>
) : (
<button
type="button"
onClick={() => setAddOpen(true)}
className="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors border border-edge bg-surface-card text-content"
>
<Plus size={14} />
{t('settings.passkey.add')}
</button>
))}
</div>
</div>
<div className="flex justify-end gap-2" style={fs(13, 'body')}>
<button
type="button"
onClick={() => { setAddOpen(false); setAddPwd(''); setAddName('') }}
className={SETTINGS_BUTTON}
>
{t('common.cancel')}
</button>
<button
type="button"
disabled={busy || !addPwd}
onClick={handleAdd}
className={SETTINGS_BUTTON_PRIMARY}
>
{busy
? <span className="h-3.5 w-3.5 animate-spin rounded-full border-2 border-current border-t-transparent" />
: <><Fingerprint size={14} />{t('settings.passkey.add')}</>}
</button>
</div>
</div>
)}
</Section>
)
}
@@ -1,11 +1,15 @@
import React, { useEffect, useMemo, useState } from 'react'
import { Camera, Save } from 'lucide-react'
import { Camera, Loader2, Plug, Save } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { useToast } from '../../components/shared/Toast'
import apiClient from '../../api/client'
import { useAddonStore } from '../../store/addonStore'
import Section from './Section'
import ToggleSwitch from './ToggleSwitch'
import { SETTINGS_BUTTON, SETTINGS_BUTTON_PRIMARY, SettingRow, SettingRows, StatusPill } from './settingsKit'
import { EditorField, INPUT } from '../shared/dialogParts'
import { fs } from '../shared/DialogShell'
import { Tooltip } from '../shared/Tooltip'
interface ProviderField {
key: string
@@ -220,72 +224,93 @@ export default function PhotoProvidersSection(): React.ReactElement {
const canSave = !!cfg.settings_put
const canTest = !!(cfg.test_post || cfg.test_get || cfg.status_get)
const saveDisabledReason = !canSave
? t('memories.saveRouteNotConfigured')
: isProviderSaveDisabled(provider) ? t('memories.fillRequiredFields') : ''
const fieldId = (key: string) => `photo-provider-${provider.id}-${key}`
// Text fields stack on top, the switches gather in one box of rows below them.
const textFields = fields.filter(field => field.input_type !== 'checkbox')
const switchFields = fields.filter(field => field.input_type === 'checkbox')
return (
<Section key={provider.id} title={provider.name || provider.id} icon={Camera}>
<div className="space-y-3">
{fields.map(field => (
<div key={`${provider.id}-${field.key}`}>
{field.input_type === 'checkbox' ? (
<div className="flex items-center gap-3">
<Section
key={provider.id}
title={provider.name || provider.id}
icon={Camera}
badge={
<StatusPill tone={connected ? 'success' : 'neutral'} icon={<span className="h-1.5 w-1.5 rounded-full bg-current" />}>
{connected ? t('memories.connected') : t('memories.disconnected')}
</StatusPill>
}
>
{textFields.length > 0 && (
<div className="flex flex-col gap-3">
{textFields.map(field => (
<EditorField
key={`${provider.id}-${field.key}`}
label={t(`memories.${field.label}`)}
htmlFor={fieldId(field.key)}
hint={field.hint ? t(`memories.${field.hint}`) : undefined}
>
<input
id={fieldId(field.key)}
type={field.input_type || 'text'}
value={values[field.key] || ''}
onChange={e => handleProviderFieldChange(provider.id, field.key, e.target.value)}
placeholder={field.secret && connected && !(values[field.key] || '') ? '••••••••' : (field.placeholder || '')}
className={INPUT}
/>
</EditorField>
))}
</div>
)}
{switchFields.length > 0 && (
<SettingRows>
{switchFields.map(field => (
<SettingRow
key={`${provider.id}-${field.key}`}
label={t(`memories.${field.label}`)}
hint={field.hint ? t(`memories.${field.hint}`) : undefined}
control={
<ToggleSwitch
on={values[field.key] === 'true'}
onToggle={() => handleProviderFieldChange(provider.id, field.key, values[field.key] === 'true' ? 'false' : 'true')}
label={t(`memories.${field.label}`)}
/>
<span className="text-sm font-medium text-slate-700">{t(`memories.${field.label}`)}</span>
</div>
) : (
<>
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t(`memories.${field.label}`)}</label>
<input
type={field.input_type || 'text'}
value={values[field.key] || ''}
onChange={e => handleProviderFieldChange(provider.id, field.key, e.target.value)}
placeholder={field.secret && connected && !(values[field.key] || '') ? '••••••••' : (field.placeholder || '')}
className="w-full px-3 py-2.5 border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-slate-300"
/>
{field.hint && (
<p className="mt-1 text-xs text-slate-500">{t(`memories.${field.hint}`)}</p>
)}
</>
)}
</div>
))}
{/* Wraps on mobile so the connection badge drops to its own row
instead of clipping off the side of the card. */}
<div className="flex flex-wrap items-center gap-3">
<button type="button"
onClick={() => handleSaveProvider(provider)}
disabled={!canSave || !!saving[provider.id] || isProviderSaveDisabled(provider)}
className="flex items-center gap-2 px-4 py-2 bg-slate-900 text-white rounded-lg text-sm hover:bg-slate-700 disabled:bg-slate-400"
title={!canSave ? t('memories.saveRouteNotConfigured') : isProviderSaveDisabled(provider) ? t('memories.fillRequiredFields') : ''}
>
<Save className="w-4 h-4" /> {t('common.save')}
</button>
<button type="button"
onClick={() => handleTestProvider(provider)}
disabled={!canTest || testing}
className="flex items-center gap-2 px-4 py-2 border border-slate-200 rounded-lg text-sm hover:bg-slate-50"
title={!canTest ? t('memories.testRouteNotConfigured') : ''}
>
{testing
? <div className="w-4 h-4 border-2 border-slate-300 border-t-slate-700 rounded-full animate-spin" />
: <Camera className="w-4 h-4" />}
<span className="sm:hidden">{t('memories.testShort')}</span>
<span className="hidden sm:inline">{t('memories.testConnection')}</span>
</button>
{/* On mobile the badge sits on its own row thanks to flex-wrap, so force a line break via basis-full. */}
{connected ? (
<span className="basis-full sm:basis-auto text-xs font-medium text-green-600 flex items-center gap-1">
<span className="w-2 h-2 bg-green-500 rounded-full" />
{t('memories.connected')}
</span>
) : (
<span className="basis-full sm:basis-auto text-xs font-medium text-slate-400 flex items-center gap-1">
<span className="w-2 h-2 bg-slate-300 rounded-full" />
{t('memories.disconnected')}
</span>
)}
</div>
}
/>
))}
</SettingRows>
)}
<div className="flex flex-wrap items-center gap-2" style={fs(13, 'body')}>
{/* The span carries the tooltip: a disabled button gets no pointer events. */}
<Tooltip label={saveDisabledReason} placement="top">
<span className="inline-flex">
<button type="button"
onClick={() => handleSaveProvider(provider)}
disabled={!canSave || !!saving[provider.id] || isProviderSaveDisabled(provider)}
className={SETTINGS_BUTTON_PRIMARY}
>
<Save size={14} strokeWidth={2.2} /> {t('common.save')}
</button>
</span>
</Tooltip>
<Tooltip label={!canTest ? t('memories.testRouteNotConfigured') : ''} placement="top">
<span className="inline-flex">
<button type="button"
onClick={() => handleTestProvider(provider)}
disabled={!canTest || testing}
className={SETTINGS_BUTTON}
>
{testing
? <Loader2 size={14} strokeWidth={2.2} className="animate-spin" />
: <Plug size={14} strokeWidth={2.2} />}
{t('memories.testConnection')}
</button>
</span>
</Tooltip>
</div>
</Section>
)
@@ -2,6 +2,9 @@ import { useEffect, useState } from 'react'
import { History, RefreshCw } from 'lucide-react'
import { pluginsApi } from '../../api/client'
import { useTranslation } from '../../i18n'
import { Tooltip } from '../shared/Tooltip'
import { fs } from '../shared/DialogShell'
import { SettingsCard, SettingsHint, SETTINGS_ICON_BUTTON } from './settingsKit'
interface ActivityRow {
ts: string
@@ -23,6 +26,9 @@ function codeTone(code: string): string {
return 'bg-warning-soft text-warning'
}
const TH = 'whitespace-nowrap px-3 py-2.5 text-left font-geist font-bold uppercase tracking-[.08em] text-content-faint'
const TD = 'px-3 py-3 align-middle'
/**
* The signed-in user's own plugin activity log — every host-mediated action a
* plugin took while bound to them, newest first. The user-facing half of the
@@ -50,54 +56,52 @@ export default function PluginActivityPanel() {
return Number.isNaN(d.getTime()) ? ts : d.toLocaleString(locale)
}
return (
<div className="rounded-xl border border-edge bg-surface-card overflow-hidden">
<div className="px-5 py-4 border-b border-edge-secondary flex items-center gap-3">
<History className="w-4 h-4 flex-shrink-0 text-content-secondary" />
<div className="flex-1 min-w-0">
<h3 className="text-sm font-semibold text-content">{t('settings.pluginActivity.title')}</h3>
<p className="text-xs text-content-muted mt-0.5">{t('settings.pluginActivity.description')}</p>
</div>
<button
type="button"
onClick={load}
disabled={loading}
className="inline-flex items-center gap-1.5 rounded-lg border border-edge px-2.5 py-1.5 text-xs font-medium text-content-secondary hover:text-content disabled:opacity-60"
>
<RefreshCw className={`w-3.5 h-3.5 ${loading ? 'animate-spin' : ''}`} />
{t('settings.pluginActivity.refresh')}
</button>
</div>
const refreshLabel = t('settings.pluginActivity.refresh')
return (
<SettingsCard
icon={History}
title={t('settings.pluginActivity.title')}
hint={t('settings.pluginActivity.description')}
action={
<Tooltip label={refreshLabel}>
<button type="button" onClick={load} disabled={loading} aria-label={refreshLabel} className={SETTINGS_ICON_BUTTON}>
<RefreshCw size={14} className={loading ? 'animate-spin' : undefined} />
</button>
</Tooltip>
}
>
{rows.length === 0 ? (
<p className="px-5 py-6 text-sm text-content-muted">
<SettingsHint>
{loading ? t('common.loading') : t('settings.pluginActivity.empty')}
</p>
</SettingsHint>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm border-collapse">
<div className={`overflow-x-auto rounded-[12px] border border-edge-faint bg-surface-card ${loading ? 'opacity-60' : ''}`}>
<table className="w-full border-collapse" style={fs(12.5, 'body')}>
<thead>
<tr className="text-left text-xs uppercase tracking-wide text-content-faint border-b border-edge-secondary">
<th className="font-medium px-5 py-2">{t('settings.pluginActivity.columns.plugin')}</th>
<th className="font-medium px-3 py-2">{t('settings.pluginActivity.columns.action')}</th>
<th className="font-medium px-3 py-2">{t('settings.pluginActivity.columns.resource')}</th>
<th className="font-medium px-3 py-2 whitespace-nowrap">{t('settings.pluginActivity.columns.when')}</th>
<th className="font-medium px-5 py-2 text-right">{t('settings.pluginActivity.columns.status')}</th>
<tr className="border-b border-edge-faint bg-surface-secondary" style={fs(10)}>
<th className={`${TH} pl-3.5`}>{t('settings.pluginActivity.columns.plugin')}</th>
<th className={TH}>{t('settings.pluginActivity.columns.action')}</th>
<th className={TH}>{t('settings.pluginActivity.columns.resource')}</th>
<th className={TH}>{t('settings.pluginActivity.columns.when')}</th>
<th className={`${TH} pr-3.5 text-right`}>{t('settings.pluginActivity.columns.status')}</th>
</tr>
</thead>
<tbody>
<tbody className="divide-y divide-edge-faint">
{rows.map((r, i) => (
<tr key={i} className="border-b border-edge-secondary last:border-b-0">
<td className="px-5 py-2.5 text-content align-top">{r.plugin_name || r.plugin_id}</td>
<td className="px-3 py-2.5 align-top">
<span className="font-mono text-xs text-content-secondary">{r.method}</span>
<tr key={i}>
<td className={`${TD} max-w-[200px] pl-3.5`}>
<span className="block truncate font-medium text-content">{r.plugin_name || r.plugin_id}</span>
</td>
<td className="px-3 py-2.5 align-top">
<span className="font-mono text-xs text-content-muted">{r.resource || '—'}</span>
<td className={TD}>
<span className="font-mono text-content-secondary" style={fs(11.5)}>{r.method}</span>
</td>
<td className="px-3 py-2.5 align-top whitespace-nowrap text-content-muted">{fmtWhen(r.ts)}</td>
<td className="px-5 py-2.5 align-top text-right">
<span className={`inline-block rounded-full px-2 py-0.5 text-[11px] font-medium ${codeTone(r.code)}`}>
<td className={`${TD} max-w-[220px]`}>
<span className="block truncate font-mono text-content-muted" style={fs(11.5)} title={r.resource || undefined}>{r.resource || '—'}</span>
</td>
<td className={`${TD} whitespace-nowrap font-geist tabular-nums text-content-muted`}>{fmtWhen(r.ts)}</td>
<td className={`${TD} pr-3.5 text-right`}>
<span className={`inline-block whitespace-nowrap rounded-full px-2 py-[2px] font-geist font-semibold ${codeTone(r.code)}`} style={fs(11)}>
{r.code}
</span>
</td>
@@ -107,6 +111,6 @@ export default function PluginActivityPanel() {
</table>
</div>
)}
</div>
</SettingsCard>
)
}
@@ -41,7 +41,7 @@ function setPlugins(plugins: ActivePlugin[]): void {
}
/** The settings card for a plugin (the heading's rounded container). */
const cardFor = (name: string) => screen.getByRole('heading', { name }).closest('div.rounded-xl') as HTMLElement;
const cardFor = (name: string) => screen.getByRole('heading', { name }).closest('section') as HTMLElement;
beforeEach(() => {
vi.clearAllMocks();
@@ -160,10 +160,13 @@ describe('PluginSettingsTab', () => {
setPlugins([plugin()]);
render(<PluginSettingsTab />);
const select = await screen.findByRole('combobox') as HTMLSelectElement;
expect(select.value).toBe('c');
await user.selectOptions(select, 'f');
expect(select.value).toBe('f');
// A CustomSelect: the field's label names the trigger, the options open in a portal.
const trigger = await screen.findByRole('button', { name: 'Unit' });
expect(trigger).toHaveTextContent('Celsius');
await user.click(trigger);
const choices = await screen.findAllByRole('button', { name: 'Fahrenheit' });
await user.click(choices[choices.length - 1]);
expect(trigger).toHaveTextContent('Fahrenheit');
});
it('FE-COMP-PLUGINSETTINGS-009: a select field without options falls back to a text input', async () => {
@@ -1,6 +1,10 @@
import { useEffect, useState } from 'react'
import { Save, Loader2, Link2, Unlink, CheckCircle } from 'lucide-react'
import PluginIcon from '../shared/PluginIcon'
import { Fragment, useEffect, useState, type ReactNode } from 'react'
import { Save, Loader2, Link2, Unlink, CheckCircle, Puzzle, Zap } from 'lucide-react'
import { resolvePluginIcon } from '../shared/PluginIcon'
import CustomSelect from '../shared/CustomSelect'
import { fs } from '../shared/DialogShell'
import { EditorField, INPUT } from '../shared/dialogParts'
import { SettingRow, SettingRows, SettingsCard, SettingsHint, SETTINGS_BUTTON, SETTINGS_BUTTON_DANGER, SETTINGS_BUTTON_PRIMARY } from './settingsKit'
import PluginFrame from '../Plugins/PluginFrame'
import { pluginsApi, type PluginUserSettingField, type PluginAction } from '../../api/client'
import { usePluginStore } from '../../store/pluginStore'
@@ -9,6 +13,40 @@ import { useTranslation } from '../../i18n'
import PluginActivityPanel from './PluginActivityPanel'
import { seedSettingsValues, findMissingRequired, settingsPatch } from '../Plugins/settingsForm'
type FieldGroup = { kind: 'switches' | 'fields'; fields: PluginUserSettingField[] }
/** Runs of on/off fields become one box of switch rows; everything else stays a field. */
function groupFields(fields: PluginUserSettingField[]): FieldGroup[] {
const groups: FieldGroup[] = []
for (const f of fields) {
const kind = f.input_type === 'checkbox' ? 'switches' : 'fields'
const last = groups[groups.length - 1]
if (last && last.kind === kind) last.fields.push(f)
else groups.push({ kind, fields: [f] })
}
return groups
}
/**
* A declared checkbox in the look of ToggleSwitch. It stays a real checkbox
* underneath, so a label points at it and assistive tech reads it as one.
*/
function CheckSwitch({ id, checked, onChange }: { id: string; checked: boolean; onChange: (checked: boolean) => void }) {
return (
<span className="relative inline-flex h-6 w-11 flex-none">
<input
id={id}
type="checkbox"
checked={checked}
onChange={e => onChange(e.target.checked)}
className="peer absolute inset-0 z-10 m-0 h-full w-full cursor-pointer opacity-0"
/>
<span aria-hidden="true" className="pointer-events-none absolute inset-0 rounded-full bg-[color:var(--border-primary)] transition-colors peer-checked:bg-accent peer-focus-visible:ring-2 peer-focus-visible:ring-[color:var(--text-primary)]" />
<span aria-hidden="true" className="pointer-events-none absolute left-0.5 top-0.5 h-5 w-5 rounded-full bg-surface-card shadow-sm transition-[left] duration-200 peer-checked:left-[22px] peer-checked:bg-[color:var(--accent-text)]" />
</span>
)
}
/** Host-brokered OAuth: a Connect/Disconnect control. The host runs the whole flow +
* holds the tokens; this only triggers connect (redirect to the provider) / disconnect. */
function PluginOAuthSection({ id, state, setState }: {
@@ -39,16 +77,16 @@ function PluginOAuthSection({ id, state, setState }: {
}
return (
<div className="mt-4 flex items-center justify-between rounded-lg border border-border bg-surface-secondary px-3 py-2">
<span className="flex items-center gap-2 text-sm text-content-secondary">
{state.connected
? <><CheckCircle className="w-4 h-4 text-success" /> {t('settings.plugins.oauth.connected')}</>
: <>{t('settings.plugins.oauth.notConnected')}</>}
</span>
{state.connected
? <button type="button" onClick={disconnect} disabled={busy} className="inline-flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-sm font-semibold text-content disabled:opacity-60"><Unlink className="w-4 h-4" />{t('settings.plugins.oauth.disconnect')}</button>
: <button type="button" onClick={connect} disabled={busy} className="inline-flex items-center gap-1.5 rounded-lg bg-accent px-3 py-1.5 text-sm font-semibold text-white disabled:opacity-60">{busy ? <Loader2 className="w-4 h-4 animate-spin" /> : <Link2 className="w-4 h-4" />}{t('settings.plugins.oauth.connect')}</button>}
</div>
<SettingRows>
<SettingRow
label={state.connected
? <span className="inline-flex items-center gap-1.5"><CheckCircle size={14} className="flex-none text-success" />{t('settings.plugins.oauth.connected')}</span>
: t('settings.plugins.oauth.notConnected')}
control={state.connected
? <button type="button" onClick={disconnect} disabled={busy} className={SETTINGS_BUTTON} style={fs(13, 'body')}><Unlink size={14} />{t('settings.plugins.oauth.disconnect')}</button>
: <button type="button" onClick={connect} disabled={busy} className={SETTINGS_BUTTON_PRIMARY} style={fs(13, 'body')}>{busy ? <Loader2 size={14} className="animate-spin" /> : <Link2 size={14} />}{t('settings.plugins.oauth.connect')}</button>}
/>
</SettingRows>
)
}
@@ -125,92 +163,103 @@ function PluginSettingsForm({ id, name, icon }: { id: string; name: string; icon
}
}
const fieldId = (key: string) => `plugin-setting-${id}-${key}`
const setValue = (key: string, value: string | boolean) => setValues(v => ({ ...v, [key]: value }))
const labelOf = (f: PluginUserSettingField): ReactNode => (
<>{f.label || f.key}{f.required && <span className="text-danger"> *</span>}</>
)
return (
<div className="rounded-xl border border-border bg-surface p-5">
<div className="flex items-center gap-2 mb-4">
<PluginIcon name={icon} className="w-4 h-4 text-content-secondary" />
<h3 className="text-sm font-semibold text-content">{name}</h3>
</div>
<div className="space-y-4">
{(fields ?? []).map(f => (
<label key={f.key} className="block">
<span className="block text-sm font-medium text-content-secondary mb-1">
{f.label || f.key}{f.required && <span className="text-danger"> *</span>}
</span>
{f.input_type === 'checkbox' ? (
<input
type="checkbox"
checked={values[f.key] === true}
onChange={e => setValues(v => ({ ...v, [f.key]: e.target.checked }))}
className="h-4 w-4 rounded border-border"
/>
) : f.input_type === 'select' && f.options ? (
<select
value={String(values[f.key] ?? '')}
onChange={e => setValues(v => ({ ...v, [f.key]: e.target.value }))}
className="w-full rounded-lg border border-border bg-surface-secondary px-3 py-2 text-sm text-content"
>
<option value="">—</option>
{f.options.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
) : (
<input
type={f.secret ? 'password' : (f.input_type === 'number' ? 'number' : 'text')}
value={String(values[f.key] ?? '')}
placeholder={f.placeholder || ''}
autoComplete={f.secret ? 'new-password' : 'off'}
onChange={e => setValues(v => ({ ...v, [f.key]: e.target.value }))}
className="w-full rounded-lg border border-border bg-surface-secondary px-3 py-2 text-sm text-content"
/>
)}
{f.hint && <span className="block text-xs text-content-muted mt-1">{f.hint}</span>}
</label>
))}
</div>
<SettingsCard icon={resolvePluginIcon(icon)} title={name}>
{hasFields && (
<button type="button"
onClick={save}
disabled={saving}
className="mt-4 inline-flex items-center gap-2 rounded-lg bg-accent px-4 py-2 text-sm font-semibold text-white disabled:opacity-60"
>
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
{t('common.save')}
</button>
<div className={`flex flex-col gap-4 ${saving ? 'opacity-60' : ''}`}>
{groupFields(fields ?? []).map(group => group.kind === 'switches' ? (
// Consecutive on/off fields share one box of rows, like the rest of settings.
<SettingRows key={group.fields[0].key}>
{group.fields.map(f => (
<SettingRow
key={f.key}
label={labelOf(f)}
hint={f.hint || undefined}
htmlFor={fieldId(f.key)}
control={
<CheckSwitch
id={fieldId(f.key)}
checked={values[f.key] === true}
onChange={checked => setValue(f.key, checked)}
/>
}
/>
))}
</SettingRows>
) : (
<Fragment key={group.fields[0].key}>
{group.fields.map(f => (
<EditorField key={f.key} label={labelOf(f)} htmlFor={fieldId(f.key)} hint={f.hint || undefined}>
{f.input_type === 'select' && f.options ? (
<CustomSelect
id={fieldId(f.key)}
value={String(values[f.key] ?? '')}
onChange={v => setValue(f.key, String(v))}
options={[{ value: '', label: '—' }, ...f.options.map(o => ({ value: o.value, label: o.label }))]}
/>
) : (
<input
id={fieldId(f.key)}
type={f.secret ? 'password' : (f.input_type === 'number' ? 'number' : 'text')}
value={String(values[f.key] ?? '')}
placeholder={f.placeholder || ''}
autoComplete={f.secret ? 'new-password' : 'off'}
onChange={e => setValue(f.key, e.target.value)}
className={INPUT}
/>
)}
</EditorField>
))}
</Fragment>
))}
<div className="flex justify-end">
<button type="button" onClick={save} disabled={saving} className={SETTINGS_BUTTON_PRIMARY} style={fs(13, 'body')}>
{saving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
{t('common.save')}
</button>
</div>
</div>
)}
{actions.length > 0 && (
<div className="mt-4 border-t border-border pt-4">
<span className="block text-xs font-semibold uppercase tracking-wide text-content-muted mb-2">
<div>
<p className="m-0 mb-2 flex items-center gap-1.5 font-geist font-bold uppercase tracking-[.08em] text-content-faint" style={fs(10)}>
<Zap size={11} className="flex-none" />
{t('settings.plugins.actions')}
</span>
<div className="flex flex-col gap-2">
</p>
<SettingRows>
{actions.map(a => {
const res = actionResult[a.key]
return (
<div key={a.key} className="flex flex-wrap items-center gap-2">
<div key={a.key} className="flex flex-wrap items-center gap-x-3 gap-y-1.5 px-3.5 py-3">
<button type="button"
onClick={() => runAction(a)}
disabled={running !== null}
className={`inline-flex items-center gap-2 rounded-lg border px-3 py-1.5 text-sm font-semibold disabled:opacity-60 ${
a.danger ? 'border-danger text-danger' : 'border-border text-content'
}`}
className={a.danger ? SETTINGS_BUTTON_DANGER : SETTINGS_BUTTON}
style={fs(13, 'body')}
>
{running === a.key && <Loader2 className="w-4 h-4 animate-spin" />}
{running === a.key && <Loader2 size={14} className="animate-spin" />}
{a.label}
</button>
{a.hint && <span className="text-xs text-content-muted">{a.hint}</span>}
{a.hint && <span className="min-w-0 flex-1 text-content-faint" style={fs(11.5)}>{a.hint}</span>}
{res && (
<span className={`text-xs font-medium ${res.ok ? 'text-success' : 'text-danger'}`}>
<span className={`ml-auto font-medium ${res.ok ? 'text-success' : 'text-danger'}`} style={fs(12, 'body')}>
{res.message || (res.ok ? t('common.success') : t('common.error'))}
</span>
)}
</div>
)
})}
</div>
</SettingRows>
</div>
)}
<PluginOAuthSection id={id} state={oauth} setState={setOauth} />
</div>
</SettingsCard>
)
}
@@ -223,20 +272,16 @@ function PluginSettingsForm({ id, name, icon }: { id: string; name: string; icon
*/
function PluginSettingsUiCard({ id, name, icon }: { id: string; name: string; icon: string | null }) {
return (
<div className="rounded-xl border border-border bg-surface p-5">
<div className="flex items-center gap-2 mb-4">
<PluginIcon name={icon} className="w-4 h-4 text-content-secondary" />
<h3 className="text-sm font-semibold text-content">{name}</h3>
</div>
<SettingsCard icon={resolvePluginIcon(icon)} title={name}>
{/* min-height covers the beat before the frame's first trek:resize lands.
color-scheme light on the frame element matches the sandboxed document's
default ("normal"): mismatched schemes make Chromium paint an opaque
white canvas behind the transparent frame (same trap .hero-overlay-frame
guards against), which glares in dark mode. */}
<div className="min-h-[120px]">
<div className="min-h-[120px] overflow-hidden rounded-[12px] border border-edge-faint bg-surface-card">
<PluginFrame pluginId={id} path="settings.html" title={name} surface="user-settings" className="[color-scheme:light]" />
</div>
</div>
</SettingsCard>
)
}
@@ -245,19 +290,16 @@ export default function PluginSettingsTab() {
const plugins = usePluginStore(s => s.plugins)
return (
<div className="space-y-4">
<div>
<h2 className="text-lg font-semibold text-content">{t('settings.plugins.title')}</h2>
<p className="text-sm text-content-muted">{t('settings.plugins.subtitle')}</p>
</div>
{plugins.length === 0
? <p className="text-sm text-content-muted">{t('settings.plugins.empty')}</p>
: plugins.map(p => (
<div key={p.id} className="space-y-4">
<PluginSettingsForm id={p.id} name={p.name} icon={p.icon} />
{p.settingsUi && <PluginSettingsUiCard id={p.id} name={p.name} icon={p.icon} />}
</div>
))}
<div>
<SettingsCard icon={Puzzle} title={t('settings.plugins.title')} hint={t('settings.plugins.subtitle')}>
{plugins.length === 0 ? <SettingsHint>{t('settings.plugins.empty')}</SettingsHint> : null}
</SettingsCard>
{plugins.map(p => (
<Fragment key={p.id}>
<PluginSettingsForm id={p.id} name={p.name} icon={p.icon} />
{p.settingsUi && <PluginSettingsUiCard id={p.id} name={p.name} icon={p.icon} />}
</Fragment>
))}
<PluginActivityPanel />
</div>
)
@@ -1,5 +1,8 @@
import React from 'react'
/** The track and the knob glide with the same easing the planner's panels use. */
const EASE = 'cubic-bezier(0.32, 0.72, 0, 1)'
/**
* `describedBy` names the element that explains the switch, for a switch whose label alone
* does not say what flipping it changes.
@@ -7,20 +10,24 @@ import React from 'react'
export default function ToggleSwitch({ on, onToggle, label, describedBy }: { on: boolean; onToggle: () => void; label?: string; describedBy?: string }) {
return (
<button type="button" onClick={onToggle} aria-pressed={on} aria-label={label} aria-describedby={describedBy}
// A keyboard focus ring in the text colour, like the fields; the pointer gets none.
className="group outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--text-primary)] focus-visible:ring-offset-2 focus-visible:ring-offset-[color:var(--bg-card)]"
style={{
position: 'relative', width: 44, height: 24, minWidth: 44, flexShrink: 0,
borderRadius: 12, border: 'none', padding: 0, cursor: 'pointer',
background: on ? 'var(--accent, #111827)' : 'var(--border-primary, #d1d5db)',
transition: 'background 0.2s',
boxShadow: 'inset 0 1px 2px color-mix(in srgb, var(--text-primary) 12%, transparent)',
transition: `background 0.2s ${EASE}`,
}}>
{/* The knob reads against its own track: --accent-text is what the palette
already picked to sit on --accent, so a light accent gets a dark knob
instead of the white-on-near-white it used to be in dark mode. */}
<span style={{
<span className="group-active:scale-95" style={{
position: 'absolute', top: 2, left: on ? 22 : 2,
width: 20, height: 20, borderRadius: '50%',
background: on ? 'var(--accent-text, #ffffff)' : 'var(--bg-card, #ffffff)',
transition: 'left 0.2s', boxShadow: 'var(--shadow-sm, 0 1px 3px rgba(0,0,0,0.2))',
transition: `left 0.22s ${EASE}, transform 0.15s ease`,
boxShadow: 'var(--shadow-sm, 0 1px 3px rgba(0,0,0,0.2))',
}} />
</button>
)
+36 -29
View File
@@ -1,6 +1,9 @@
import React from 'react'
import { BellRing } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { useWebPush } from '../../hooks/useWebPush'
import { fs } from '../shared/DialogShell'
import { SettingsCard, SETTINGS_BUTTON, SETTINGS_BUTTON_PRIMARY } from './settingsKit'
/**
* The "Push notifications on this device" card of the Notifications tab. Only
@@ -12,36 +15,40 @@ export default function WebPushCard(): React.ReactElement {
const push = useWebPush()
return (
<div className="mb-4 rounded-lg border border-edge bg-surface-secondary p-3">
<p className="mb-1 text-caption font-semibold text-content-secondary">{t('settings.webPush.title')}</p>
<p className="mb-2 text-caption text-content-faint">{t('settings.webPush.hint')}</p>
{push.stateText && (
<p role="status" className="mb-2 text-caption text-content-secondary">
{push.stateText}
</p>
)}
{push.canSwitch && (
<div className="flex items-center gap-2">
<button
type="button"
onClick={push.toggle}
disabled={push.busy}
className={`rounded-md px-3 py-1.5 text-caption disabled:cursor-not-allowed disabled:opacity-60 ${
push.on ? 'border border-edge bg-transparent text-content-secondary' : 'bg-accent text-accent-text'
}`}
>
{push.switchLabel}
</button>
<button
type="button"
onClick={push.sendTest}
disabled={!push.canTest}
className="rounded-md border border-edge bg-transparent px-3 py-1.5 text-caption text-content-secondary disabled:cursor-not-allowed disabled:opacity-50"
>
{t('settings.notificationPreferences.sendTest')}
</button>
<SettingsCard icon={BellRing} title={t('settings.webPush.title')} hint={t('settings.webPush.hint')}>
{(push.stateText || push.canSwitch) && (
<div className="flex flex-wrap items-center gap-x-4 gap-y-3 rounded-[12px] border border-edge-faint bg-surface-card px-3.5 py-3">
{push.stateText
? (
<p role="status" className="m-0 min-w-0 flex-1 basis-56 text-content-secondary" style={fs(12.5, 'body')}>
{push.stateText}
</p>
)
: <span className="flex-1" />}
{push.canSwitch && (
<div className="flex flex-none flex-wrap items-center gap-2">
<button
type="button"
onClick={push.sendTest}
disabled={!push.canTest}
className={SETTINGS_BUTTON}
style={fs(13, 'body')}
>
{t('settings.notificationPreferences.sendTest')}
</button>
<button
type="button"
onClick={push.toggle}
disabled={push.busy}
className={push.on ? SETTINGS_BUTTON : SETTINGS_BUTTON_PRIMARY}
style={fs(13, 'body')}
>
{push.switchLabel}
</button>
</div>
)}
</div>
)}
</div>
</SettingsCard>
)
}