mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 22:46:32 +00:00
feat(settings,admin): every tab and popup in the planner's design
This commit is contained in:
@@ -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('');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user