feat(settings): one look for every user and admin settings page

The settings pages had grown into a mix of styles: folds, dialogs behind
rows, full-width rows next to boxed ones, pills for every figure and buttons
in four sizes. Every page now uses the same kit.

- Cards keep their tinted head band; every part of a card is a rounded block
  with its own small head (SettingsBlock), and rows sit in rounded boxes.
- Nothing folds and nothing hides behind a dialog: the MCP config, OAuth
  scopes, mail how-to, AI privacy note and addon sub-options are all shown.
- Each page is split into two columns balanced by length (SettingsColumns).
- Long explanations moved into an info mark next to the label (HintMark).
- One button size everywhere (text-control); tab names are one word.
- Admin: the old Settings tab is split into Security and Services
  (?tab=settings still opens Security, and the phone keeps its single
  Settings section for both); the Integration group is gone, live flights
  and mail import sit next to the addons they configure.
- Addons: one card per type with rows, no search, filter or fold.
- Quota, TREK API facts, mail import status and Google's daily limit are
  compact lines and strips instead of tiles and pills.
- Language is a select; routing has its own card under the defaults;
  storage saves from the foot of its categories card.
- Unused keys, FoldSummary and the page-width wrapper are removed.
This commit is contained in:
Maurice
2026-11-13 23:18:28 +01:00
parent 5fe3dea4cc
commit 94ac7673a5
138 changed files with 1299 additions and 2188 deletions
@@ -53,12 +53,6 @@ function isOn(toggle: HTMLElement): boolean {
return toggle.getAttribute('aria-pressed') === 'true';
}
/** A shelf of more than one row starts folded; open every one so its rows can be read. */
async function unfoldShelves(): Promise<void> {
const bars = await screen.findAllByRole('button', { name: /\d+ options?/, expanded: false });
for (const bar of bars) await userEvent.click(bar);
}
beforeAll(() => {
Object.defineProperty(window, 'matchMedia', {
writable: true,
@@ -236,7 +230,6 @@ describe('AddonManager', () => {
)
);
render(<AddonManager />);
await unfoldShelves();
// Provider sub-rows are visible under Journey addon
await screen.findByText('Unsplash');
@@ -272,15 +265,6 @@ describe('AddonManager', () => {
expect(screen.getByText('No addons available')).toBeInTheDocument();
});
it('FE-ADMIN-ADDON-013: dark mode swaps the wordmark in the header', async () => {
// The wordmark reads the .dark class, the same source applyAppearance() writes.
document.documentElement.classList.add('dark');
render(<AddonManager />);
await screen.findByText('No addons available');
expect(screen.getByAltText('TREK')).toHaveAttribute('src', '/text-light.svg');
});
it('FE-ADMIN-ADDON-014: photo-flavoured trip addons are hidden from the trip section', async () => {
server.use(addonsRoute([
buildAddon({ id: 'photos', name: 'Memories', icon: 'Image' }),
@@ -302,7 +286,6 @@ describe('AddonManager', () => {
buildAddon({ id: 'unsplash', name: 'Unsplash', description: 'Stock photos', type: 'photo_provider', enabled: false }),
]));
render(<AddonManager />);
await unfoldShelves();
await screen.findByText('Immich');
// immich and synologyphotos ship a vendor glyph, unsplash does not
@@ -437,7 +420,6 @@ describe('AddonManager', () => {
]));
render(<AddonManager />);
await unfoldShelves();
await screen.findByText('Paperless-ngx');
const paperlessRow = screen.getByText('Paperless-ngx').closest('li') as HTMLElement;
@@ -536,7 +518,6 @@ describe('AddonManager', () => {
/>,
);
await unfoldShelves();
await screen.findByText('Chat');
expect(screen.getByText('Notes')).toBeInTheDocument();
@@ -567,7 +548,6 @@ describe('AddonManager', () => {
onToggleCollabFeature={vi.fn()}
/>
);
await unfoldShelves();
const links = await screen.findByText('Links');
expect(links).toHaveAttribute('title');
@@ -623,7 +603,7 @@ describe('AddonManager', () => {
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
});
it('FE-ADMIN-ADDON-040: a shelf of several options starts folded and says how many there are and how many are on', async () => {
it('FE-ADMIN-ADDON-040: the options of an addon are listed under it at once, nothing to unfold', async () => {
server.use(addonsRoute([buildAddon({ id: 'collab', name: 'Collab', enabled: true })]));
render(
<AddonManager
@@ -632,16 +612,9 @@ describe('AddonManager', () => {
/>
);
const bar = await screen.findByRole('button', { name: /5 options/, expanded: false });
expect(bar).toHaveTextContent('2 on');
expect(screen.queryByText('Chat')).not.toBeInTheDocument();
await userEvent.click(bar);
expect(bar).toHaveAttribute('aria-expanded', 'true');
expect(screen.getByText('Chat')).toBeInTheDocument();
await userEvent.click(bar);
expect(screen.queryByText('Chat')).not.toBeInTheDocument();
expect(await screen.findByRole('button', { name: 'Chat' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Polls' })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /\d+ options?/ })).not.toBeInTheDocument();
});
it('FE-ADMIN-ADDON-041: a shelf of one option shows it at once, without a fold', async () => {
@@ -668,10 +641,6 @@ describe('AddonManager', () => {
</>
);
// Folded like Collab's and Documents' options until the bar is opened.
await screen.findByRole('button', { name: /\d+ options?/, expanded: false });
expect(screen.queryByRole('button', { name: 'wanderer' })).not.toBeInTheDocument();
await unfoldShelves();
const toggle = await screen.findByRole('button', { name: 'wanderer' });
expect(isOn(toggle)).toBe(false);
await userEvent.click(toggle);
@@ -685,8 +654,7 @@ describe('AddonManager', () => {
server.use(addonsRoute([buildAddon({ id: 'tours', name: 'Tours', icon: 'Mountain', enabled: true })]));
render(<AddonManager />);
await screen.findByRole('button', { name: /\d+ options?/, expanded: false });
await unfoldShelves();
await screen.findByText('Tours');
expect(screen.queryByRole('button', { name: 'wanderer' })).not.toBeInTheDocument();
});
@@ -702,7 +670,6 @@ describe('AddonManager', () => {
</>
);
await unfoldShelves();
await userEvent.click(await screen.findByRole('button', { name: 'wanderer' }));
await waitFor(() => expect(isOn(screen.getByRole('button', { name: 'wanderer' }))).toBe(false));
@@ -723,33 +690,4 @@ describe('AddonManager', () => {
expect(roadtrip.querySelector('svg.lucide-car-front')).toBeInTheDocument();
expect(tours.querySelector('svg.lucide-puzzle')).not.toBeInTheDocument();
});
it('FE-ADMIN-ADDON-045: finds an addon by name or description and shows the ones on or off', async () => {
const user = userEvent.setup();
server.use(
addonsRoute([
buildAddon({ id: 'x-lists', name: 'Todo List', description: 'Track tasks', type: 'trip', enabled: true }),
buildAddon({ id: 'x-costs', name: 'Budget', description: 'Split costs', type: 'trip', enabled: false }),
buildAddon({ id: 'x-holiday', name: 'Vacay', description: 'Holiday planner', type: 'global', enabled: true }),
])
);
render(<AddonManager />);
await screen.findByText('Todo List');
await user.type(screen.getByRole('searchbox', { name: 'Search addons' }), 'costs');
expect(screen.getByText('Budget')).toBeInTheDocument();
expect(screen.queryByText('Todo List')).toBeNull();
expect(screen.queryByText('Vacay')).toBeNull();
await user.clear(screen.getByRole('searchbox', { name: 'Search addons' }));
await user.click(screen.getByRole('button', { name: 'On' }));
expect(screen.getByText('Todo List')).toBeInTheDocument();
expect(screen.getByText('Vacay')).toBeInTheDocument();
expect(screen.queryByText('Budget')).toBeNull();
await user.click(screen.getByRole('button', { name: 'Off' }));
await user.type(screen.getByRole('searchbox', { name: 'Search addons' }), 'nothing like this');
expect(screen.getByText('No addon matches the search or the filter.')).toBeInTheDocument();
});
});
+21 -130
View File
@@ -1,7 +1,6 @@
import { useState, type ComponentType } from 'react'
import type { ComponentType, ReactNode } from 'react'
import { useTranslation } from '../../i18n'
import { useIsDark } from '../../hooks/useIsDark'
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, Sparkles, Luggage, Plane, Bookmark, Users, Loader2, Mountain, CarFront, Search } from 'lucide-react'
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, Sparkles, Luggage, Plane, Bookmark, Users, Loader2, Mountain, CarFront } from 'lucide-react'
import EmptyState from '../shared/EmptyState'
import DawarichIcon from '../shared/DawarichIcon'
import AirTrailIcon from '../shared/AirTrailIcon'
@@ -9,8 +8,7 @@ import { DOCUMENT_PROVIDER_ICONS } from '../shared/documentProviderIconMap'
import AddonTile from './AddonTile'
import AddonSubRow from './AddonSubRow'
import { Tooltip } from '../shared/Tooltip'
import { fs } from '../shared/DialogShell'
import { SettingsCard, StatusPill } from '../Settings/settingsKit'
import { SettingsCard, SettingsColumns, StatusPill } from '../Settings/settingsKit'
import { type Addon, type CollabFeatures, COLLAB_SUB_FEATURES, getAddonLabel } from './addons/addonModel'
import { useAddonManager } from './addons/useAddonManager'
import AiAddonTile from './addons/AiAddonTile'
@@ -67,9 +65,15 @@ const TYPE_META: Record<string, { icon: ComponentType<{ size?: number }>; labelK
integration: { icon: Link2, labelKey: 'admin.addons.type.integration', hintKey: 'admin.addons.integrationHint' },
}
export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking, collabFeatures, onToggleCollabFeature }: { bagTrackingEnabled?: boolean; onToggleBagTracking?: () => void; collabFeatures?: CollabFeatures; onToggleCollabFeature?: (key: string) => void }) {
export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking, collabFeatures, onToggleCollabFeature, aside }: {
bagTrackingEnabled?: boolean
onToggleBagTracking?: () => void
collabFeatures?: CollabFeatures
onToggleCollabFeature?: (key: string) => void
/** Cards that set up what the integrations reach (flight status, booking mails), under them in the right column. */
aside?: ReactNode
}) {
const { t } = useTranslation()
const dark = useIsDark()
const {
addons, loading, handleToggle, tripAddons, globalAddons, integrationAddons, aiAddons, providerOptions, documentProviderOptions,
toursWanderer,
@@ -77,9 +81,6 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
toggleToursWanderer,
mapSwitches,
} = useAddonManager()
const [query, setQuery] = useState('')
const [view, setView] = useState<AddonView>('all')
if (loading) {
return (
<SettingsCard icon={Puzzle} title={t('admin.addons.title')}>
@@ -210,32 +211,12 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
{ key: 'global', addons: globalAddons },
{ key: 'integration', addons: integrationAddons },
].filter(g => g.addons.length > 0 || (g.key === 'integration' && aiAddons.length > 0))
const enabledCount = addons.filter(a => a.type !== 'photo_provider' && a.type !== 'document_provider' && a.enabled).length
const totalCount = tripAddons.length + globalAddons.length + integrationAddons.length + aiAddons.length
const subtitle = (
<span className="inline-flex flex-wrap items-center gap-1">
{t('admin.addons.subtitleBefore')}
<img src={dark ? '/text-light.svg' : '/text-dark.svg'} alt="TREK" style={{ height: 10, verticalAlign: 'middle', opacity: 0.7 }} />
{t('admin.addons.subtitleAfter')}
</span>
)
/** Whether an addon is shown under the search and the filter. */
const shown = (addon: Addon) => {
if (view === 'on' && !addon.enabled) return false
if (view === 'off' && addon.enabled) return false
const q = query.trim().toLocaleLowerCase()
if (!q) return true
const label = getAddonLabel(t, addon)
return `${label.name} ${label.description ?? ''}`.toLocaleLowerCase().includes(q)
}
const card = (g: (typeof groups)[number]) => {
const meta = TYPE_META[g.key]
const all = g.key === 'integration' ? [...aiAddons, ...g.addons] : g.addons
const visible = g.addons.filter(shown)
const ai = g.key === 'integration' && aiAddons.some(shown)
const visible = g.addons
const ai = g.key === 'integration' && aiAddons.length > 0
if (!visible.length && !ai) return null
const on = all.filter(a => a.enabled).length
return (
@@ -249,8 +230,8 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
<span className="inline-flex"><StatusPill tone={on > 0 ? 'success' : 'neutral'}>{on}/{all.length}</StatusPill></span>
</Tooltip>
)}
bodyClassName="p-3"
>
{/* The addons in one rounded block of rows, as every settings card holds its rows. */}
<div className="divide-y divide-edge-faint overflow-hidden rounded-[12px] border border-edge-faint bg-surface-card">
{/* The AI addons share a connection and a settings dialog, so they head the card as one tile. */}
{ai && <AiAddonTile addons={aiAddons} onToggle={handleToggle} />}
@@ -263,103 +244,13 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
const [first, ...rest] = groups
const cards = { start: first ? card(first) : null, end: rest.map(card).filter(Boolean) }
return (
<>
<SettingsCard
icon={Puzzle}
title={t('admin.addons.title')}
hint={subtitle}
badge={totalCount > 0 ? (
<Tooltip label={t('admin.addons.group.count', { enabled: enabledCount, total: totalCount })}>
<span className="inline-flex">
<StatusPill tone={enabledCount > 0 ? 'success' : 'neutral'}>{enabledCount}/{totalCount}</StatusPill>
</span>
</Tooltip>
) : undefined}
>
{addons.length === 0
? <EmptyState scene="idle" title={t('admin.addons.noAddons')} surface="var(--bg-secondary)" />
: <AddonToolbar groups={groups.map(g => ({ key: g.key, addons: g.key === 'integration' ? [...aiAddons, ...g.addons] : g.addons }))} query={query} onQuery={setQuery} view={view} onView={setView} t={t} />}
if (addons.length === 0) {
return (
<SettingsCard icon={Puzzle} title={t('admin.addons.title')}>
<EmptyState scene="idle" title={t('admin.addons.noAddons')} surface="var(--bg-secondary)" />
</SettingsCard>
)
}
{addons.length > 0 && (cards.start || cards.end.length > 0 ? (
<div className="grid grid-cols-1 gap-x-5 xl:grid-cols-2 xl:items-start">
<div className="min-w-0">{cards.start}</div>
<div className="min-w-0">{cards.end}</div>
</div>
) : (
<p className="m-0 rounded-2xl border border-dashed border-edge-faint px-4 py-8 text-center text-content-muted" style={fs(13, 'body')}>
{t('admin.addons.noMatch')}
</p>
))}
</>
)
}
type AddonView = 'all' | 'on' | 'off'
/**
* The head card's body: a tile per type with how many of it are on, then a
* search over the addons' names and descriptions and a switch between all,
* the ones on and the ones off.
*/
function AddonToolbar({ groups, query, onQuery, view, onView, t }: {
groups: { key: string; addons: Addon[] }[]
query: string
onQuery: (q: string) => void
view: AddonView
onView: (v: AddonView) => void
t: (key: string, params?: Record<string, unknown>) => string
}) {
return (
<>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-3">
{groups.map(g => {
const meta = TYPE_META[g.key]
const Icon = meta.icon
const on = g.addons.filter(a => a.enabled).length
return (
<div key={g.key} className="flex items-center gap-3 rounded-[12px] border border-edge-faint bg-surface-card px-3 py-2.5">
<span className="grid h-8 w-8 flex-none place-items-center rounded-[10px] bg-surface-secondary text-content-secondary">
<Icon size={15} />
</span>
<span className="min-w-0 flex-1">
<span className="block truncate font-semibold text-content" style={fs(12.5, 'body')}>{t(meta.labelKey)}</span>
<span className="block text-content-faint" style={fs(11)}>{t('admin.addons.group.count', { enabled: on, total: g.addons.length })}</span>
</span>
<span className={`font-geist font-bold tabular-nums ${on > 0 ? 'text-content' : 'text-content-faint'}`} style={fs(18, 'body')} aria-hidden>{on}</span>
</div>
)
})}
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<label className="relative flex min-w-0 flex-1 items-center">
<Search size={14} className="pointer-events-none absolute start-3 text-content-faint" />
<input
type="search"
value={query}
onChange={e => onQuery(e.target.value)}
placeholder={t('admin.addons.search')}
aria-label={t('admin.addons.search')}
className="h-9 w-full rounded-[10px] border border-edge-faint bg-surface-card ps-8 pe-3 text-content outline-none transition-colors placeholder:text-content-faint focus:border-accent"
style={fs(13, 'body')}
/>
</label>
<div className="inline-flex flex-none gap-1 rounded-[10px] bg-surface-tertiary p-1" role="group" aria-label={t('admin.addons.filter')}>
{(['all', 'on', 'off'] as const).map(v => (
<button
key={v}
type="button"
onClick={() => onView(v)}
aria-pressed={view === v}
className={`h-7 rounded-[8px] px-3 font-medium transition-colors ${view === v ? 'bg-surface-card text-content shadow-sm' : 'text-content-muted hover:text-content'}`}
style={fs(12.5, 'body')}
>
{t(`admin.addons.filter.${v}`)}
</button>
))}
</div>
</div>
</>
)
return <SettingsColumns left={cards.start} right={<>{cards.end}{aside}</>} />
}
+2 -1
View File
@@ -33,7 +33,8 @@ export default function AddonSubRow({
onToggle: () => void
}) {
return (
<li className="flex min-h-[44px] items-center gap-2.5 px-3 py-1.5">
// Indented under the addon's name (16 + 32 + 12 px), one step quieter than it.
<li className="flex min-h-[44px] items-center gap-2.5 py-1.5 pe-4 ps-[60px]">
{icon
? <span className={`grid h-6 w-6 flex-none place-items-center ${enabled ? 'text-content-muted' : 'text-content-faint'}`}>{icon}</span>
: <span className="w-6 flex-none" aria-hidden />}
+26 -86
View File
@@ -1,30 +1,18 @@
import { Children, type ReactNode, isValidElement, useId, useState } from 'react'
import { Children, type ReactNode } from 'react'
import ToggleSwitch from '../Settings/ToggleSwitch'
import { HintMark } from '../Settings/settingsKit'
import { fs } from '../shared/DialogShell'
import { ChevronDown } from 'lucide-react'
import { useTranslation } from '../../i18n'
/** How many of the shelf's rows are switched on, read from the rows' own `enabled`. */
function enabledRows(rows: ReactNode[]): number {
return rows.filter((row) => isValidElement<{ enabled?: boolean }>(row) && row.props.enabled === true).length
}
/**
* One addon in its type's card: a row like the bag sidebar's (#2541), with the
* icon tile on the left, name and description beside it and the switch on the
* right. Its sub-features sit under it in a grey inset box of hairline rows.
* One addon in its type's card, as a settings row: the icon tile, the name with
* its description behind an info mark, and the switch on the right. Its
* sub-features follow as indented rows while it is on, in the same list and with
* the same hairlines: no box of their own, nothing folded away.
*
* The on/off state rides on the icon tile and the switch: a switched-off addon
* greys its tile and its description, but keeps its name at full strength. The
* name is the tile's identity, not its state; greying it out reads as "broken"
* rather than "not enabled".
*
* The row is not a click target, only the switches are. No lift, no pointer
* cursor, no `role="button"`.
*
* A shelf of more than one row starts folded behind a bar that says how many
* options there are and how many are on, so a page of addons stays short and
* the options are still plain to see.
* greys its tile but keeps its name at full strength. The name is the row's
* identity, not its state; greying it out reads as "broken" rather than "not
* enabled". The row is not a click target, only the switches are.
*/
export default function AddonTile({
icon,
@@ -32,90 +20,42 @@ export default function AddonTile({
description,
enabled,
onToggle,
trailing,
children,
}: {
icon: ReactNode
name: string
description?: string
enabled: boolean
onToggle: () => void
/** The sub-shelf: `<AddonSubRow>` children. */
/** The addon's switch; leave it out for a row that shows a state instead (`trailing`). */
onToggle?: () => void
/** Shown in place of the switch. */
trailing?: ReactNode
/** The sub-features: `<AddonSubRow>` children. */
children?: ReactNode
}) {
const { t } = useTranslation()
const shelfId = useId()
const [open, setOpen] = useState(false)
const rows = Children.toArray(children)
const foldable = rows.length > 1
const active = enabledRows(rows)
return (
<article className="px-3.5 py-3 transition-colors duration-150 focus-within:bg-surface-secondary">
<div className="flex items-start gap-3">
<article>
<div className="flex items-center gap-3 px-4 py-3">
<div
// overflow-hidden so a brand mark that fills the slot keeps the slot's
// rounded corners instead of squaring them off.
className={`grid h-9 w-9 flex-none place-items-center overflow-hidden rounded-[10px] transition-colors ${
enabled ? 'bg-surface-card text-content shadow-sm ring-1 ring-edge-faint' : 'bg-surface-tertiary text-content-faint'
className={`grid h-8 w-8 flex-none place-items-center overflow-hidden rounded-[10px] transition-colors ${
enabled ? 'bg-surface-card text-content-secondary shadow-sm ring-1 ring-edge-faint' : 'bg-surface-tertiary text-content-faint'
}`}
>
{icon}
</div>
<div className="min-w-0 flex-1 pt-px">
<h4 className="m-0 truncate font-semibold text-content" style={fs(13, 'body')} title={name}>
{name}
</h4>
<p
className={`m-0 mt-0.5 line-clamp-2 leading-snug ${enabled ? 'text-content-muted' : 'text-content-faint'}`}
style={fs(11.5)}
title={description}
>
{description}
</p>
<div className="flex min-w-0 flex-1 items-center gap-1.5">
<h4 className="m-0 truncate font-medium text-content" style={fs(13, 'body')}>{name}</h4>
{description && <HintMark hint={description} />}
</div>
<div className="flex-none pt-1.5">
<ToggleSwitch on={enabled} onToggle={onToggle} label={name} />
<div className="flex flex-none items-center">
{trailing ?? (onToggle && <ToggleSwitch on={enabled} onToggle={onToggle} label={name} />)}
</div>
</div>
{rows.length > 0 && (
<div className="mt-3 overflow-hidden rounded-[12px] border border-edge-faint bg-surface-secondary">
{foldable && (
<button
type="button"
onClick={() => setOpen((value) => !value)}
aria-expanded={open}
aria-controls={shelfId}
className={`flex min-h-[44px] w-full items-center gap-2.5 px-3 py-1.5 text-start transition-colors hover:bg-surface-tertiary focus-visible:outline focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-accent ${open ? 'border-b border-edge-faint' : ''}`}
>
{/* The same slot and type as a row below, so the bar reads as one more row. */}
<span className="grid h-6 w-6 flex-none place-items-center text-content-muted">
<ChevronDown
size={15}
strokeWidth={2.2}
className={`transition-transform duration-150 ${open ? 'rotate-180' : ''}`}
/>
</span>
<span className="min-w-0 flex-1 truncate font-medium text-content" style={fs(12.5, 'body')}>
{t('admin.addons.subOptions', { count: rows.length })}
</span>
<span
className={`flex-none rounded-full px-2 py-0.5 font-semibold ${active > 0 ? 'bg-accent text-accent-text' : 'bg-surface-tertiary text-content-muted'}`}
style={fs(10.5, 'body')}
>
{t('admin.addons.subOptionsOn', { count: active })}
</span>
</button>
)}
{/* Sitting inside the parent on its own fill is enough to say these belong
to it: no rail, no indent guides. */}
{(!foldable || open) && (
<ul id={shelfId} className="m-0 list-none divide-y divide-edge-faint p-0">{children}</ul>
)}
</div>
)}
{/* An empty shelf (a tours addon without its options here) draws no line. */}
{Children.count(children) > 0 && <ul className="m-0 list-none divide-y divide-edge-faint border-t border-edge-faint p-0">{children}</ul>}
</article>
)
}
@@ -5,7 +5,7 @@ import { useFormatDate } from '../../hooks/useDateFormat'
import ConfirmDialog from '../shared/ConfirmDialog'
import { Tooltip } from '../shared/Tooltip'
import { fs } from '../shared/DialogShell'
import { SETTINGS_ICON_BUTTON, SettingRows, SettingsCard, SettingsHint, StatusPill } from '../Settings/settingsKit'
import { SETTINGS_ICON_BUTTON, SettingRows, SettingsCard, SettingsHint, StatusPill, SettingsColumns } from '../Settings/settingsKit'
import { SCOPES_PREVIEW, useMcpTokensAdmin } from './useMcpTokensAdmin'
const ROW = 'flex flex-wrap items-center gap-x-4 gap-y-2 px-3.5 py-3'
@@ -72,6 +72,9 @@ export default function AdminMcpTokensPanel() {
return (
<div>
{/* The sessions and the tokens side by side: two lists of the same kind. */}
<SettingsColumns
left={<>
{/* OAuth Sessions. The tab's own subtitle sits in its band: the sidebar
already names the tab, so a bare heading over the cards only repeated it. */}
<SettingsCard
@@ -116,7 +119,8 @@ export default function AdminMcpTokensPanel() {
</SettingRows>
)}
</SettingsCard>
</>}
right={<>
{/* MCP Tokens */}
<SettingsCard
icon={KeyRound}
@@ -147,6 +151,8 @@ export default function AdminMcpTokensPanel() {
</SettingRows>
)}
</SettingsCard>
</>}
/>
{/* Revoke OAuth session */}
<ConfirmDialog
@@ -184,7 +184,7 @@ export default function AdminPluginsPanel() {
<input id="plugin-dev-link" value={linkPath} onChange={(e) => setLinkPath(e.target.value)} spellCheck={false}
placeholder={t('admin.plugins.devLinkPathPlaceholder')} className={`${INPUT} font-mono`} />
</EditorField>
<button type="submit" disabled={!linkPath.trim() || busy === '__link'} className={SETTINGS_BUTTON_PRIMARY} style={fs(13, 'body')}>
<button type="submit" disabled={!linkPath.trim() || busy === '__link'} className={SETTINGS_BUTTON_PRIMARY}>
<Link2 size={14} /> {t('admin.plugins.devLinkButton')}
</button>
</div>
@@ -223,7 +223,7 @@ export default function AdminPluginsPanel() {
surface="var(--bg-secondary)"
size={88}
action={(
<button type="button" onClick={openDiscover} className={SETTINGS_BUTTON_PRIMARY} style={fs(13, 'body')}>
<button type="button" onClick={openDiscover} className={SETTINGS_BUTTON_PRIMARY}>
<Download size={14} /> {t('admin.plugins.tabDiscover')}
</button>
)}
@@ -627,7 +627,7 @@ function EgressDialog({ data, draft, setDraft, saving, error, onSave, onClose, t
className={`${INPUT} flex-1 font-mono`}
/>
<button type="button" disabled={saving || !draft.trim()} onClick={() => onSave([...data.hosts, draft.trim()])}
className={SETTINGS_BUTTON_PRIMARY} style={fs(13, 'body')}>
className={SETTINGS_BUTTON_PRIMARY}>
{t('common.add')}
</button>
</div>
@@ -68,14 +68,14 @@ export default function AuditLogPanel({ serverTimezone }: AuditLogPanelProps): R
icon={ClipboardList}
title={t('admin.tabs.audit')}
hint={t('admin.audit.subtitle')}
badge={<StatusPill>{t('admin.audit.showing', { count: entries.length, total })}</StatusPill>}
// The count only once there is one: never "0 of 0" while the first page loads.
badge={total > 0 ? <StatusPill>{t('admin.audit.showing', { count: entries.length, total })}</StatusPill> : undefined}
action={
<button
type="button"
disabled={loading}
onClick={() => loadFirstPage()}
className={SETTINGS_BUTTON}
style={fs(12.5, 'body')}
>
<RefreshCw size={14} strokeWidth={2.2} className={loading ? 'animate-spin' : ''} />
{t('admin.audit.refresh')}
@@ -91,7 +91,6 @@ export default function AuditLogPanel({ serverTimezone }: AuditLogPanelProps): R
disabled={loading}
onClick={() => loadMore()}
className={SETTINGS_BUTTON}
style={fs(12.5, 'body')}
>
{t('admin.audit.loadMore')}
</button>
+1 -4
View File
@@ -56,7 +56,6 @@ export default function BackupPanel() {
onClick={() => fileInputRef.current?.click()}
disabled={isUploading}
className={SETTINGS_BUTTON}
style={fs(12.5, 'body')}
title={isUploading ? t('backup.uploading') : t('backup.upload')}
>
{isUploading
@@ -69,7 +68,6 @@ export default function BackupPanel() {
onClick={handleCreate}
disabled={isCreating}
className={SETTINGS_BUTTON_PRIMARY}
style={fs(12.5, 'body')}
title={isCreating ? t('backup.creating') : t('backup.create')}
>
{isCreating
@@ -97,7 +95,7 @@ export default function BackupPanel() {
surface="var(--bg-secondary)"
className="!py-6"
action={
<button type="button" onClick={handleCreate} className={SETTINGS_BUTTON} style={fs(12.5, 'body')}>
<button type="button" onClick={handleCreate} className={SETTINGS_BUTTON}>
<Plus size={14} strokeWidth={2.2} />
{t('backup.createFirst')}
</button>
@@ -295,7 +293,6 @@ export default function BackupPanel() {
onClick={handleSaveAutoSettings}
disabled={autoSettingsSaving || !autoSettingsDirty}
className={SETTINGS_BUTTON_PRIMARY}
style={fs(13, 'body')}
>
{autoSettingsSaving
? <Loader2 size={14} strokeWidth={2.2} className="animate-spin" />
@@ -135,7 +135,7 @@ export default function CategoryManager() {
hint={t('categories.subtitle')}
badge={!isLoading && categories.length > 0 ? <StatusPill>{categories.length}</StatusPill> : undefined}
action={
<button type="button" onClick={handleStartCreate} className={SETTINGS_BUTTON_PRIMARY} style={fs(13, 'body')}>
<button type="button" onClick={handleStartCreate} className={SETTINGS_BUTTON_PRIMARY}>
<Plus size={14} strokeWidth={2.2} />
<span className="hidden sm:inline">{t('categories.new')}</span>
</button>
@@ -1,8 +1,8 @@
import React from 'react'
import { Loader2, Map as MapIcon, Settings2 } from 'lucide-react'
import { Loader2, Map as MapIcon, Settings2, Route } from 'lucide-react'
import { useTranslation } from '../../i18n'
import Section from '../Settings/Section'
import { SettingRows, SettingsHint } from '../Settings/settingsKit'
import { SettingRow, SettingRows, SettingsColumns } from '../Settings/settingsKit'
import { fs } from '../shared/DialogShell'
import { EditorField, INPUT, Segmented } from '../shared/dialogParts'
import CustomSelect from '../shared/CustomSelect'
@@ -34,15 +34,8 @@ function ChoiceRow({
stacked?: boolean
children: React.ReactNode
}) {
return (
<div className={stacked ? 'flex flex-col gap-2.5 px-3.5 py-3' : 'flex flex-wrap items-center gap-x-4 gap-y-2 px-3.5 py-3'}>
<label className={`block font-medium text-content ${stacked ? '' : 'min-w-0 flex-1 basis-40'}`} style={fs(13, 'body')}>
{label}
</label>
{hint && <p className="-mt-2 m-0 leading-snug text-content-faint" style={fs(11.5)}>{hint}</p>}
<div className={stacked ? 'min-w-0' : 'flex min-w-0 flex-none items-center'}>{children}</div>
</div>
)
// The settings row every page uses, so a default reads like the setting it presets.
return <SettingRow label={label} hint={hint} control={children} stacked={stacked} />
}
/** A wide field box inside the white rows, for the map card's token and style. */
@@ -114,23 +107,11 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
const darkMode = defaults.dark_mode
return (
<div>
<SettingsHint className="mb-4">
{t('admin.defaultSettings.description')}
</SettingsHint>
<SettingsColumns
left={
<>
{/* Two columns from xl up, the same idea as the Settings tab: as one flat list
the map fields sat a screen below the units while the right half of the page
stayed empty. Grouped by subject rather than by height — what a user ends up
seeing on the left, everything that configures the map on the right.
The columns are deliberately uneven: the left one never needs more than the
widest option row, while the right one holds four text fields whose values
are 60-character tile URLs. Only the column gap is set, because Section
carries its own bottom margin and a row gap would double it once the grid
collapses. Two cards can sit straight in the grid; a third would need the
explicit per-column stacks the Settings tab uses, or it leaves a hole. */}
<div className="grid grid-cols-1 gap-x-5 xl:grid-cols-[minmax(0,1fr)_minmax(0,1.35fr)] xl:items-start">
<Section title={t('admin.defaultSettings.title')} icon={Settings2}>
<Section title={t('admin.defaultSettings.title')} icon={Settings2} hint={t('admin.defaultSettings.description')}>
<SettingRows>
{/* Color Mode */}
<ChoiceRow label={<>{t('settings.colorMode')} <ResetButton {...resetOf('dark_mode')} /></>}>
@@ -207,7 +188,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
</ChoiceRow>
{/* Default Currency */}
<ChoiceRow label={<>{t('settings.currency')} <ResetButton {...resetOf('default_currency')} /></>}>
<ChoiceRow label={<>{t('settings.currency')} <ResetButton {...resetOf('default_currency')} /></>} hint={t('settings.currencyHint')}>
<div className="w-[220px] max-w-full">
<CustomSelect
value={defaults.default_currency || ''}
@@ -232,9 +213,17 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
/>
</ChoiceRow>
</SettingRows>
<SettingsHint className="-mt-2">{t('settings.currencyHint')}</SettingsHint>
</Section>
{/* Which engines answer routes, under the defaults: on its own card it keeps
the two columns about as long as each other. */}
{!managed && (
<Section title={t('admin.defaultSettings.routing')} icon={Route}>
<RoutingInstanceFields defaults={defaults} onSave={save} onReset={reset} />
</Section>
)}
</>
}
right={
<Section title={t('settings.map')} icon={MapIcon}>
{/* Map Tile URL */}
<EditorField label={<>{t('settings.mapTemplate')}<ResetButton {...resetOf('map_tile_url')} /></>} hint={t('settings.mapDefaultHint')}>
@@ -270,7 +259,6 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
/>
</EditorField>
)}
{!managed && <RoutingInstanceFields defaults={defaults} onSave={save} onReset={reset} />}
<div className="relative h-[200px] w-full overflow-hidden rounded-[12px] border border-edge-faint">
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
{React.createElement(MapView as any, {
@@ -386,7 +374,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
)}
</SettingRows>
</Section>
</div>
</div>
}
/>
)
}
@@ -205,7 +205,6 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
target="_blank"
rel="noopener noreferrer"
className={`${SETTINGS_BUTTON} no-underline`}
style={fs(12.5, 'body')}
>
<ExternalLink size={13} strokeWidth={2.2} />
GitHub
@@ -298,7 +297,6 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
onClick={handleLoadMore}
disabled={loadingMore}
className={SETTINGS_BUTTON}
style={fs(12.5, 'body')}
>
{loadingMore ? <Loader2 size={13} className="animate-spin" /> : <ChevronDown size={13} strokeWidth={2.2} />}
{loadingMore ? t('admin.github.loading') : t('admin.github.loadMore')}
@@ -1,44 +1,40 @@
import { AlertTriangle, Check } from 'lucide-react'
import { Check } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { NumericInput } from '../shared/NumericInput'
import { fs } from '../shared/DialogShell'
import { INPUT } from '../shared/dialogParts'
import { SETTINGS_BUTTON_PRIMARY, StatusPill } from '../Settings/settingsKit'
import { HintMark, SETTINGS_BUTTON_PRIMARY } from '../Settings/settingsKit'
import { useGoogleQuota } from './useGoogleQuota'
/**
* The daily ceiling on Google calls (#1582), as one row of the Google options:
* today's count as a pill beside the name, the number on the right, a save
* today's count as a quiet line under the name, the number on the right, a save
* button only while there is something to save. Laid out like settingsKit's
* SettingRow, but with the pill outside the <label> so the field keeps the
* plain name as its accessible name.
* SettingRow; the count stays outside the <label>, so the field keeps the plain
* name as its accessible name.
*/
export default function GoogleDailyLimitRow() {
const { t } = useTranslation()
const { status, draft, setDraft, dirty, saving, save } = useGoogleQuota()
return (
<div className={`px-3.5 py-3 ${saving ? 'opacity-60' : ''}`}>
<div className={`px-4 py-3 ${saving ? 'opacity-60' : ''}`}>
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
<div className="min-w-0 flex-1 basis-56">
<div className="flex flex-wrap items-center gap-2">
<div className="flex min-w-0 items-center gap-1.5">
<label htmlFor="admin-google-daily-limit" className="font-medium text-content" style={fs(13, 'body')}>{t('admin.googleQuota.title')}</label>
{/* Today's count beside the name it belongs to: a green dot while there is room, amber once the day is used up. */}
{status && (
status.exhausted ? (
<StatusPill tone="warning" icon={<AlertTriangle size={11} strokeWidth={2.4} />}>
{t('admin.googleQuota.reached', { used: status.used_today })}
</StatusPill>
) : (
<StatusPill icon={<span aria-hidden className="me-0.5 h-1.5 w-1.5 rounded-full bg-success" />}>
{status.daily_limit == null
? t('admin.googleQuota.usedToday', { used: status.used_today })
: t('admin.googleQuota.usedOfLimit', { used: status.used_today, limit: status.daily_limit })}
</StatusPill>
)
)}
<HintMark hint={t('admin.googleQuota.subtitle')} />
</div>
<p className="m-0 mt-0.5 leading-snug text-content-faint" style={fs(11.5)}>{t('admin.googleQuota.subtitle')}</p>
{/* Today's count as plain text under the name; in the warning colour once the day is used up. */}
{status && (
<p className={`m-0 mt-0.5 tabular-nums ${status.exhausted ? 'text-warning' : 'text-content-faint'}`} style={fs(11.5)}>
{status.exhausted
? t('admin.googleQuota.reached', { used: status.used_today })
: status.daily_limit == null
? t('admin.googleQuota.usedToday', { used: status.used_today })
: t('admin.googleQuota.usedOfLimit', { used: status.used_today, limit: status.daily_limit })}
</p>
)}
</div>
<div className="flex flex-none items-center gap-2">
<div className="w-32">
@@ -55,7 +51,7 @@ export default function GoogleDailyLimitRow() {
</div>
{dirty && (
<button type="button" onClick={() => void save()} disabled={saving}
className={SETTINGS_BUTTON_PRIMARY} style={fs(13, 'body')}>
className={SETTINGS_BUTTON_PRIMARY}>
<Check size={14} strokeWidth={2.2} />
{t('common.save')}
</button>
@@ -48,7 +48,7 @@ export default function PackingTemplateManager() {
hint={t('admin.packingTemplates.subtitle')}
badge={!isLoading && templates.length > 0 ? <StatusPill>{templates.length}</StatusPill> : undefined}
action={
<button type="button" onClick={() => setShowCreate(true)} className={SETTINGS_BUTTON_PRIMARY} style={fs(13, 'body')}>
<button type="button" onClick={() => setShowCreate(true)} className={SETTINGS_BUTTON_PRIMARY}>
<Plus size={14} strokeWidth={2.2} /> <span className="hidden sm:inline">{t('admin.packingTemplates.create')}</span>
</button>
}
@@ -1,5 +1,5 @@
import type { PlaceContribField, PlaceContribState } from '@trek/shared';
import { ChevronRight, HeartHandshake, Undo2 } from 'lucide-react';
import { HeartHandshake, Undo2 } from 'lucide-react';
import { useState } from 'react';
import { useTranslation } from '../../i18n';
import type { TranslationFn } from '../../types';
@@ -152,19 +152,13 @@ export default function PlaceContribPanel({ hideWhenSwitchedOff = false }: { hid
)}
{(agreed || state.consent.decision === 'off') && (
<details className="group overflow-hidden rounded-[12px] border border-edge-faint bg-surface-card">
{/* The admin page's fold head (pages/admin/FoldSummary), which a component cannot import. */}
<summary className="flex cursor-pointer list-none items-center gap-2.5 px-3.5 py-2.5 hover:bg-surface-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[color:var(--text-primary)] [&::-webkit-details-marker]:hidden">
<ChevronRight
size={15}
strokeWidth={2}
className="flex-none text-content-faint transition-transform group-open:rotate-90"
aria-hidden="true"
/>
<div className="overflow-hidden rounded-[12px] border border-edge-faint bg-surface-card">
{/* A plain section head: the settings fold nothing away. */}
<div className="flex items-center gap-2.5 px-3.5 py-2.5">
<span className="font-medium text-content" style={fs(12.5, 'body')}>
{t('admin.contribute.log.title')}
</span>
</summary>
</div>
<div className="flex flex-col gap-3 border-t border-edge-faint p-3">
<PlaceContribLog />
<button
@@ -178,7 +172,7 @@ export default function PlaceContribPanel({ hideWhenSwitchedOff = false }: { hid
{t('admin.contribute.withdraw')}
</button>
</div>
</details>
</div>
)}
{showPrompt && state.eligible && (
@@ -3,6 +3,7 @@ import type { ValhallaProvider } from '@trek/shared'
import { useTranslation } from '../../i18n/TranslationContext'
import { fs } from '../shared/DialogShell'
import { INPUT, LABEL } from '../shared/dialogParts'
import CustomSelect from '../shared/CustomSelect'
export interface RoutingDefaults {
routing_base_url?: string
@@ -121,10 +122,20 @@ function RoutingProviderField({ defaults, onSave, onReset, hintClassName }: Rout
<div className={look.head}>
<label htmlFor={id} className={look.label}>{t('settings.valhallaProvider')}</label>
</div>
<select id={id} value={defaults.routing_valhalla_provider ?? ''} disabled={saving}
onChange={e => void pick(e.target.value)} aria-describedby={`${id}-hint`} className={look.input}>
{PROVIDERS.map(option => <option key={option.value} value={option.value}>{t(option.labelKey)}</option>)}
</select>
{phone ? (
<select id={id} value={defaults.routing_valhalla_provider ?? ''} disabled={saving}
onChange={e => void pick(e.target.value)} aria-describedby={`${id}-hint`} className={look.input}>
{PROVIDERS.map(option => <option key={option.value} value={option.value}>{t(option.labelKey)}</option>)}
</select>
) : (
// The desktop's own select, like every other choice on its settings pages.
<CustomSelect
value={defaults.routing_valhalla_provider ?? ''}
onChange={value => void pick(String(value))}
options={PROVIDERS.map(option => ({ value: option.value, label: t(option.labelKey) }))}
disabled={saving}
/>
)}
<FieldHint id={`${id}-hint`} text={t('settings.valhallaProviderHint')} hintClassName={hintClassName} />
</div>
)
@@ -39,7 +39,7 @@ function Catalog() {
{state.loading && <p role="status" className="m-0 flex items-center gap-2 text-content-muted" style={fs(12.5, 'body')}><Loader2 size={14} className="flex-none animate-spin" />{t('common.loading')}</p>}
{state.error && !state.editor && <div role="alert" className="flex flex-wrap items-center gap-3 rounded-[12px] bg-danger-soft px-3.5 py-2.5 text-danger" style={fs(12.5, 'body')}>
<span className="min-w-0 flex-1 basis-48">{state.error}</span>
<button type="button" disabled={disabled} onClick={() => void state.refresh()} className={SETTINGS_BUTTON} style={fs(12.5, 'body')}>{t('schoolCatalog.retry')}</button>
<button type="button" disabled={disabled} onClick={() => void state.refresh()} className={SETTINGS_BUTTON}>{t('schoolCatalog.retry')}</button>
</div>}
<fieldset disabled={disabled} className={`m-0 flex min-w-0 flex-col gap-4 border-0 p-0 ${state.busy ? 'opacity-60' : ''}`}>
{state.catalog.countries.length > 0 && <div className="flex items-end gap-2">
@@ -59,7 +59,7 @@ function Catalog() {
</Tooltip>
</div>}
{!adding && state.catalog.countries.length === 0 && <div>
<button type="button" onClick={() => setAdding(true)} className={SETTINGS_BUTTON} style={fs(13, 'body')}><Plus size={14} strokeWidth={2.2} />{t('schoolCatalog.addCountry')}</button>
<button type="button" onClick={() => setAdding(true)} className={SETTINGS_BUTTON}><Plus size={14} strokeWidth={2.2} />{t('schoolCatalog.addCountry')}</button>
</div>}
{adding && <form className={PANEL} onSubmit={async event => {
event.preventDefault()
@@ -74,8 +74,8 @@ function Catalog() {
</EditorField>
</div>
<div className="flex justify-end gap-2">
<button type="button" onClick={() => setAdding(false)} className={SETTINGS_BUTTON} style={fs(13, 'body')}>{t('common.cancel')}</button>
<button type="submit" className={SETTINGS_BUTTON_PRIMARY} style={fs(13, 'body')}>{t('common.save')}</button>
<button type="button" onClick={() => setAdding(false)} className={SETTINGS_BUTTON}>{t('common.cancel')}</button>
<button type="submit" className={SETTINGS_BUTTON_PRIMARY}>{t('common.save')}</button>
</div>
</form>}
{selectedCountry && <>
@@ -39,11 +39,10 @@ describe('ServiceIdentityPanel', () => {
const bar = screen.getByRole('progressbar', { name: 'admin.trekApi.quota.service.places' });
expect(bar).toHaveAttribute('aria-valuenow', '2500');
expect(bar).toHaveAttribute('aria-valuemax', '10000');
// What is left is the figure of the tile; used and the limit sit beside it as badges.
expect(screen.getByText('7,500')).toBeInTheDocument();
expect(screen.getByText('2,500')).toBeInTheDocument();
expect(screen.getByText('10,000')).toBeInTheDocument();
expect(screen.getByText('admin.trekApi.quota.limit')).toBeInTheDocument();
// One row per service: used of the limit beside the bar, what is left in its title.
const figure = screen.getByText('2,500').parentElement as HTMLElement;
expect(figure).toHaveTextContent('2,500 / 10,000');
expect(figure).toHaveAttribute('title', 'admin.trekApi.quota.remaining: 7,500');
// The day starts again at the same time for every service, so it is said once, in the header.
expect(screen.getAllByText('admin.trekApi.quota.resets')).toHaveLength(1);
expect(screen.queryByText(/admin\.trekApi\.quota\.hint\./)).toBeNull();
@@ -64,44 +64,39 @@ function ServiceTile({ allowance }: { allowance: ServiceAllowance }) {
const Icon = SERVICE_ICON[allowance.service] ?? MapPin;
const share = quota && quota.limit > 0 ? Math.min(100, (quota.used / quota.limit) * 100) : 100;
// One row per service: its name, the day's use as a bar, used of the limit.
return (
<li
className={`flex min-w-0 flex-col gap-2.5 rounded-xl border border-edge-faint bg-surface-card p-3 shadow-sm ${allowance.inUse ? '' : 'opacity-60'}`}
>
<div className="flex min-w-0 items-center gap-2">
<span className="grid h-7 w-7 flex-none place-items-center rounded-lg bg-surface-tertiary text-content-muted">
<Icon size={14} strokeWidth={2} aria-hidden="true" />
<li className={`flex min-w-0 flex-col gap-1 px-3 py-2.5 ${allowance.inUse ? '' : 'opacity-60'}`}>
<div className="flex min-w-0 items-center gap-3">
<span className="flex w-[34%] min-w-0 flex-none items-center gap-2">
<Icon size={14} strokeWidth={2} aria-hidden="true" className="flex-none text-content-muted" />
<span className="truncate text-caption font-medium text-content-secondary">{name}</span>
</span>
<p className="m-0 truncate text-caption font-medium text-content-secondary">{name}</p>
</div>
{quota && (
<>
<div className="min-w-0">
<p className={`m-0 truncate text-title font-semibold tabular-nums leading-tight ${figureTone(quota)}`}>
{number.format(quota.remaining)}
</p>
<p className="m-0 text-caption text-content-muted">{t('admin.trekApi.quota.remaining')}</p>
</div>
<div
role="progressbar"
aria-label={name}
aria-valuemin={0}
aria-valuemax={quota.limit}
aria-valuenow={quota.used}
className="h-1.5 overflow-hidden rounded-full bg-surface-tertiary"
>
{quota && (
<>
<div
className={`h-full rounded-full transition-[width] duration-500 ${barTone(quota)}`}
style={{ width: `${share}%` }}
/>
</div>
<div className="flex flex-wrap gap-1.5">
<FigureBadge label={t('admin.trekApi.quota.used')} value={number.format(quota.used)} />
<FigureBadge label={t('admin.trekApi.quota.limit')} value={number.format(quota.limit)} />
</div>
</>
)}
role="progressbar"
aria-label={name}
aria-valuemin={0}
aria-valuemax={quota.limit}
aria-valuenow={quota.used}
className="h-1.5 min-w-0 flex-1 overflow-hidden rounded-full bg-surface-tertiary"
>
<div
className={`h-full rounded-full transition-[width] duration-500 ${barTone(quota)}`}
style={{ width: `${share}%` }}
/>
</div>
<span
className="flex-none text-caption tabular-nums text-content-faint"
title={`${t('admin.trekApi.quota.remaining')}: ${number.format(quota.remaining)}`}
>
<span className={`font-semibold ${figureTone(quota)}`}>{number.format(quota.used)}</span>
{' / '}{number.format(quota.limit)}
</span>
</>
)}
</div>
{note && <p className="m-0 text-caption leading-snug text-content-muted">{note}</p>}
</li>
);
@@ -172,7 +167,7 @@ export default function ServiceIdentityPanel({ managed = false }: { managed?: bo
{status && status.services.length > 0 && (
<ul
aria-label={t('admin.trekApi.quota.title')}
className="m-0 grid list-none grid-cols-1 gap-2 p-0 sm:grid-cols-3"
className="m-0 flex list-none flex-col divide-y divide-edge-faint overflow-hidden rounded-xl border border-edge-faint bg-surface-card p-0"
>
{status.services.map((allowance) => (
<ServiceTile key={allowance.service} allowance={allowance} />
@@ -4,6 +4,7 @@ import { useTranslation } from '../../../i18n';
import { StatusPill } from '../../Settings/settingsKit';
import { fs } from '../../shared/DialogShell';
import AddonSubRow from '../AddonSubRow';
import AddonTile from '../AddonTile';
import { type Addon, getAddonLabel } from './addonModel';
import { AI_ADDON_ICONS, type AiAddonId } from './aiAddons';
import AiSettingsDialog from './AiAdminConfig';
@@ -26,72 +27,53 @@ export default function AiAddonTile({
const tile = useAiAddonTile(addons);
const anyOn = tile.enabledCount > 0;
return (
<article className="px-3.5 py-3 transition-colors duration-150 focus-within:bg-surface-secondary">
<div className="flex items-start gap-3">
<div
className={`grid h-9 w-9 flex-none place-items-center rounded-[10px] transition-colors ${
anyOn
? 'bg-surface-card text-content shadow-sm ring-1 ring-edge-faint'
: 'bg-surface-tertiary text-content-faint'
}`}
>
<Sparkles size={18} />
</div>
<div className="min-w-0 flex-1 pt-px">
<h4 className="m-0 truncate font-semibold text-content" style={fs(13, 'body')}>
{t('admin.addons.ai.name')}
</h4>
<p
className={`m-0 mt-0.5 line-clamp-2 leading-snug ${anyOn ? 'text-content-muted' : 'text-content-faint'}`}
style={fs(11.5)}
>
{t('admin.addons.ai.description')}
</p>
</div>
<div className="flex-none pt-1">
<>
<AddonTile
icon={<Sparkles size={15} />}
name={t('admin.addons.ai.name')}
description={t('admin.addons.ai.description')}
enabled={anyOn}
trailing={
<StatusPill tone={anyOn ? 'success' : 'neutral'}>
{tile.enabledCount}/{addons.length}
</StatusPill>
</div>
</div>
<div className="mt-3 overflow-hidden rounded-[12px] border border-edge-faint bg-surface-secondary">
<ul className="m-0 list-none divide-y divide-edge-faint p-0">
{addons.map((addon) => {
const Icon = AI_ADDON_ICONS[addon.id as AiAddonId];
const label = getAddonLabel(t, addon);
return (
<AddonSubRow
key={addon.id}
icon={<Icon size={14} />}
title={label.name}
description={label.description}
badge={addon.id === 'ai_assistant' ? <BetaBadge label={t('assistant.setup.beta')} /> : undefined}
enabled={addon.enabled}
onToggle={() => onToggle(addon)}
/>
);
})}
</ul>
}
>
{addons.map((addon) => {
const Icon = AI_ADDON_ICONS[addon.id as AiAddonId];
const label = getAddonLabel(t, addon);
return (
<AddonSubRow
key={addon.id}
icon={<Icon size={14} />}
title={label.name}
description={label.description}
badge={addon.id === 'ai_assistant' ? <BetaBadge label={t('assistant.setup.beta')} /> : undefined}
enabled={addon.enabled}
onToggle={() => onToggle(addon)}
/>
);
})}
{tile.canSetUp && (
<button
type="button"
onClick={tile.openDialog}
className="flex min-h-[44px] w-full items-center gap-2.5 border-t border-edge-faint px-3 py-1.5 text-start transition-colors hover:bg-surface-tertiary focus-visible:outline focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-accent"
>
<span className="grid h-6 w-6 flex-none place-items-center text-content-muted">
<Settings2 size={14} />
</span>
<span className="min-w-0 flex-1 truncate font-medium text-content" style={fs(12.5, 'body')}>
{t('admin.addons.ai.setup')}
</span>
<ChevronRight size={15} strokeWidth={2.2} className="flex-none text-content-faint rtl:rotate-180" />
</button>
<li>
<button
type="button"
onClick={tile.openDialog}
className="flex min-h-[44px] w-full items-center gap-2.5 border-0 bg-transparent py-1.5 pe-4 ps-[60px] text-start transition-colors hover:bg-surface-hover focus-visible:bg-surface-hover focus-visible:outline-none"
>
<span className="grid h-6 w-6 flex-none place-items-center text-content-muted">
<Settings2 size={14} />
</span>
<span className="min-w-0 flex-1 truncate font-medium text-content" style={fs(12.5, 'body')}>
{t('admin.addons.ai.setup')}
</span>
<ChevronRight size={15} strokeWidth={2.2} className="flex-none text-content-faint rtl:rotate-180" />
</button>
</li>
)}
</div>
</AddonTile>
{tile.dialogOpen && <AiSettingsDialog addons={addons} onClose={tile.closeDialog} />}
</article>
</>
);
}
@@ -128,7 +128,6 @@ function InstanceConnection({ A }: { A: AiAdmin }) {
type="button"
onClick={cancelReplace}
className={`${SETTINGS_BUTTON} self-start`}
style={fs(12.5, 'body')}
>
{t('common.cancel')}
</button>
@@ -61,7 +61,6 @@ function ParsingSource({ A, P, onOpenConnection }: { A: AiAdmin; P: Parsing; onO
onClick={() => void P.switchToShared()}
disabled={P.saving}
className={`${SETTINGS_BUTTON} self-start`}
style={fs(12.5, 'body')}
>
{t('admin.addons.ai.parsing.useShared')}
</button>
@@ -166,7 +165,6 @@ function LocalModels({ P }: { P: Parsing }) {
onClick={() => P.setModel(m.id)}
disabled={chosen}
className={SETTINGS_BUTTON}
style={fs(12, 'body')}
>
{chosen ? <Check size={13} strokeWidth={2.4} /> : null}
{t(chosen ? 'admin.addons.ai.parsing.selected' : 'admin.addons.ai.parsing.use')}
@@ -177,7 +175,6 @@ function LocalModels({ P }: { P: Parsing }) {
onClick={() => void P.pull(m.id)}
disabled={!!P.pulling}
className={SETTINGS_BUTTON_PRIMARY}
style={fs(12, 'body')}
>
{t(P.pulling === m.id ? 'admin.addons.ai.parsing.pulling' : 'admin.addons.ai.parsing.pull')}
</button>
@@ -1,29 +1,16 @@
import {
AlertTriangle,
CheckCircle,
Copy,
Inbox,
Loader2,
Mail,
PlugZap,
Save,
Upload,
Webhook,
XCircle,
type LucideIcon,
} from 'lucide-react';
import { AlertTriangle, CheckCircle, Copy, Loader2, Mail, PlugZap, Save, XCircle } from 'lucide-react';
import type { ReactNode } from 'react';
import { useTranslation } from '../../../i18n';
import type { TranslationFn } from '../../../types';
import { copyText } from '../../../utils/clipboard';
import { relativeTime } from '../../../utils/relativeTime';
import {
HintMark,
SETTINGS_BUTTON,
SETTINGS_BUTTON_PRIMARY,
SettingRow,
SettingRows,
SettingsCard,
SettingsHint,
StatusPill,
} from '../../Settings/settingsKit';
import ToggleSwitch from '../../Settings/ToggleSwitch';
@@ -45,9 +32,6 @@ import { useMailIngestAdmin, type MailIngestAdmin } from './useMailIngestAdmin';
type T = TranslationFn;
/** The card's buttons sit inside the form, one size below the page's: caption type, tighter padding. */
const SMALL = 'px-3 py-1.5 text-caption';
/** The address a mail service posts to, shown so the admin can paste it into the sender's settings. */
function InboundUrl({ t }: { t: T }) {
const toast = useToast();
@@ -58,7 +42,7 @@ function InboundUrl({ t }: { t: T }) {
<span className={READONLY_BOX}>{url}</span>
<button
type="button"
className={`${SETTINGS_BUTTON} ${SMALL}`}
className={SETTINGS_BUTTON}
aria-label={t('mailAdmin.copyUrl')}
onClick={() => void copyText(url).then((ok) => (ok ? toast.success(t('common.copied')) : undefined))}
>
@@ -171,43 +155,18 @@ function WebhookFields({ admin, form, t, error }: FieldsProps) {
);
}
const STAT_TONE: Record<StatTone, { icon: string; value: string }> = {
neutral: { icon: 'bg-surface-tertiary text-content-muted', value: 'text-content' },
success: { icon: 'bg-success-soft text-success', value: 'text-content' },
warning: { icon: 'bg-warning-soft text-warning', value: 'text-content' },
danger: { icon: 'bg-danger-soft text-danger', value: 'text-danger' },
/** The colour a count takes once it is not zero. */
const STAT_COLOUR: Record<StatTone, string> = {
neutral: 'text-content',
success: 'text-success',
warning: 'text-warning',
danger: 'text-danger',
};
/** One number of the status: its icon in the tone's soft colour, the count large, the label under it. */
function StatTile({
icon: Icon,
label,
value,
tone,
}: {
icon: LucideIcon;
label: string;
value: number;
tone: StatTone;
}) {
const look = STAT_TONE[tone];
return (
<div className="flex min-w-0 items-center gap-2 rounded-lg border border-edge-faint bg-surface-card px-2.5 py-1.5 shadow-sm">
<span className={`grid h-6 w-6 flex-none place-items-center rounded-full ${look.icon}`}>
<Icon size={12} strokeWidth={2.2} />
</span>
<div className="min-w-0">
<div className={`text-body font-semibold tabular-nums leading-tight ${look.value}`}>{value}</div>
<div className="truncate text-caption leading-tight text-content-faint">{label}</div>
</div>
</div>
);
}
/**
* How the import runs, in counts only: no subject, sender or content of a mail
* is ever shown here. A head line with the last successful read (green, or
* amber while the last run failed), then the day's numbers as tiles.
* is ever shown here. One line: the last successful read (green, or amber while
* the last run failed), then the day's numbers as badges.
*/
function StatusBlock({ admin, t, locale }: { admin: MailIngestAdmin; t: T; locale: string }) {
const status = admin.status;
@@ -218,21 +177,25 @@ function StatusBlock({ admin, t, locale }: { admin: MailIngestAdmin; t: T; local
: t('mailAdmin.status.never');
const healthy = !status.last_error && !!status.last_ok_at;
return (
<div className="flex flex-col gap-2 rounded-xl border border-edge-faint bg-surface-secondary p-2.5">
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="text-caption font-semibold uppercase tracking-[0.08em] text-content-faint">
{t('mailAdmin.status.title')}
</span>
<span className="inline-flex items-center gap-1.5 text-caption font-medium text-content-secondary">
<span className={`h-2 w-2 rounded-full ${healthy ? 'bg-success' : 'bg-warning'}`} />
{t('mailAdmin.status.lastRead', { time: lastRead })}
</span>
</div>
<div className="grid grid-cols-4 gap-1.5 max-md:grid-cols-2">
{statTiles(facts, t).map(({ key, ...tile }) => (
<StatTile key={key} {...tile} />
<div className="flex flex-col gap-2">
{/* When it last read, as a plain line with a dot: green while it runs, amber after a failed run. */}
<p className="m-0 flex items-center gap-1.5 text-caption text-content-muted">
<span className={`h-1.5 w-1.5 flex-none rounded-full ${healthy ? 'bg-success' : 'bg-warning'}`} />
{t('mailAdmin.status.lastRead', { time: lastRead })}
</p>
{/* The day's counts as one quiet strip: the number over its name, coloured only when it is not zero. */}
<dl className="m-0 grid grid-cols-2 divide-edge-faint overflow-hidden rounded-[12px] border border-edge-faint bg-surface-card sm:grid-cols-4 sm:divide-x">
{statTiles(facts, t).map(({ key, label, value, tone }) => (
<div key={key} className="flex min-w-0 flex-col-reverse px-3 py-2">
<dt className="truncate text-caption text-content-faint">{label}</dt>
<dd
className={`m-0 text-body font-semibold tabular-nums ${value > 0 ? STAT_COLOUR[tone] : 'text-content'}`}
>
{value}
</dd>
</div>
))}
</div>
</dl>
{status.last_error && (
<p className="m-0 flex items-start gap-1.5 rounded-lg bg-warning-soft px-3 py-2 text-caption leading-snug text-warning">
<AlertTriangle size={13} strokeWidth={2.2} className="mt-px flex-none" />
@@ -256,31 +219,20 @@ function TestResult({ admin, t }: { admin: MailIngestAdmin; t: T }) {
);
}
const TRANSPORT_ICON = { off: Upload, imap: Inbox, webhook: Webhook } as const;
/** What the chosen way of taking mail in means, as a calm blue note under the choice. */
function TransportInfo({ transport, t }: { transport: MailAdminForm['transport']; t: T }) {
const Icon = TRANSPORT_ICON[transport];
return (
<div className="flex items-center gap-3 rounded-xl border border-[color-mix(in_srgb,var(--info)_22%,transparent)] bg-info-soft px-3.5 py-3">
<span className="grid h-8 w-8 flex-none place-items-center rounded-full bg-[color-mix(in_srgb,var(--info)_16%,transparent)] text-info">
<Icon size={15} strokeWidth={2.1} />
</span>
<div className="min-w-0">
<p className="m-0 text-caption font-semibold text-info">{transportLabel(transport, t)}</p>
<p className="m-0 mt-0.5 text-caption leading-snug text-content-secondary">{transportInfo(transport, t)}</p>
</div>
</div>
);
}
/** Everything under the switch: how mail arrives, its fields, then save, test and the status. */
function Setup({ admin, form, t, locale }: { admin: MailIngestAdmin; form: MailAdminForm; t: T; locale: string }) {
const error = (problem: MailAdminProblem) => (admin.problems.includes(problem) ? problemText(problem, t) : undefined);
return (
<div className="flex flex-col gap-4">
<div className={GRID_2}>
<EditorField label={t('mailAdmin.transport.label')}>
<EditorField
label={
<span className="inline-flex items-center gap-1.5">
{t('mailAdmin.transport.label')}
<HintMark hint={`${transportLabel(form.transport, t)}: ${transportInfo(form.transport, t)}`} />
</span>
}
>
<CustomSelect
value={form.transport}
onChange={(value) => admin.set('transport', TRANSPORTS.find((x) => x === value) ?? 'off')}
@@ -288,9 +240,13 @@ function Setup({ admin, form, t, locale }: { admin: MailIngestAdmin; form: MailA
/>
</EditorField>
<EditorField
label={t('mailAdmin.aiDaily')}
label={
<span className="inline-flex items-center gap-1.5">
{t('mailAdmin.aiDaily')}
<HintMark hint={t('mailAdmin.aiDailyHint')} />
</span>
}
htmlFor="mail-ai-daily"
hint={t('mailAdmin.aiDailyHint')}
error={error('dailyInvalid')}
>
<input
@@ -302,11 +258,14 @@ function Setup({ admin, form, t, locale }: { admin: MailIngestAdmin; form: MailA
/>
</EditorField>
</div>
<TransportInfo transport={form.transport} t={t} />
<EditorField
label={t('mailAdmin.template')}
label={
<span className="inline-flex items-center gap-1.5">
{t('mailAdmin.template')}
<HintMark hint={t('mailAdmin.templateHint', { token: '{token}' })} />
</span>
}
htmlFor="mail-template"
hint={t('mailAdmin.templateHint', { token: '{token}' })}
error={error('templateToken')}
>
<input
@@ -319,11 +278,10 @@ function Setup({ admin, form, t, locale }: { admin: MailIngestAdmin; form: MailA
</EditorField>
{form.transport === 'imap' && <ImapFields admin={admin} form={form} t={t} error={error} />}
{form.transport === 'webhook' && <WebhookFields admin={admin} form={form} t={t} error={error} />}
<SettingsHint>{t('mailAdmin.envHint')}</SettingsHint>
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
className={`${SETTINGS_BUTTON_PRIMARY} ${SMALL}`}
className={SETTINGS_BUTTON_PRIMARY}
disabled={!admin.dirty || admin.problems.length > 0 || admin.saving}
onClick={admin.save}
>
@@ -332,7 +290,7 @@ function Setup({ admin, form, t, locale }: { admin: MailIngestAdmin; form: MailA
</button>
<button
type="button"
className={`${SETTINGS_BUTTON} ${SMALL}`}
className={SETTINGS_BUTTON}
disabled={admin.testing || admin.dirty || form.transport === 'off'}
onClick={admin.runTest}
>
@@ -340,6 +298,7 @@ function Setup({ admin, form, t, locale }: { admin: MailIngestAdmin; form: MailA
{t('mailAdmin.test.run')}
</button>
<TestResult admin={admin} t={t} />
<HintMark hint={t('mailAdmin.envHint')} />
</div>
<StatusBlock admin={admin} t={t} locale={locale} />
</div>
@@ -107,7 +107,6 @@ function RequestRow({ request, admin }: { request: PaddleRegionPending; admin: A
disabled={admin.busyId !== null}
onClick={() => void admin.decline(request.id)}
className={SETTINGS_BUTTON}
style={fs(12.5, 'body')}
>
{t('admin.paddleRegions.requests.decline')}
</button>
@@ -117,7 +116,6 @@ function RequestRow({ request, admin }: { request: PaddleRegionPending; admin: A
disabled={admin.busyId !== null}
onClick={() => void admin.approve(request.id)}
className={SETTINGS_BUTTON}
style={fs(12.5, 'body')}
>
{busy ? <Loader2 size={13} className="animate-spin" /> : <CloudDownload size={13} strokeWidth={2.2} />}
{t('admin.paddleRegions.requests.approve')}
@@ -66,7 +66,7 @@ function Panel() {
style={fs(12.5, 'body')}
>
<span className="min-w-0 flex-1 basis-48">{admin.error}</span>
<button type="button" onClick={admin.retry} className={SETTINGS_BUTTON} style={fs(12.5, 'body')}>
<button type="button" onClick={admin.retry} className={SETTINGS_BUTTON}>
{t('admin.paddleRegions.retry')}
</button>
</div>
@@ -159,7 +159,6 @@ function CatalogSearch({ admin }: { admin: Admin }) {
disabled={admin.busyId !== null}
onClick={() => void admin.add(hit.id)}
className={SETTINGS_BUTTON}
style={fs(12.5, 'body')}
>
{admin.busyId === hit.id ? (
<Loader2 size={13} className="animate-spin" />
@@ -29,14 +29,7 @@ import CustomSelect from '../../shared/CustomSelect'
import { Tooltip } from '../../shared/Tooltip'
import { NEUTRAL_TINT, fs } from '../../shared/DialogShell'
import Section from '../../Settings/Section'
import {
SETTINGS_BUTTON,
SETTINGS_BUTTON_PRIMARY,
SETTINGS_ICON_BUTTON,
SettingRows,
SettingsHint,
StatusPill,
} from '../../Settings/settingsKit'
import { SETTINGS_BUTTON, SETTINGS_BUTTON_PRIMARY, SETTINGS_ICON_BUTTON, SettingRows, SettingsHint, StatusPill, SettingsColumns } from '../../Settings/settingsKit'
import BackendForm from './BackendForm'
import StorageReclaimSection from './StorageReclaimSection'
import { CACHE_CATEGORIES, primaryNameOf } from './storageModel'
@@ -142,6 +135,10 @@ export default function AdminStoragePanel(): React.ReactElement {
</div>
)}
{/* Two columns by length: the health and the backends on the left, which
category goes where on the right, its save bar at the card's foot. */}
<SettingsColumns
left={<>
<Section title={t('storage.health.title')} icon={Activity}>
{state.health.replicaFailures.length === 0 ? (
<p className="m-0 flex items-center gap-2 text-content-muted" style={fs(12.5, 'body')}>
@@ -180,7 +177,6 @@ export default function AdminStoragePanel(): React.ReactElement {
<button type="button"
onClick={startAdd}
className={SETTINGS_BUTTON}
style={fs(12.5, 'body')}
>
<Plus size={14} strokeWidth={2.2} />
{t('storage.backends.add')}
@@ -486,7 +482,18 @@ export default function AdminStoragePanel(): React.ReactElement {
</SettingRows>
)}
</Section>
{/* Under the backends it cleans up after, which keeps the two columns even. */}
<StorageReclaimSection
reclaim={reclaim}
checking={reclaimChecking}
saving={reclaimSaving}
confirming={reclaimConfirming}
onConfirm={(request) => void handleReclaimConfirm(request)}
onCheck={() => void handleReclaimCheck()}
onDelete={(enabled) => void handleReclaimDelete(enabled)}
/>
</>}
right={<>
<Section title={t('storage.categories.title')} icon={FolderTree}>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{STORAGE_CATEGORIES.map((category) => {
@@ -544,26 +551,24 @@ export default function AdminStoragePanel(): React.ReactElement {
)
})}
</div>
{/* Saving is part of the card it saves, under a hairline at its foot. */}
<div className="flex flex-wrap items-center gap-3 border-t border-edge-faint pt-4">
<button type="button"
onClick={save}
disabled={!admin.dirty || admin.saving}
className={SETTINGS_BUTTON_PRIMARY}
>
{admin.saving ? <Loader2 size={14} strokeWidth={2.2} className="animate-spin" /> : <Check size={14} strokeWidth={2.2} />}
{t('storage.save')}
</button>
{admin.dirty && <StatusPill tone="warning">{t('storage.unsaved')}</StatusPill>}
{migrationQueue.length > 0 && (
<SettingsHint className="min-w-0 flex-1">
{t('storage.migrate.queued', { categories: categoryNames(t, migrationQueue.map((c) => c.category)) })}
</SettingsHint>
)}
</div>
</Section>
<div className="flex flex-wrap items-center gap-3 rounded-2xl border border-edge-faint bg-surface-secondary px-4 py-3">
<button type="button"
onClick={save}
disabled={!admin.dirty || admin.saving}
className={SETTINGS_BUTTON_PRIMARY}
style={fs(13, 'body')}
>
{admin.saving ? <Loader2 size={14} strokeWidth={2.2} className="animate-spin" /> : <Check size={14} strokeWidth={2.2} />}
{t('storage.save')}
</button>
{admin.dirty && <StatusPill tone="warning">{t('storage.unsaved')}</StatusPill>}
{migrationQueue.length > 0 && (
<SettingsHint className="min-w-0 flex-1">
{t('storage.migrate.queued', { categories: categoryNames(t, migrationQueue.map((c) => c.category)) })}
</SettingsHint>
)}
</div>
{migratePromptOpen && (
<div
role="alertdialog"
@@ -586,7 +591,6 @@ export default function AdminStoragePanel(): React.ReactElement {
<div className="flex flex-wrap items-center gap-2 border-t border-edge-faint px-4 py-3">
<button type="button"
className={SETTINGS_BUTTON}
style={fs(13, 'body')}
onClick={closeMigratePrompt}
>
{t('storage.migrate.promptCancel')}
@@ -594,14 +598,12 @@ export default function AdminStoragePanel(): React.ReactElement {
<span className="flex-1" />
<button type="button"
className={SETTINGS_BUTTON}
style={fs(13, 'body')}
onClick={() => void routeOnlySave()}
>
{t('storage.migrate.routeOnly')}
</button>
<button type="button"
className={SETTINGS_BUTTON_PRIMARY}
style={fs(13, 'body')}
onClick={() => void moveAndSave()}
>
{t('storage.migrate.move')}
@@ -609,6 +611,8 @@ export default function AdminStoragePanel(): React.ReactElement {
</div>
</div>
)}
</>}
/>
{admin.saveError && (
<div role="alert" className="mt-3 flex flex-wrap items-start gap-3 rounded-2xl border border-edge-faint bg-danger-soft px-4 py-3">
<AlertTriangle size={16} strokeWidth={2.2} className="mt-0.5 flex-none text-danger" />
@@ -629,17 +633,6 @@ export default function AdminStoragePanel(): React.ReactElement {
</div>
)}
<div className="mt-5">
<StorageReclaimSection
reclaim={reclaim}
checking={reclaimChecking}
saving={reclaimSaving}
confirming={reclaimConfirming}
onConfirm={(request) => void handleReclaimConfirm(request)}
onCheck={() => void handleReclaimCheck()}
onDelete={(enabled) => void handleReclaimDelete(enabled)}
/>
</div>
{editing && (
<BackendForm
@@ -50,7 +50,6 @@ export default function StorageReclaimSection({
<button
type="button"
className={SETTINGS_BUTTON}
style={fs(12.5, 'body')}
onClick={onCheck}
disabled={checking}
>
@@ -132,7 +131,6 @@ export default function StorageReclaimSection({
<button
type="button"
className={SETTINGS_BUTTON}
style={fs(12, 'body')}
onClick={() => onConfirm({ category: line.category, unreferenced: line.unreferenced })}
disabled={confirming === line.category}
>
@@ -42,7 +42,6 @@ export default function ScopeGroupPicker({ selected, onChange }: Props): React.R
type="button"
onClick={toggleAll}
className={`${SETTINGS_BUTTON} px-3 py-1.5`}
style={fs(12, 'body')}
>
{allSelected ? t('settings.oauth.modal.deselectAll') : t('settings.oauth.modal.selectAll')}
</button>
+62 -58
View File
@@ -9,9 +9,7 @@ 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'
import { SETTINGS_BUTTON, SETTINGS_BUTTON_DANGER, SETTINGS_BUTTON_PRIMARY, SettingRow, SettingRows, SettingsHint, StatusPill, SettingsColumns } from './settingsKit'
import { stripTrailingSlashes, useAccountSettings } from './useAccountSettings'
import AccountDemoLock from './AccountDemoLock'
@@ -76,6 +74,10 @@ export default function AccountTab(): React.ReactElement {
<NativeServerSection />
{/* The shared demo account: everything here is shown, nothing can be changed. */}
<AccountDemoLock>
{/* Two columns by length: who you are and the second factor on the left,
the password and passkeys on the right. */}
<SettingsColumns
left={<>
<Section title={t('settings.account')} icon={User}>
{/* 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">
@@ -169,54 +171,6 @@ export default function AccountTab(): React.ReactElement {
</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={INPUT}
/>
</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={INPUT}
/>
</EditorField>
</div>
<PasswordChecklist password={newPassword} />
<div className="flex justify-end" style={fs(13, 'body')}>
<button type="button"
onClick={changePassword}
className={SETTINGS_BUTTON}
>
<Lock size={14} />
{t('settings.updatePassword')}
</button>
</div>
</Section>
)}
{/* MFA */}
<Section title={t('settings.mfa.title')} icon={KeyRound}>
{mfaRequiredByPolicy && (
@@ -236,7 +190,6 @@ export default function AccountTab(): React.ReactElement {
disabled={mfaLoading}
onClick={startMfaSetup}
className={SETTINGS_BUTTON}
style={fs(13, 'body')}
>
{mfaLoading ? <span className={SPINNER} /> : <KeyRound size={14} />}
{t('settings.mfa.setup')}
@@ -296,7 +249,7 @@ export default function AccountTab(): React.ReactElement {
{user?.mfa_enabled && (
<div className={PANEL}>
<div>
<p className={EYEBROW} style={fs(10)}>{t('settings.mfa.disableTitle')}</p>
<p className={EYEBROW}>{t('settings.mfa.disableTitle')}</p>
<SettingsHint className="mt-1">{t('settings.mfa.disableHint')}</SettingsHint>
</div>
<div className={GRID_2}>
@@ -319,7 +272,7 @@ export default function AccountTab(): React.ReactElement {
className={`${INPUT} font-geist tabular-nums`}
/>
</div>
<div className="flex justify-end" style={fs(13, 'body')}>
<div className="flex justify-end">
<button
type="button"
disabled={mfaLoading || !mfaDisablePwd || mfaDisableCode.length < 6}
@@ -335,14 +288,14 @@ export default function AccountTab(): React.ReactElement {
{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>
<p className="m-0 font-semibold text-content">{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)}>
<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">{backupCodesText}</pre>
<p className="m-0 flex items-center gap-1.5 font-medium text-warning">
<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')}>
<div className="flex flex-wrap items-center gap-2">
<button type="button" onClick={copyBackupCodes} className={SETTINGS_BUTTON}>
<Copy size={13} /> {t('settings.mfa.backupCopy')}
</button>
@@ -361,9 +314,60 @@ export default function AccountTab(): React.ReactElement {
)}
</>
</Section>
</>}
right={<>
{/* 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={INPUT}
/>
</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={INPUT}
/>
</EditorField>
</div>
<PasswordChecklist password={newPassword} />
<div className="flex justify-end" style={fs(13, 'body')}>
<button type="button"
onClick={changePassword}
className={SETTINGS_BUTTON}
>
<Lock size={14} />
{t('settings.updatePassword')}
</button>
</div>
</Section>
)}
{/* Passkeys */}
<PasskeysSection demoMode={demoMode} />
</>}
/>
</AccountDemoLock>
{/* Delete Account Blocked */}
@@ -6,7 +6,6 @@ import { server } from '../../../tests/helpers/msw/server';
import { resetAllStores } from '../../../tests/helpers/store';
import { ToastContainer } from '../shared/Toast';
import AirTrailConnectionSection from './AirTrailConnectionSection';
import { openSettingsCards } from '../../../tests/helpers/settingsCards';
interface SavedBody {
url: string;
@@ -38,8 +37,6 @@ function toggleNextTo(label: string): HTMLElement {
}
beforeEach(() => {
// The cards start shut; these tests read into them.
openSettingsCards();
resetAllStores();
vi.clearAllMocks();
stubSettings();
@@ -5,7 +5,7 @@ import { useAirTrailSettings } from './useAirTrailSettings'
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 { SETTINGS_BUTTON, SETTINGS_BUTTON_PRIMARY, SettingRow, SettingRows, StatusPill } from './settingsKit'
import { EditorField, GRID_2, INPUT } from '../shared/dialogParts'
import { fs } from '../shared/DialogShell'
@@ -41,9 +41,10 @@ export default function AirTrailConnectionSection(): React.ReactElement {
return (
<Section
id="airtrail"
collapsible
title={t('settings.airtrail.title')}
hint={t('settings.airtrail.hint')}
icon={AirTrailIcon}
iconFill
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')}
@@ -107,7 +108,6 @@ export default function AirTrailConnectionSection(): React.ReactElement {
</button>
</div>
<SettingsHint>{t('settings.airtrail.hint')}</SettingsHint>
</Section>
)
}
@@ -9,7 +9,6 @@ import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { buildUser } from '../../../tests/helpers/factories';
import { ToastContainer } from '../shared/Toast';
import ApiKeysSection from './ApiKeysSection';
import { openSettingsCards } from '../../../tests/helpers/settingsCards';
const clipboardWriteText = vi.fn().mockResolvedValue(undefined);
@@ -22,8 +21,6 @@ beforeAll(() => {
});
beforeEach(() => {
// The cards start shut; these tests read into them.
openSettingsCards();
clipboardWriteText.mockReset();
clipboardWriteText.mockResolvedValue(undefined);
resetAllStores();
@@ -239,12 +239,11 @@ export default function ApiKeysSection(): React.ReactElement {
<>
<Section
id="api-keys"
collapsible
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')}>
<button type="button" onClick={openModal} disabled={atLimit} className={SETTINGS_BUTTON_PRIMARY}>
<Plus size={14} /> {t('settings.apiKeys.create')}
</button>
)}
@@ -5,7 +5,7 @@ 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 { SETTINGS_BUTTON, SettingRow, SettingRows, SettingsColumns, StatusPill } from './settingsKit'
import { APPEARANCE_SCHEMES, CUSTOM_ACCENT_PRESETS } from '../../theme/schemes'
import { APPEARANCE_SCALE_MIN, APPEARANCE_SCALE_MAX } from '@trek/shared'
import { DESKTOP_GROUPS, MOBILE_GROUPS, isHex } from './appearanceModel'
@@ -44,6 +44,8 @@ export default function AppearanceSettingsTab(): React.ReactElement {
return (
<>
<SettingsColumns
left={<>
{/* ── Theme ───────────────────────────────────────────────── */}
<Section title={tr('settings.appearance.theme', 'Theme')} icon={Paintbrush}>
<SettingRows>
@@ -70,7 +72,8 @@ export default function AppearanceSettingsTab(): React.ReactElement {
{/* Color scheme swatches */}
<SettingRow label={tr('settings.appearance.scheme', 'Color scheme')} stacked>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
{/* As many per line as fit at a width that holds every name in full. */}
<div className="grid grid-cols-[repeat(auto-fill,minmax(140px,1fr))] gap-2">
{APPEARANCE_SCHEMES.map((s) => {
const active = cfg.schemeId === s.id
const dot = isDark ? s.swatch.dark : s.swatch.light
@@ -208,6 +211,8 @@ export default function AppearanceSettingsTab(): React.ReactElement {
</SettingRows>
</Section>
</>}
right={<>
{/* ── Dashboard widgets ───────────────────────────────────── */}
<Section
title={tr('settings.appearance.dashboardWidgets', 'Dashboard widgets')}
@@ -236,12 +241,12 @@ export default function AppearanceSettingsTab(): React.ReactElement {
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>
/>
{/* Its widgets, indented under it in the same block. They only matter while the
column is shown: dimmed, not hidden, while it is off. */}
<div className={`divide-y divide-edge-faint ps-6 transition-opacity ${masterOn ? '' : 'pointer-events-none opacity-40'}`}>
{rows}
</div>
</SettingRows>
)
})}
@@ -265,9 +270,11 @@ export default function AppearanceSettingsTab(): React.ReactElement {
</div>
</DialogSection>
</Section>
</>}
/>
<div className="mb-6 flex justify-end">
<button type="button" onClick={resetAll} className={SETTINGS_BUTTON} style={fs(13, 'body')}>
<button type="button" onClick={resetAll} className={SETTINGS_BUTTON}>
<RotateCcw size={14} strokeWidth={2} />
{tr('settings.appearance.reset', 'Reset to defaults')}
</button>
@@ -46,7 +46,6 @@ import { render, screen, waitFor } from '../../../tests/helpers/render';
import { server } from '../../../tests/helpers/msw/server';
import { resetAllStores } from '../../../tests/helpers/store';
import DawarichConnectionSection from './DawarichConnectionSection';
import { openSettingsCards } from '../../../tests/helpers/settingsCards';
const toast = { success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() };
vi.mock('../shared/Toast', () => ({
@@ -157,8 +156,6 @@ function testButton(): HTMLElement {
}
beforeEach(() => {
// The cards start shut; these tests read into them.
openSettingsCards();
resetAllStores();
vi.clearAllMocks();
stubSettings();
@@ -6,7 +6,7 @@ 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 { SETTINGS_BUTTON, SETTINGS_BUTTON_DANGER, SETTINGS_BUTTON_PRIMARY, SettingRow, SettingRows, StatusPill } from './settingsKit'
import { EditorField, GRID_2, INPUT } from '../shared/dialogParts'
import { fs } from '../shared/DialogShell'
@@ -30,16 +30,16 @@ export default function DawarichConnectionSection(): React.ReactElement {
return (
<Section
id="dawarich"
collapsible
title={t('dawarich.title')}
hint={t('dawarich.intro')}
icon={DawarichIcon}
iconFill
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 className={GRID_2}>
<EditorField label={t('dawarich.url')} htmlFor="dawarich-url">
@@ -1,5 +1,5 @@
// FE-COMP-DISPLAY-001 to FE-COMP-DISPLAY-057
import { render, screen, within, fireEvent } from '../../../tests/helpers/render';
import { render, screen, within } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { server } from '../../../tests/helpers/msw/server';
@@ -57,20 +57,20 @@ describe('DisplaySettingsTab', () => {
expect(screen.getByText(/12h/i)).toBeInTheDocument();
});
it('FE-COMP-DISPLAY-015: clicking a language button calls updateSetting with that language code', async () => {
it('FE-COMP-DISPLAY-015: picking a language in the select calls updateSetting with its code', async () => {
const user = userEvent.setup();
const updateSetting = vi.fn().mockResolvedValue(undefined);
seedStore(useSettingsStore, { settings: buildSettings({ language: 'en' }), updateSetting });
render(<DisplaySettingsTab />);
await user.click(screen.getByText('Deutsch'));
await user.click(screen.getByRole('button', { name: 'Language' }));
await user.click(await screen.findByText('Deutsch'));
expect(updateSetting).toHaveBeenCalledWith('language', 'de');
});
it('FE-COMP-DISPLAY-016: active language button is visually highlighted', () => {
it('FE-COMP-DISPLAY-016: the language select shows the current language', () => {
seedStore(useSettingsStore, { settings: buildSettings({ language: 'en' }) });
render(<DisplaySettingsTab />);
// "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();
expect(screen.getByRole('button', { name: 'Language' })).toHaveTextContent('English');
});
it('FE-COMP-DISPLAY-017: shows Temperature section label', () => {
@@ -199,7 +199,7 @@ describe('DisplaySettingsTab – Display currency', () => {
render(<DisplaySettingsTab />);
await user.click(screen.getByRole('button', { name: 'Same as the app' }));
// The language grid shows the same name, so the option is the last one on screen.
// The app language is German here, so the only English on screen is the place option.
const options = await screen.findAllByText('English');
await user.click(options[options.length - 1]);
@@ -232,85 +232,19 @@ describe('DisplaySettingsTab – Display currency', () => {
});
});
// ── Compact language dropdown (035–039) ───────────────────────────────────────
// ── Language errors (037) ─────────────────────────────────────────────────────
function mobileLangWrap(): HTMLElement {
return optionBlock('Language').querySelector('.sm\\:hidden') as HTMLElement;
}
describe('DisplaySettingsTab – Compact language picker', () => {
it('FE-COMP-DISPLAY-035: the compact trigger shows the active language and opens the list', async () => {
const user = userEvent.setup();
render(<DisplaySettingsTab />);
const wrap = mobileLangWrap();
expect(within(wrap).getAllByRole('button')).toHaveLength(1);
expect(within(wrap).getByText('English')).toBeInTheDocument();
await user.click(within(wrap).getByRole('button'));
expect(within(wrap).getAllByRole('button').length).toBeGreaterThan(1);
});
it('FE-COMP-DISPLAY-036: picking a language from the list saves it and closes the list', async () => {
const user = userEvent.setup();
const updateSetting = vi.fn().mockResolvedValue(undefined);
seedStore(useSettingsStore, { settings: buildSettings({ language: 'en' }), updateSetting });
render(<DisplaySettingsTab />);
const wrap = mobileLangWrap();
await user.click(within(wrap).getByRole('button'));
await user.click(within(wrap).getByText('Deutsch').closest('button')!);
expect(updateSetting).toHaveBeenCalledWith('language', 'de');
expect(within(wrap).getAllByRole('button')).toHaveLength(1);
});
it('FE-COMP-DISPLAY-037: a rejected pick from the list surfaces the error', async () => {
const user = userEvent.setup();
seedFailing('Language locked');
render(<><ToastContainer /><DisplaySettingsTab /></>);
const wrap = mobileLangWrap();
await user.click(within(wrap).getByRole('button'));
await user.click(within(wrap).getByText('Deutsch').closest('button')!);
expect(await screen.findByText('Language locked')).toBeInTheDocument();
});
it('FE-COMP-DISPLAY-048: a rejected pick from the desktop grid surfaces the error', async () => {
describe('DisplaySettingsTab – Language select', () => {
it('FE-COMP-DISPLAY-037: a rejected language pick surfaces the error', async () => {
const user = userEvent.setup();
seedFailing('Language locked');
render(<><ToastContainer /><DisplaySettingsTab /></>);
await user.click(screen.getByText('Deutsch'));
await user.click(screen.getByRole('button', { name: 'Language' }));
await user.click(await screen.findByText('Deutsch'));
expect(await screen.findByText('Language locked')).toBeInTheDocument();
});
it('FE-COMP-DISPLAY-038: a mousedown outside the picker closes it', async () => {
const user = userEvent.setup();
render(<DisplaySettingsTab />);
const wrap = mobileLangWrap();
await user.click(within(wrap).getByRole('button'));
expect(within(wrap).getAllByRole('button').length).toBeGreaterThan(1);
fireEvent.mouseDown(document.body);
expect(within(wrap).getAllByRole('button')).toHaveLength(1);
});
it('FE-COMP-DISPLAY-039: a mousedown inside the picker keeps it open', async () => {
const user = userEvent.setup();
render(<DisplaySettingsTab />);
const wrap = mobileLangWrap();
await user.click(within(wrap).getByRole('button'));
fireEvent.mouseDown(within(wrap).getByText('Deutsch'));
expect(within(wrap).getAllByRole('button').length).toBeGreaterThan(1);
});
});
// ── Map & privacy toggles (040–047) ───────────────────────────────────────────
@@ -1,17 +1,14 @@
import React, { useState, useEffect, useRef } from 'react'
import { Languages, Map, ChevronDown, Rocket } from 'lucide-react'
import React, { useState, useEffect } from 'react'
import { Languages, Map, Rocket } from 'lucide-react'
import { SUPPORTED_LANGUAGES, useTranslation } from '../../i18n'
import { DEFAULT_SETTINGS } from '../../store/settingsStore'
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 { SettingRow, SettingRows, SettingsColumns } from './settingsKit'
import { useSettingSaver } from './useSettingSaver'
import { TRIP_TAB_IDS, TRIP_TAB_LABEL_KEYS } from '../../constants/tripTabs'
import { DEFAULT_START_PAGE, DEFAULT_START_TRIP_TAB } from '../../utils/startDestination'
@@ -31,18 +28,6 @@ export default function DisplaySettingsTab(): React.ReactElement {
const demoLocked = useDemoAccountLock()
const [tempUnit, setTempUnit] = useState<string>(settings.temperature_unit || DEFAULT_SETTINGS.temperature_unit)
const [distanceUnit, setDistanceUnit] = useState<DistanceUnit>(settings.distance_unit || DEFAULT_SETTINGS.distance_unit)
const [langOpen, setLangOpen] = useState(false)
const langDropdownRef = useRef<HTMLDivElement | null>(null)
useEffect(() => {
if (!langOpen) return
const handler = (e: MouseEvent) => {
if (langDropdownRef.current && !langDropdownRef.current.contains(e.target as Node)) setLangOpen(false)
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [langOpen])
useEffect(() => {
setTempUnit(settings.temperature_unit || DEFAULT_SETTINGS.temperature_unit)
}, [settings.temperature_unit])
@@ -55,12 +40,12 @@ 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 = (value: string) => save('language', value)
return (
<>
<SettingsColumns
left={<>
<Section title={t('settings.general.startup')} icon={Rocket}>
<SettingRows>
{/* Where opening TREK lands */}
@@ -127,47 +112,23 @@ export default function DisplaySettingsTab(): React.ReactElement {
}
/>
{/* Language: every option at a glance on a wide window, a compact list on a narrow one.
The shared demo account always runs in English. */}
{/* Language: a select like every other choice in this card. The shared
demo account always runs in English. */}
<DemoLock locked={demoLocked} reason={t('demo.lock.language')} size="row">
<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)}
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-start font-medium text-content"
style={fs(13, 'body')}
>
<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 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>
</SettingRow>
<SettingRow
label={t('settings.language')}
control={
<div className={SELECT_BOX}>
<CustomSelect
value={settings.language || SUPPORTED_LANGUAGES[0].value}
onChange={v => void pickLanguage(String(v))}
options={SUPPORTED_LANGUAGES.map(opt => ({ value: opt.value, label: opt.label }))}
searchable
ariaLabel={t('settings.language')}
/>
</div>
}
/>
</DemoLock>
{/* Place names (#1799): searches can answer in another language than the app. */}
@@ -286,6 +247,8 @@ export default function DisplaySettingsTab(): React.ReactElement {
</SettingRows>
</Section>
</>}
right={
<Section title={t('settings.general.travelMap')} icon={Map}>
<SettingRows>
{/* Preferred map app (#2423): opt-in, the picker stays the default */}
@@ -327,7 +290,8 @@ export default function DisplaySettingsTab(): React.ReactElement {
<OnOffSetting label={t('settings.optimizeFromAccommodation')} hint={t('settings.optimizeFromAccommodationHint')} on={settings.optimize_from_accommodation !== false} onChange={value => saveOnOff('optimize_from_accommodation', value)} />
</SettingRows>
</Section>
</>
}
/>
)
}
@@ -10,7 +10,6 @@ import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { buildUser } from '../../../tests/helpers/factories';
import { ToastContainer } from '../shared/Toast';
import IntegrationsTab from './IntegrationsTab';
import { openSettingsCards } from '../../../tests/helpers/settingsCards';
function enableMcp() {
seedStore(useAddonStore, {
@@ -32,6 +31,11 @@ function mcpEndpointRow() {
return { code: row.querySelector('code')!, copy: row.querySelector('button')! };
}
/** Waits for the MCP card: its endpoint, clients and tokens are on the page, nothing to open. */
async function openMcp() {
await screen.findByText('MCP Endpoint');
}
const clipboardWriteText = vi.fn().mockResolvedValue(undefined);
function enableAddons(ids: string[]) {
@@ -51,8 +55,6 @@ beforeAll(() => {
});
beforeEach(() => {
// The cards start shut; these tests read into them.
openSettingsCards();
clipboardWriteText.mockClear();
resetAllStores();
useAssistantStore.setState({ status: null });
@@ -91,21 +93,18 @@ describe('IntegrationsTab', () => {
it('FE-COMP-INTEGRATIONS-004: MCP endpoint URL is displayed', async () => {
enableMcp();
render(<IntegrationsTab />);
await screen.findByText('MCP Configuration');
await openMcp();
const { code } = mcpEndpointRow();
expect(code).not.toBeNull();
expect(code.textContent).toContain('/mcp');
});
it('FE-COMP-INTEGRATIONS-005: JSON config block is rendered when expanded', async () => {
const user = userEvent.setup();
it('FE-COMP-INTEGRATIONS-005: JSON config block is always rendered', async () => {
enableMcp();
render(<IntegrationsTab />);
await screen.findByText('MCP Configuration');
// Config is collapsed by default — no <pre> yet
expect(document.querySelector('pre')).toBeNull();
// Expand by clicking the "Client Configuration" toggle
await user.click(screen.getByRole('button', { name: /Client Configuration/i }));
await openMcp();
// The client config is always shown: nothing folds away in the settings.
expect(screen.getByText('Client Configuration')).toBeInTheDocument();
const preEl = document.querySelector('pre');
expect(preEl).not.toBeNull();
expect(preEl!.textContent).toContain('mcpServers');
@@ -115,7 +114,7 @@ describe('IntegrationsTab', () => {
const user = userEvent.setup();
enableMcp();
render(<IntegrationsTab />);
await screen.findByText('MCP Configuration');
await openMcp();
await user.click(screen.getByRole('button', { name: /API Tokens/i }));
expect(await screen.findByText('No tokens yet. Create one to connect MCP clients.')).toBeInTheDocument();
});
@@ -134,7 +133,7 @@ describe('IntegrationsTab', () => {
const user = userEvent.setup();
enableMcp();
render(<IntegrationsTab />);
await screen.findByText('MCP Configuration');
await openMcp();
await user.click(screen.getByRole('button', { name: /API Tokens/i }));
await screen.findByText('My Token');
expect(await screen.findByText('Other Token')).toBeInTheDocument();
@@ -144,7 +143,7 @@ describe('IntegrationsTab', () => {
const user = userEvent.setup();
enableMcp();
render(<IntegrationsTab />);
await screen.findByText('MCP Configuration');
await openMcp();
await user.click(screen.getByRole('button', { name: /API Tokens/i }));
const createBtn = screen.getByRole('button', { name: /Create New Token/i });
await user.click(createBtn);
@@ -155,7 +154,7 @@ describe('IntegrationsTab', () => {
const user = userEvent.setup();
enableMcp();
render(<IntegrationsTab />);
await screen.findByText('MCP Configuration');
await openMcp();
await user.click(screen.getByRole('button', { name: /API Tokens/i }));
await user.click(screen.getByRole('button', { name: /Create New Token/i }));
await screen.findByText('Create API Token');
@@ -167,7 +166,7 @@ describe('IntegrationsTab', () => {
const user = userEvent.setup();
enableMcp();
render(<IntegrationsTab />);
await screen.findByText('MCP Configuration');
await openMcp();
await user.click(screen.getByRole('button', { name: /API Tokens/i }));
await user.click(screen.getByRole('button', { name: /Create New Token/i }));
await screen.findByText('Create API Token');
@@ -194,7 +193,7 @@ describe('IntegrationsTab', () => {
const user = userEvent.setup();
enableMcp();
render(<IntegrationsTab />);
await screen.findByText('MCP Configuration');
await openMcp();
await user.click(screen.getByRole('button', { name: /API Tokens/i }));
await user.click(screen.getByRole('button', { name: /Create New Token/i }));
await screen.findByText('Create API Token');
@@ -224,7 +223,7 @@ describe('IntegrationsTab', () => {
const user = userEvent.setup();
enableMcp();
render(<IntegrationsTab />);
await screen.findByText('MCP Configuration');
await openMcp();
await user.click(screen.getByRole('button', { name: /API Tokens/i }));
await user.click(screen.getByRole('button', { name: /Create New Token/i }));
await screen.findByText('Create API Token');
@@ -250,7 +249,7 @@ describe('IntegrationsTab', () => {
const user = userEvent.setup();
enableMcp();
render(<IntegrationsTab />);
await screen.findByText('MCP Configuration');
await openMcp();
await user.click(screen.getByRole('button', { name: /API Tokens/i }));
await screen.findByText('Delete Me');
await user.click(screen.getByLabelText('Delete Token'));
@@ -276,7 +275,7 @@ describe('IntegrationsTab', () => {
const user = userEvent.setup();
enableMcp();
render(<IntegrationsTab />);
await screen.findByText('MCP Configuration');
await openMcp();
await user.click(screen.getByRole('button', { name: /API Tokens/i }));
await screen.findByText('Delete Me');
await user.click(screen.getByLabelText('Delete Token'));
@@ -294,7 +293,7 @@ describe('IntegrationsTab', () => {
const user = userEvent.setup();
enableMcp();
render(<IntegrationsTab />);
await screen.findByText('MCP Configuration');
await openMcp();
// Spy after userEvent.setup() may have replaced navigator.clipboard
const writeSpy = vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue(undefined);
await user.click(mcpEndpointRow().copy);
@@ -305,7 +304,7 @@ describe('IntegrationsTab', () => {
const user = userEvent.setup();
enableMcp();
render(<IntegrationsTab />);
await screen.findByText('MCP Configuration');
await openMcp();
vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue(undefined);
const { copy } = mcpEndpointRow();
await user.click(copy);
@@ -334,7 +333,7 @@ describe('IntegrationsTab', () => {
const user = userEvent.setup();
enableMcp();
render(<IntegrationsTab />);
await screen.findByText('MCP Configuration');
await openMcp();
await user.click(screen.getByRole('button', { name: /API Tokens/i }));
await screen.findByText('Cancel Token');
await user.click(screen.getByLabelText('Delete Token'));
@@ -365,7 +364,7 @@ describe('IntegrationsTab', () => {
const user = userEvent.setup();
enableMcp();
render(<IntegrationsTab />);
await screen.findByText('MCP Configuration');
await openMcp();
await user.click(screen.getByRole('button', { name: /API Tokens/i }));
await user.click(screen.getByRole('button', { name: /Create New Token/i }));
await screen.findByText('Create API Token');
@@ -380,7 +379,7 @@ describe('IntegrationsTab', () => {
it('FE-COMP-INTEGRATIONS-019: default tab is OAuth 2.1 Clients — OAuth hint visible, token list hidden', async () => {
enableMcp();
render(<IntegrationsTab />);
await screen.findByText('MCP Configuration');
await openMcp();
// OAuth hint is visible on the default tab
expect(screen.getByText(/Register OAuth 2\.1 clients/i)).toBeInTheDocument();
// API Tokens "no tokens" message is not rendered
@@ -391,7 +390,7 @@ describe('IntegrationsTab', () => {
const user = userEvent.setup();
enableMcp();
render(<IntegrationsTab />);
await screen.findByText('MCP Configuration');
await openMcp();
// Default: OAuth hint visible, token list absent
expect(screen.getByText(/Register OAuth 2\.1 clients/i)).toBeInTheDocument();
expect(screen.queryByText('No tokens yet. Create one to connect MCP clients.')).toBeNull();
@@ -475,7 +474,7 @@ describe('IntegrationsTab', () => {
const user = userEvent.setup();
enableMcp();
render(<IntegrationsTab />);
await screen.findByText('MCP Configuration');
await openMcp();
await user.click(screen.getByRole('button', { name: /New Client/i }));
await screen.findByText('Register OAuth Client');
expect(screen.getByText('Claude.ai')).toBeInTheDocument();
@@ -486,7 +485,7 @@ describe('IntegrationsTab', () => {
const user = userEvent.setup();
enableMcp();
render(<IntegrationsTab />);
await screen.findByText('MCP Configuration');
await openMcp();
await user.click(screen.getByRole('button', { name: /New Client/i }));
await screen.findByText('Register OAuth Client');
// Presets render as buttons — click "Claude.ai" preset
@@ -516,7 +515,7 @@ describe('IntegrationsTab', () => {
);
enableMcp();
render(<IntegrationsTab />);
await screen.findByText('MCP Configuration');
await openMcp();
await user.click(screen.getByRole('button', { name: /New Client/i }));
await screen.findByText('Register OAuth Client');
@@ -550,7 +549,7 @@ describe('IntegrationsTab', () => {
);
enableMcp();
render(<IntegrationsTab />);
await screen.findByText('MCP Configuration');
await openMcp();
await user.click(screen.getByRole('button', { name: /New Client/i }));
await screen.findByText('Register OAuth Client');
await user.type(screen.getByPlaceholderText(/Claude Web, My MCP App/i), 'TC2');
@@ -646,7 +645,7 @@ describe('IntegrationsTab', () => {
const user = userEvent.setup();
enableMcp();
render(<IntegrationsTab />);
await screen.findByText('MCP Configuration');
await openMcp();
await user.click(screen.getByRole('button', { name: /New Client/i }));
await screen.findByText('Register OAuth Client');
const createBtn = screen.getByRole('button', { name: /Register Client/i });
@@ -665,7 +664,7 @@ describe('IntegrationsTab', () => {
);
enableMcp();
render(<><ToastContainer /><IntegrationsTab /></>);
await screen.findByText('MCP Configuration');
await openMcp();
await user.click(screen.getByRole('button', { name: /New Client/i }));
await screen.findByText('Register OAuth Client');
await user.type(screen.getByPlaceholderText(/Claude Web, My MCP App/i), 'Fail Client');
@@ -680,7 +679,7 @@ describe('IntegrationsTab', () => {
server.use(http.post('/api/oauth/clients', () => HttpResponse.error()));
enableMcp();
render(<><ToastContainer /><IntegrationsTab /></>);
await screen.findByText('MCP Configuration');
await openMcp();
await user.click(screen.getByRole('button', { name: /New Client/i }));
await screen.findByText('Register OAuth Client');
await user.type(screen.getByPlaceholderText(/Claude Web, My MCP App/i), 'Fail Client');
@@ -710,7 +709,7 @@ const oneToken = [
];
async function openTokensTab(user: ReturnType<typeof userEvent.setup>) {
await screen.findByText('MCP Configuration');
await openMcp();
await user.click(screen.getByRole('button', { name: /API Tokens/i }));
}
@@ -725,10 +724,9 @@ describe('IntegrationsTab – copy actions and cancels', () => {
const user = userEvent.setup();
enableMcp();
render(<IntegrationsTab />);
await screen.findByText('MCP Configuration');
await openMcp();
const writeSpy = vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue(undefined);
await user.click(screen.getByRole('button', { name: /Client Configuration/i }));
await user.click(labelledCopyButton());
expect(writeSpy).toHaveBeenCalledWith(expect.stringContaining('mcp'));
@@ -742,7 +740,6 @@ describe('IntegrationsTab – copy actions and cancels', () => {
await openTokensTab(user);
const writeSpy = vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue(undefined);
await user.click(screen.getByRole('button', { name: /Client Configuration/i }));
await user.click(labelledCopyButton());
expect(writeSpy).toHaveBeenCalledWith(expect.stringContaining('mcp'));
@@ -790,7 +787,7 @@ describe('IntegrationsTab – copy actions and cancels', () => {
const user = userEvent.setup();
enableMcp();
render(<IntegrationsTab />);
await screen.findByText('MCP Configuration');
await openMcp();
await user.click(screen.getByRole('button', { name: /New Client/i }));
await screen.findByText('Register OAuth Client');
@@ -803,7 +800,7 @@ describe('IntegrationsTab – copy actions and cancels', () => {
const user = userEvent.setup();
enableMcp();
render(<IntegrationsTab />);
await screen.findByText('MCP Configuration');
await openMcp();
await user.click(screen.getByRole('button', { name: /New Client/i }));
await screen.findByText('Register OAuth Client');
@@ -834,7 +831,7 @@ describe('IntegrationsTab – copy actions and cancels', () => {
);
enableMcp();
render(<IntegrationsTab />);
await screen.findByText('MCP Configuration');
await openMcp();
await user.click(screen.getByRole('button', { name: /New Client/i }));
await user.type(screen.getByPlaceholderText(/Claude Web, My MCP App/i), 'Copyable');
await user.type(screen.getByPlaceholderText(/https:\/\/your-app/i), 'http://localhost');
@@ -1015,6 +1012,7 @@ describe('IntegrationsTab – failure toasts', () => {
enableAddons(['ai_assistant']);
const { unmount } = render(<IntegrationsTab />);
// Its own card on the page, the connection form open in it.
expect(await screen.findByText('What would you like to chat with?')).toBeInTheDocument();
unmount();
@@ -3,7 +3,7 @@ import React, { useId } from 'react'
import { useTranslation } from '../../i18n'
import { useFormatDate } from '../../hooks/useDateFormat'
import { useToast } from '../shared/Toast'
import { Trash2, Copy, Terminal, Plus, Check, Key, KeyRound, ChevronRight, RefreshCw, AlertTriangle, Link2 } from 'lucide-react'
import { Trash2, Copy, Terminal, Plus, Check, Key, KeyRound, RefreshCw, AlertTriangle, Link2 } from 'lucide-react'
import PhotoProvidersSection from './PhotoProvidersSection'
import AirTrailConnectionSection from './AirTrailConnectionSection'
import MailImportSection from './MailImportSection'
@@ -16,7 +16,7 @@ import ConfirmDialog from '../shared/ConfirmDialog'
import { Tooltip } from '../shared/Tooltip'
import { DialogButton, DialogFooter, DialogHeader, DialogSection, DialogShell, DialogTile, FooterSpacer, NEUTRAL_TINT, fs } from '../shared/DialogShell'
import { EditorField, INPUT, Segmented, TEXTAREA } from '../shared/dialogParts'
import { SETTINGS_BUTTON, SETTINGS_BUTTON_PRIMARY, SETTINGS_ICON_BUTTON, SettingRows, SettingsHint, StatusPill } from './settingsKit'
import { SETTINGS_BUTTON, SETTINGS_BUTTON_PRIMARY, SETTINGS_ICON_BUTTON, SettingRows, SettingsColumns, SettingsHint, StatusPill } from './settingsKit'
import { OAUTH_PRESETS, useMcpIntegration, type OAuthClient } from './useMcpIntegration'
import { useIntegrationGates } from './useIntegrationGates'
import WandererConnectionSection from './WandererConnectionSection'
@@ -29,47 +29,43 @@ export default function IntegrationsTab(): React.ReactElement {
const demoLocked = useDemoAccountLock()
return (
<>
{/* Two columns from xl up, as on the admin settings: what connects your
trips with other services on the left, AI and developer access on the
right. Flex, not a grid: a column with nothing to show (a managed
install without the mail import) is empty and drops out, and the other
takes the whole width instead of leaving a hole. The cards carry their
own bottom margin, so only the gap between the columns is set. */}
<div className="xl:flex xl:items-start xl:gap-5">
<div className="min-w-0 flex-1 empty:hidden">
{/* Forwarded booking mails: hides itself where this server or plan has no import. */}
<MailImportSection />
{/* Immich, Synology Photos and AirTrail all connect to a server the reader
runs themselves. On a managed install there is none, and every one of
these would ask for an address that cannot be reached from here. */}
{!managed && <PhotoProvidersSection />}
{S.airtrailEnabled && !managed && <AirTrailConnectionSection />}
{S.dawarichEnabled && !managed && <DawarichConnectionSection />}
{S.wandererEnabled && !managed && <WandererConnectionSection />}
</div>
<div className="min-w-0 flex-1 empty:hidden">
{/* Which model reads a booking, and what that costs, comes with the instance on
a managed install. The per-user fallback exists for people who supply their
own key, and there nobody does. */}
{/* The assistant in TREK, with the same managed rule as AI parsing: there the
operator decides which model the install talks to. */}
{S.aiConnectionEnabled && !managed && (
<AiConnectionSection assistant={S.aiAssistantEnabled} parsing={S.llmEnabled} />
)}
{S.llmEnabled && S.ownParsing && !managed && <LlmConnectionSection />}
{/* Above MCP on purpose: an API key needs no addon, and someone looking for
one should not have to read past a section about AI assistants. */}
<DemoLock locked={demoLocked} reason={S.t('demo.lock.apiKeys')}>
<ApiKeysSection />
</DemoLock>
{/* TREK inside Claude rides on the MCP server, so it follows that addon. */}
{S.mcpEnabled && (
<DemoLock locked={demoLocked} reason={S.t('demo.lock.mcp')}>
<IntegrationsMcpSection S={S} />
{/* What connects your trips with other services on the left, AI and
developer access on the right, every card open on the page. */}
<SettingsColumns
left={
<>
{/* Forwarded booking mails: hides itself where this server or plan has no import. */}
<MailImportSection />
{/* Immich, Synology Photos and AirTrail all connect to a server the reader
runs themselves. On a managed install there is none, and every one of
these would ask for an address that cannot be reached from here. */}
{!managed && <PhotoProvidersSection />}
{S.airtrailEnabled && !managed && <AirTrailConnectionSection />}
{S.dawarichEnabled && !managed && <DawarichConnectionSection />}
{S.wandererEnabled && !managed && <WandererConnectionSection />}
{/* The assistant and AI parsing: on a managed install the operator decides
which model the install talks to, so neither is offered there. */}
{S.aiConnectionEnabled && !managed && (
<AiConnectionSection assistant={S.aiAssistantEnabled} parsing={S.llmEnabled} />
)}
{S.llmEnabled && S.ownParsing && !managed && <LlmConnectionSection />}
</>
}
right={
<>
{/* An API key needs no addon, so it leads, ahead of the MCP row. */}
<DemoLock locked={demoLocked} reason={S.t('demo.lock.apiKeys')}>
<ApiKeysSection />
</DemoLock>
)}
</div>
</div>
{/* TREK inside Claude rides on the MCP server, so it follows that addon. */}
{S.mcpEnabled && (
<DemoLock locked={demoLocked} reason={S.t('demo.lock.mcp')}>
<IntegrationsMcpSection S={S} />
</DemoLock>
)}
</>
}
/>
<McpTokenModals S={S} />
<OAuthClientModals S={S} />
</>
@@ -151,10 +147,8 @@ function EmptyHint({ children }: { children: React.ReactNode }) {
)
}
/** The mcp-remote snippet, folded away until asked for. */
function ConfigBlock({ open, onToggle, json, hint, copied, onCopy, t }: {
open: boolean
onToggle: () => void
/** The mcp-remote snippet, shown with its copy button. */
function ConfigBlock({ json, hint, copied, onCopy, t }: {
json: string
hint: string
copied: boolean
@@ -163,33 +157,26 @@ function ConfigBlock({ open, onToggle, json, hint, copied, onCopy, t }: {
}) {
return (
<div className="overflow-hidden rounded-[12px] border border-edge-faint bg-surface-card">
<button type="button" onClick={onToggle} aria-expanded={open}
className="flex w-full items-center gap-2 px-3.5 py-2.5 text-start hover:bg-surface-secondary">
<ChevronRight size={15} strokeWidth={2.2} className={`flex-none text-content-faint transition-transform ${open ? 'rotate-90' : ''}`} />
<div className="flex items-center gap-2 px-3.5 py-2.5">
<span className="flex-1 font-medium text-content" style={fs(13, 'body')}>{t('settings.mcp.clientConfig')}</span>
</button>
{open && (
<div className="flex flex-col gap-2 border-t border-edge-faint p-3.5">
<div className="flex justify-end">
<button type="button" onClick={onCopy} className={`${SMALL_BUTTON} hover:text-content`} style={fs(12, 'body')}>
{copied ? <Check size={13} strokeWidth={2.2} className="text-success" /> : <Copy size={13} strokeWidth={2} />}
{copied ? t('settings.mcp.copied') : t('settings.mcp.copy')}
</button>
</div>
<pre className="m-0 max-h-80 overflow-auto rounded-[10px] border border-edge-faint bg-surface-secondary p-3 font-mono leading-relaxed text-content" style={fs(11.5)}>
{json}
</pre>
<SettingsHint>{hint}</SettingsHint>
</div>
)}
<button type="button" onClick={onCopy} className={`${SMALL_BUTTON} hover:text-content`} style={fs(12, 'body')}>
{copied ? <Check size={13} strokeWidth={2.2} className="text-success" /> : <Copy size={13} strokeWidth={2} />}
{copied ? t('settings.mcp.copied') : t('settings.mcp.copy')}
</button>
</div>
<div className="flex flex-col gap-2 border-t border-edge-faint p-3.5">
<pre className="m-0 max-h-80 overflow-auto rounded-[10px] border border-edge-faint bg-surface-secondary p-3 font-mono leading-relaxed text-content" style={fs(11.5)}>
{json}
</pre>
<SettingsHint>{hint}</SettingsHint>
</div>
</div>
)
}
/** One registered OAuth client: what it is, the scopes it may use, and its two actions. */
function OAuthClientRow({ client, S }: { client: OAuthClient; S: IntegrationsState }) {
const { t, formatDate, oauthScopesExpanded, setOauthScopesExpanded, setOauthRotateId, setOauthDeleteId } = S
const expanded = !!oauthScopesExpanded[client.id]
const { t, formatDate, setOauthRotateId, setOauthDeleteId } = S
return (
<div className={`${ROW} items-start`}>
<span className={ROW_TILE}><KeyRound size={15} strokeWidth={2} /></span>
@@ -205,17 +192,10 @@ function OAuthClientRow({ client, S }: { client: OAuthClient; S: IntegrationsSta
<span className="tabular-nums">{t('settings.mcp.tokenCreatedAt')} {formatDate(new Date(client.created_at))}</span>
</div>
<div className="mt-2 flex flex-wrap gap-1" style={fs(10.5)}>
{(expanded ? client.allowed_scopes : client.allowed_scopes.slice(0, 5)).map(s => (
{/* Every scope the client may use, none folded behind a "+N". */}
{client.allowed_scopes.map(s => (
<span key={s} className={CHIP}>{s}</span>
))}
{client.allowed_scopes.length > 5 && (
<button
type="button"
onClick={() => setOauthScopesExpanded(prev => ({ ...prev, [client.id]: !prev[client.id] }))}
className={`${CHIP} tabular-nums hover:text-content`}>
{expanded ? '−' : `+${client.allowed_scopes.length - 5}`}
</button>
)}
</div>
</div>
<div className="flex flex-none items-center gap-1.5">
@@ -233,13 +213,11 @@ function OAuthClientRow({ client, S }: { client: OAuthClient; S: IntegrationsSta
/** The OAuth tab: the snippet, the registered clients and the sessions they hold. */
function OAuthTab({ S }: { S: IntegrationsState }) {
const {
t, formatDate, oauthClients, oauthSessions, setOauthCreateOpen, setOauthNewName, setOauthNewUris, setOauthNewScopes, setOauthCreatedClient, setOauthRevokeId, setOauthIsMachine, configOpenOAuth, setConfigOpenOAuth, copiedKey, mcpJsonConfigOAuth, handleCopy,
t, formatDate, oauthClients, oauthSessions, setOauthCreateOpen, setOauthNewName, setOauthNewUris, setOauthNewScopes, setOauthCreatedClient, setOauthRevokeId, setOauthIsMachine, copiedKey, mcpJsonConfigOAuth, handleCopy,
} = S
return (
<>
<ConfigBlock
open={configOpenOAuth}
onToggle={() => setConfigOpenOAuth(o => !o)}
json={mcpJsonConfigOAuth}
hint={t('settings.mcp.clientConfigHintOAuth')}
copied={copiedKey === 'json-oauth'}
@@ -251,7 +229,7 @@ function OAuthTab({ S }: { S: IntegrationsState }) {
<div className="flex flex-wrap items-center gap-3">
<SettingsHint className="min-w-0 flex-1 basis-64">{t('settings.oauth.clientsHint')}</SettingsHint>
<button type="button" onClick={() => { setOauthCreateOpen(true); setOauthCreatedClient(null); setOauthNewName(''); setOauthNewUris(''); setOauthNewScopes([]); setOauthIsMachine(false) }}
className={SETTINGS_BUTTON_PRIMARY} style={fs(13, 'body')}>
className={SETTINGS_BUTTON_PRIMARY}>
<Plus size={14} strokeWidth={2.2} /> {t('settings.oauth.createClient')}
</button>
</div>
@@ -295,14 +273,12 @@ function OAuthTab({ S }: { S: IntegrationsState }) {
/** The deprecated API tokens tab: the warning, the snippet and the tokens. */
function ApiTokensTab({ S }: { S: IntegrationsState }) {
const { t, formatDate, configOpenToken, setConfigOpenToken, mcpTokens, setMcpModalOpen, setMcpNewName, setMcpCreatedToken, setMcpDeleteId, copiedKey, mcpJsonConfig, handleCopy } = S
const { t, formatDate, mcpTokens, setMcpModalOpen, setMcpNewName, setMcpCreatedToken, setMcpDeleteId, copiedKey, mcpJsonConfig, handleCopy } = S
return (
<>
<WarningNote>{t('settings.mcp.apiTokensDeprecated')}</WarningNote>
<ConfigBlock
open={configOpenToken}
onToggle={() => setConfigOpenToken(o => !o)}
json={mcpJsonConfig}
hint={t('settings.mcp.clientConfigHint')}
copied={copiedKey === 'json-token'}
@@ -312,7 +288,7 @@ function ApiTokensTab({ S }: { S: IntegrationsState }) {
<div className="flex justify-end">
<button type="button" onClick={() => { setMcpModalOpen(true); setMcpCreatedToken(null); setMcpNewName('') }}
className={SETTINGS_BUTTON} style={fs(13, 'body')}>
className={SETTINGS_BUTTON}>
<Plus size={14} strokeWidth={2.2} /> {t('settings.mcp.createToken')}
</button>
</div>
@@ -352,13 +328,12 @@ function IntegrationsMcpSection({ S }: { S: IntegrationsState }) {
return (
<Section
id="mcp"
collapsible
title={t('settings.mcp.title')}
icon={Terminal}
badge={
connected > 0
? <StatusPill tone="success" icon={<span className="h-1.5 w-1.5 rounded-full bg-current" />}>{t('settings.mcp.appsConnected', { count: connected })}</StatusPill>
: <StatusPill>{t('settings.notSetUp')}</StatusPill>
: <StatusPill icon={<span className="h-1.5 w-1.5 rounded-full bg-current" />}>{t('settings.notSetUp')}</StatusPill>
}
>
{/* Endpoint URL */}
@@ -8,7 +8,6 @@ import { useAuthStore } from '../../store/authStore';
import type { Settings } from '../../types';
import { ToastContainer } from '../shared/Toast';
import LlmConnectionSection from './LlmConnectionSection';
import { openSettingsCards } from '../../../tests/helpers/settingsCards';
let loadSettings = vi.fn().mockResolvedValue(undefined);
@@ -51,8 +50,6 @@ async function pickProvider(user: ReturnType<typeof userEvent.setup>, current: R
}
beforeEach(() => {
// The cards start shut; these tests read into them.
openSettingsCards();
resetAllStores();
vi.clearAllMocks();
seedRole('user');
@@ -45,7 +45,6 @@ export default function LlmConnectionSection(): React.ReactElement {
return (
<Section
id="ai-parsing"
collapsible
title={t('settings.aiParsing.title')}
icon={Sparkles}
badge={<ParsingState />}
@@ -117,5 +116,5 @@ function ParsingState() {
const model = useSettingsStore(s => s.settings.llm_model)
return model
? <StatusPill tone="success" icon={<span className="h-1.5 w-1.5 rounded-full bg-current" />}>{model}</StatusPill>
: <StatusPill>{t('settings.notSetUp')}</StatusPill>
: <StatusPill icon={<span className="h-1.5 w-1.5 rounded-full bg-current" />}>{t('settings.notSetUp')}</StatusPill>
}
@@ -1,4 +1,4 @@
import { ChevronRight, Copy, FileUp, Inbox, Loader2, Mail, RefreshCw } from 'lucide-react';
import { Copy, FileUp, Inbox, Loader2, Mail, RefreshCw } from 'lucide-react';
import { useRef, type ReactElement } from 'react';
import { useTranslation } from '../../i18n';
import { openMailInbox } from '../MailIngest/mailInboxOpener';
@@ -31,7 +31,6 @@ export default function MailImportSection(): ReactElement | null {
return (
<Section
id="mail-import"
collapsible
title={t('mailInbox.settings.title')}
icon={Mail}
badge={
@@ -78,21 +77,14 @@ export default function MailImportSection(): ReactElement | null {
</Tooltip>
</div>
<SettingsHint>{t('mailInbox.settings.addressHint')}</SettingsHint>
<details className="group rounded-lg bg-surface-tertiary px-3 py-2 text-body text-content-secondary">
<summary className="flex cursor-pointer list-none items-center gap-1.5 font-medium text-content">
<ChevronRight
size={14}
strokeWidth={2}
className="transition-transform group-open:rotate-90 rtl:-scale-x-100"
/>
{t('mailInbox.settings.howTo')}
</summary>
<div className="rounded-lg bg-surface-tertiary px-3 py-2 text-body text-content-secondary">
<div className="font-medium text-content">{t('mailInbox.settings.howTo')}</div>
<ul className="mt-2 flex list-disc flex-col gap-1.5 ps-5 text-caption">
<li>{t('mailInbox.settings.howToGmail')}</li>
<li>{t('mailInbox.settings.howToOutlook')}</li>
<li>{t('mailInbox.settings.howToManual')}</li>
</ul>
</details>
</div>
</div>
)}
@@ -379,7 +379,6 @@ export default function MapSettingsTab(): React.ReactElement {
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')}
@@ -119,10 +119,10 @@ export default function NotificationsTab(): React.ReactElement {
aria-label={t('settings.webhookUrl.label')}
className={`${INPUT} min-w-[220px] flex-1`}
/>
<button type="button" onClick={testWebhookUrl} disabled={webhookTestDisabled} className={SETTINGS_BUTTON} style={fs(13, 'body')}>
<button type="button" onClick={testWebhookUrl} disabled={webhookTestDisabled} className={SETTINGS_BUTTON}>
{t('settings.webhookUrl.test')}
</button>
<button type="button" onClick={saveWebhookUrl} disabled={webhookSaving} className={SETTINGS_BUTTON_PRIMARY} style={fs(13, 'body')}>
<button type="button" onClick={saveWebhookUrl} disabled={webhookSaving} className={SETTINGS_BUTTON_PRIMARY}>
{t('common.save')}
</button>
</div>
@@ -164,17 +164,17 @@ export default function NotificationsTab(): React.ReactElement {
className={`${INPUT} flex-1`}
/>
{ntfyTokenIsSet && (
<button type="button" onClick={clearNtfyToken} className={SETTINGS_BUTTON_DANGER} style={fs(13, 'body')}>
<button type="button" onClick={clearNtfyToken} className={SETTINGS_BUTTON_DANGER}>
{t('common.clear')}
</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')}>
<button type="button" onClick={testNtfySettings} disabled={!ntfyTopic || ntfyTesting} className={SETTINGS_BUTTON}>
{t('settings.ntfyUrl.test')}
</button>
<button type="button" onClick={saveNtfySettings} disabled={ntfySaving} className={SETTINGS_BUTTON_PRIMARY} style={fs(13, 'body')}>
<button type="button" onClick={saveNtfySettings} disabled={ntfySaving} className={SETTINGS_BUTTON_PRIMARY}>
{t('common.save')}
</button>
</div>
@@ -198,7 +198,7 @@ export default function NotificationsTab(): React.ReactElement {
{/* 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} className={`${SETTINGS_BUTTON_PRIMARY} no-underline`} style={fs(13, 'body')}>
<Link to={ch.settingsPath} className={`${SETTINGS_BUTTON_PRIMARY} no-underline`}>
{t('settings.notificationPreferences.configure')}
</Link>
)}
@@ -206,7 +206,6 @@ export default function NotificationsTab(): React.ReactElement {
onClick={() => testChannel(ch)}
disabled={!ch.configured || channelTesting === ch.id}
className={SETTINGS_BUTTON}
style={fs(13, 'body')}
>
{t('settings.notificationPreferences.sendTest')}
</button>
+47 -43
View File
@@ -14,7 +14,7 @@ import React, { useState } 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 { SettingRow, SettingRows, SettingsHint, StatusPill, SETTINGS_BUTTON, SETTINGS_BUTTON_DANGER, SettingsColumns } from './settingsKit'
import { useOfflineSettings, offlineNoticeKey, isOfflineNoticeWarning, offlineProgressLabel, offlineRowMeta } from './useOfflineSettings'
import { useTranslation } from '../../i18n'
import CustomSelect from '../shared/CustomSelect'
@@ -49,6 +49,10 @@ export default function OfflineTab(): React.ReactElement {
return (
<div>
{/* Two columns by length: what the mode does and what is cached on the
left, the per-trip choice of what to keep on the right. */}
<SettingsColumns
left={<>
{/* Offline mode + prepare */}
<Section title={t('settings.offline.mode.title')} icon={CloudOff}>
<SettingRows>
@@ -71,7 +75,6 @@ export default function OfflineTab(): React.ReactElement {
onClick={runPrepare}
disabled={preparing || offline}
className={SETTINGS_BUTTON}
style={fs(13, 'body')}
>
{preparing
? <RefreshCw size={14} className="animate-spin" />
@@ -82,7 +85,6 @@ export default function OfflineTab(): React.ReactElement {
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')}
@@ -130,10 +132,10 @@ export default function OfflineTab(): React.ReactElement {
{t('settings.offline.conflicts.item', { name: conflictName(c) })}
</span>
<div className="flex flex-none gap-2">
<button type="button" onClick={() => resolveConflict(c.id, true)} className={SETTINGS_BUTTON} style={fs(12.5, 'body')}>
<button type="button" onClick={() => resolveConflict(c.id, true)} className={SETTINGS_BUTTON}>
{t('settings.offline.conflicts.keepMine')}
</button>
<button type="button" onClick={() => resolveConflict(c.id, false)} className={SETTINGS_BUTTON} style={fs(12.5, 'body')}>
<button type="button" onClick={() => resolveConflict(c.id, false)} className={SETTINGS_BUTTON}>
{t('settings.offline.conflicts.keepServer')}
</button>
</div>
@@ -163,41 +165,6 @@ export default function OfflineTab(): React.ReactElement {
</Section>
)}
{/* What to store offline */}
<Section title={t('settings.offline.storage.title')} icon={MapIcon}>
<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')} />}
/>
</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>
</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')}
@@ -207,7 +174,6 @@ export default function OfflineTab(): React.ReactElement {
onClick={() => setConfirmClear(true)}
disabled={clearing || !canClear}
className={SETTINGS_BUTTON_DANGER}
style={fs(12.5, 'body')}
>
<Trash2 size={14} />
{t('settings.offline.clear')}
@@ -225,11 +191,11 @@ export default function OfflineTab(): React.ReactElement {
<div className="flex flex-wrap items-center justify-between gap-3">
<SettingsHint>{t('settings.offline.failed.hint')}</SettingsHint>
<div className="flex gap-2">
<button type="button" onClick={() => void retryFailed()} disabled={offline} className={SETTINGS_BUTTON} style={fs(12.5, 'body')}>
<button type="button" onClick={() => void retryFailed()} disabled={offline} className={SETTINGS_BUTTON}>
<RefreshCw size={14} />
{t('settings.offline.failed.retry')}
</button>
<button type="button" onClick={() => void discardFailed()} className={SETTINGS_BUTTON_DANGER} style={fs(12.5, 'body')}>
<button type="button" onClick={() => void discardFailed()} className={SETTINGS_BUTTON_DANGER}>
<Trash2 size={14} />
{t('settings.offline.failed.discard')}
</button>
@@ -268,6 +234,44 @@ export default function OfflineTab(): React.ReactElement {
</SettingRows>
)}
</Section>
</>}
right={<>
{/* What to store offline */}
<Section title={t('settings.offline.storage.title')} icon={MapIcon}>
<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')} />}
/>
</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>
</div>
<ToggleSwitch on={on} onToggle={() => handleToggleTrip(trip)} label={trip.title} />
</div>
)
})}
</SettingRows>
</div>
)}
</Section>
</>}
/>
<ConfirmDialog
isOpen={confirmClear}
@@ -30,7 +30,7 @@ export default function PasskeysSection({ demoMode }: { demoMode?: boolean }): R
badge={pk.creds.length > 0 ? <StatusPill>{pk.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={pk.canAdd && !pk.addOpen ? (
<button type="button" onClick={() => pk.setAddOpen(true)} className={SETTINGS_BUTTON} style={fs(12.5, 'body')}>
<button type="button" onClick={() => pk.setAddOpen(true)} className={SETTINGS_BUTTON}>
<Plus size={14} />
{t('settings.passkey.add')}
</button>
@@ -9,7 +9,6 @@ import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { buildUser } from '../../../tests/helpers/factories';
import { ToastContainer } from '../shared/Toast';
import PhotoProvidersSection from './PhotoProvidersSection';
import { openSettingsCards } from '../../../tests/helpers/settingsCards';
const fakeProvider = {
id: 'immich',
@@ -46,8 +45,6 @@ function seedMemoriesEnabled(providers = [fakeProvider]) {
}
beforeEach(() => {
// The cards start shut; these tests read into them.
openSettingsCards();
resetAllStores();
vi.clearAllMocks();
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true });
@@ -36,7 +36,6 @@ export default function PhotoProvidersSection(): React.ReactElement {
return (
<Section
id={`photos-${provider.id}`}
collapsible
key={provider.id}
title={provider.name || provider.id}
icon={Camera}
@@ -66,8 +66,8 @@ function PluginOAuthSection({ id, state, setState }: {
? <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>}
? <button type="button" onClick={disconnect} disabled={busy} className={SETTINGS_BUTTON}><Unlink size={14} />{t('settings.plugins.oauth.disconnect')}</button>
: <button type="button" onClick={connect} disabled={busy} className={SETTINGS_BUTTON_PRIMARY}>{busy ? <Loader2 size={14} className="animate-spin" /> : <Link2 size={14} />}{t('settings.plugins.oauth.connect')}</button>}
/>
</SettingRows>
)
@@ -158,7 +158,7 @@ function PluginSettingsForm({ id, name, icon }: { id: string; name: string; icon
</Fragment>
))}
<div className="flex justify-end">
<button type="button" onClick={save} disabled={saving} className={SETTINGS_BUTTON_PRIMARY} style={fs(13, 'body')}>
<button type="button" onClick={save} disabled={saving} className={SETTINGS_BUTTON_PRIMARY}>
{saving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
{t('common.save')}
</button>
@@ -180,7 +180,6 @@ function PluginSettingsForm({ id, name, icon }: { id: string; name: string; icon
onClick={() => runAction(a)}
disabled={running !== null}
className={a.danger ? SETTINGS_BUTTON_DANGER : SETTINGS_BUTTON}
style={fs(13, 'body')}
>
{running === a.key && <Loader2 size={14} className="animate-spin" />}
{a.label}
+8 -6
View File
@@ -9,22 +9,24 @@ interface SectionProps {
* standing in for a glyph gets sized by the same utilities.
*/
icon: LucideIcon | React.ComponentType<{ className?: string }>
/** The icon is a brand badge that fills its tile instead of a glyph in it. */
iconFill?: boolean
badge?: React.ReactNode
/** One line under the title in the head band. */
hint?: React.ReactNode
/** A control on the right of the head band. */
action?: React.ReactNode
/** Where a link lands and, for a collapsible card, what it is remembered by. */
/** Where a link lands: `#id` scrolls to the card, or opens it from an overview. */
id?: string
/** Shut until its head is clicked, the badge saying its state meanwhile (see settingsKit). */
collapsible?: boolean
children: React.ReactNode
}
/** A settings group: the planner's card with a head band (see settingsKit). */
export default function Section({ title, icon, badge, hint, action, id, collapsible, children }: SectionProps): React.ReactElement {
/**
* A settings group: the planner's card with a head band (see settingsKit).
*/
export default function Section({ title, icon, iconFill, badge, hint, action, id, children }: SectionProps): React.ReactElement {
return (
<SettingsCard icon={icon} title={title} badge={badge} hint={hint} action={action} id={id} collapsible={collapsible}>
<SettingsCard icon={icon} iconFill={iconFill} title={title} badge={badge} hint={hint} action={action} id={id}>
{children}
</SettingsCard>
)
@@ -4,7 +4,6 @@ import { http, HttpResponse } from 'msw';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { server } from '../../../tests/helpers/msw/server';
import { render, screen, waitFor } from '../../../tests/helpers/render';
import { openSettingsCards } from '../../../tests/helpers/settingsCards';
import { resetAllStores } from '../../../tests/helpers/store';
import WandererConnectionSection from './WandererConnectionSection';
@@ -32,8 +31,6 @@ function settingsRoute(state: { current: typeof disconnected }) {
}
beforeEach(() => {
// The cards start shut; these tests read into them.
openSettingsCards();
resetAllStores();
vi.clearAllMocks();
});
@@ -11,7 +11,6 @@ import {
SETTINGS_BUTTON_PRIMARY,
SettingRow,
SettingRows,
SettingsHint,
StatusPill,
} from './settingsKit';
import ToggleSwitch from './ToggleSwitch';
@@ -32,8 +31,8 @@ export default function WandererConnectionSection(): React.ReactElement {
return (
<Section
id="wanderer"
collapsible
title={t('wanderer.title')}
hint={t('wanderer.intro')}
icon={WandererMark}
badge={
<StatusPill
@@ -44,7 +43,6 @@ export default function WandererConnectionSection(): React.ReactElement {
</StatusPill>
}
>
<SettingsHint>{t('wanderer.intro')}</SettingsHint>
<div className={GRID_2}>
<EditorField label={t('wanderer.url')} htmlFor="wanderer-url">
@@ -48,7 +48,6 @@ function HintsButton({ W }: { W: WandererWatchController }) {
onClick={W.openHints}
disabled={count === 0}
className={`${SETTINGS_BUTTON} relative`}
style={fs(12.5, 'body')}
>
<BellRing size={13} strokeWidth={2.2} />
{t('wanderer.watch.hints')}
@@ -32,7 +32,6 @@ export default function WebPushCard(): React.ReactElement {
onClick={push.sendTest}
disabled={!push.canTest}
className={SETTINGS_BUTTON}
style={fs(13, 'body')}
>
{t('settings.notificationPreferences.sendTest')}
</button>
@@ -41,7 +40,6 @@ export default function WebPushCard(): React.ReactElement {
onClick={push.toggle}
disabled={push.busy}
className={push.on ? SETTINGS_BUTTON : SETTINGS_BUTTON_PRIMARY}
style={fs(13, 'body')}
>
{push.switchLabel}
</button>
@@ -1,5 +1,5 @@
import type { AiApiKeyKind, AiConnectionScope, AiParsingPrefill } from '@trek/shared';
import { ChevronRight, KeyRound, Loader2, Server, ShieldCheck } from 'lucide-react';
import { KeyRound, Loader2, Server, ShieldCheck } from 'lucide-react';
import React, { useId, useState } from 'react';
import { useTranslation } from '../../../i18n';
import { useAssistantStore } from '../../../store/assistantStore';
@@ -193,7 +193,6 @@ function KeyForm({ scope, keyConnect }: { scope: AiConnectionScope; keyConnect:
onClick={() => void submit()}
disabled={!kind || testing}
className={`${SETTINGS_BUTTON_PRIMARY} whitespace-nowrap`}
style={fs(13, 'body')}
>
{testing && <Loader2 size={14} className="animate-spin" />}
{testing ? t('assistant.connect.testing') : t('assistant.connect.save')}
@@ -225,7 +224,6 @@ function KeyForm({ scope, keyConnect }: { scope: AiConnectionScope; keyConnect:
onClick={() => void openrouter.start()}
disabled={openrouter.busy}
className={SETTINGS_BUTTON}
style={fs(13, 'body')}
>
{openrouter.busy ? (
<Loader2 size={14} className="animate-spin" />
@@ -298,7 +296,6 @@ function CustomForm({ custom }: { custom: ReturnType<typeof useCustomConnect> })
onClick={() => void custom.submit()}
disabled={!custom.ready || custom.saving}
className={SETTINGS_BUTTON_PRIMARY}
style={fs(13, 'body')}
>
{custom.saving && <Loader2 size={14} className="animate-spin" />}
{custom.saving ? t('assistant.connect.testing') : t('assistant.connect.save')}
@@ -314,7 +311,6 @@ function CustomForm({ custom }: { custom: ReturnType<typeof useCustomConnect> })
*/
function PrivacyNote({ provider }: { provider: string }) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
return (
<div className="rounded-[12px] bg-surface-tertiary px-3 py-2.5">
{provider && (
@@ -322,28 +318,16 @@ function PrivacyNote({ provider }: { provider: string }) {
{t('assistant.privacy.banner', { provider })}
</p>
)}
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
className="inline-flex items-center gap-1.5 font-medium text-content-muted hover:text-content"
style={fs(12, 'body')}
>
{/* Said in full before anything is saved, not folded behind a link. */}
<div className="inline-flex items-center gap-1.5 font-medium text-content-muted" style={fs(12, 'body')}>
<ShieldCheck size={13} strokeWidth={2.2} />
{t('assistant.privacy.what')}
<ChevronRight
size={13}
strokeWidth={2.2}
className={`transition-transform ${open ? 'rotate-90' : 'rtl:rotate-180'}`}
/>
</button>
{open && (
<ul className="m-0 mt-2 flex list-disc flex-col gap-1 ps-5 text-content-faint" style={fs(11.5)}>
<li>{t('assistant.privacy.shared')}</li>
<li>{t('assistant.privacy.notShared')}</li>
{provider && <li>{t('assistant.privacy.provider', { provider })}</li>}
</ul>
)}
</div>
<ul className="m-0 mt-2 flex list-disc flex-col gap-1 ps-5 text-content-faint" style={fs(11.5)}>
<li>{t('assistant.privacy.shared')}</li>
<li>{t('assistant.privacy.notShared')}</li>
{provider && <li>{t('assistant.privacy.provider', { provider })}</li>}
</ul>
</div>
);
}
@@ -4,7 +4,6 @@ import type { AiStatusResponse } from '@trek/shared';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { buildUser } from '../../../../tests/helpers/factories';
import { render, screen } from '../../../../tests/helpers/render';
import { openSettingsCards } from '../../../../tests/helpers/settingsCards';
import { resetAllStores, seedStore } from '../../../../tests/helpers/store';
import { useAddonStore } from '../../../store/addonStore';
import { useAssistantStore } from '../../../store/assistantStore';
@@ -31,8 +30,6 @@ const INSTANCE: AiStatusResponse = {
};
beforeEach(() => {
// The cards start shut; these tests read into them.
openSettingsCards();
resetAllStores();
vi.clearAllMocks();
useAssistantStore.setState({ status: null });
@@ -4,7 +4,6 @@ import type { AiConnectionView, AiStatusResponse } from '@trek/shared';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { buildAdmin, buildUser } from '../../../../tests/helpers/factories';
import { render, screen, waitFor } from '../../../../tests/helpers/render';
import { openSettingsCards } from '../../../../tests/helpers/settingsCards';
import { resetAllStores, seedStore } from '../../../../tests/helpers/store';
import { useAddonStore } from '../../../store/addonStore';
import { useAssistantStore } from '../../../store/assistantStore';
@@ -49,8 +48,6 @@ function status(over: Partial<AiStatusResponse> = {}): AiStatusResponse {
}
beforeEach(() => {
// The cards start shut; these tests read into them.
openSettingsCards();
resetAllStores();
vi.clearAllMocks();
useAssistantStore.setState({ status: null });
@@ -65,7 +65,7 @@ function OpenAssistantButton({ S }: { S: AiConnections }) {
const { t } = useTranslation();
if (!S.canOpen) return null;
return (
<button type="button" onClick={S.openAssistant} className={SETTINGS_BUTTON_PRIMARY} style={fs(13, 'body')}>
<button type="button" onClick={S.openAssistant} className={SETTINGS_BUTTON_PRIMARY}>
<MessageCircle size={14} strokeWidth={2.2} /> {t('assistant.connect.openAssistant')}
</button>
);
@@ -85,7 +85,6 @@ function OwnConnection({ S, own }: { S: AiConnections; own: AiConnectionView })
type="button"
onClick={() => S.repair(repairMode)}
className={SETTINGS_BUTTON_PRIMARY}
style={fs(13, 'body')}
>
{t(repairMode === 'chatgpt' ? 'assistant.errorAction.signIn' : 'assistant.errorAction.replaceKey')}
</button>
@@ -98,7 +97,6 @@ function OwnConnection({ S, own }: { S: AiConnections; own: AiConnectionView })
onClick={() => void signOut()}
disabled={busy}
className={SETTINGS_BUTTON}
style={fs(13, 'body')}
>
<LogOut size={14} strokeWidth={2} /> {t('assistant.connect.signOut')}
</button>
@@ -108,7 +106,6 @@ function OwnConnection({ S, own }: { S: AiConnections; own: AiConnectionView })
onClick={askDisconnect}
disabled={busy}
className={SETTINGS_BUTTON_DANGER}
style={fs(13, 'body')}
>
<Unplug size={14} strokeWidth={2} /> {t('assistant.connect.disconnect')}
</button>
@@ -157,7 +154,7 @@ function InstanceConnection({ S }: { S: AiConnections }) {
>
<OpenAssistantButton S={S} />
{S.status?.canConnectOwn && !S.panel && (
<button type="button" onClick={S.connectOwn} className={SETTINGS_BUTTON} style={fs(13, 'body')}>
<button type="button" onClick={S.connectOwn} className={SETTINGS_BUTTON}>
{t('assistant.errorAction.connectOwn')}
</button>
)}
@@ -213,7 +210,7 @@ function LockedOwnConnection() {
<SettingRow
label={t('demo.lock.aiTitle')}
control={
<button type="button" disabled className={SETTINGS_BUTTON} style={fs(13, 'body')}>
<button type="button" disabled className={SETTINGS_BUTTON}>
{t('assistant.errorAction.connectOwn')}
</button>
}
@@ -230,7 +227,7 @@ function ConnectPanel({ S }: { S: AiConnections }) {
<div className={`flex flex-col gap-3 ${S.view === 'setup' ? '' : 'border-t border-edge-faint pt-4'}`}>
<AiConnectPanel key={S.panelMode ?? 'any'} scope="user" onConnected={S.onConnected} initialMode={S.panelMode} />
{S.view !== 'setup' && (
<button type="button" onClick={S.closePanel} className={`${SETTINGS_BUTTON} self-start`} style={fs(13, 'body')}>
<button type="button" onClick={S.closePanel} className={`${SETTINGS_BUTTON} self-start`}>
{t('common.cancel')}
</button>
)}
@@ -283,7 +280,6 @@ export default function AiConnectionSection({
return (
<Section
id="ai-connection"
collapsible
title={t(text.titleKey)}
icon={Sparkles}
hint={t(text.hintKey)}
@@ -113,7 +113,6 @@ export default function ChatGptPasteStep({
}}
aria-disabled={!flow}
className={`${SETTINGS_BUTTON_PRIMARY} ${flow ? '' : 'pointer-events-none opacity-50'}`}
style={fs(13, 'body')}
>
{flow ? <ExternalLink size={14} strokeWidth={2.2} /> : <Loader2 size={14} className="animate-spin" />}
{t('assistant.connect.chatgptOpen')}
@@ -163,7 +162,6 @@ export default function ChatGptPasteStep({
onClick={() => void submit()}
disabled={busy || !flow}
className={SETTINGS_BUTTON_PRIMARY}
style={fs(13, 'body')}
>
{busy && <Loader2 size={14} className="animate-spin" />}
{t('assistant.connect.save')}
@@ -174,7 +172,6 @@ export default function ChatGptPasteStep({
onClick={pasteFromClipboard}
disabled={!flow}
className={`${SETTINGS_BUTTON} whitespace-nowrap`}
style={fs(13, 'body')}
>
<ClipboardPaste size={14} strokeWidth={2} />
{t('assistant.connect.chatgptPaste')}
@@ -1,65 +0,0 @@
// FE-SETTINGS-KIT-001 to FE-SETTINGS-KIT-004
import { fireEvent, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it } from 'vitest';
import { SettingsCard, StatusPill, settingsCardOpenKey } from './settingsKit';
function card(props: { collapsible?: boolean; id?: string } = {}) {
return (
<SettingsCard
title="AirTrail"
badge={<StatusPill>Connected</StatusPill>}
action={<button type="button">Sync</button>}
{...props}
>
<label>
Address
<input />
</label>
</SettingsCard>
);
}
afterEach(() => {
window.location.hash = '';
});
describe('SettingsCard', () => {
it('FE-SETTINGS-KIT-001: a collapsible card starts shut, its state still showing in the head', () => {
render(card({ collapsible: true, id: 'airtrail' }));
const head = screen.getByRole('button', { name: 'AirTrail' });
expect(head).toHaveAttribute('aria-expanded', 'false');
expect(screen.getByText('Connected')).toBeVisible();
expect(screen.queryByRole('textbox', { name: 'Address' })).toBeNull();
// The head's own action stays a button of its own.
expect(screen.getByRole('button', { name: 'Sync' })).toBeVisible();
});
it('FE-SETTINGS-KIT-002: the head opens and shuts it, and the browser remembers it open', () => {
const { unmount } = render(card({ collapsible: true, id: 'airtrail' }));
fireEvent.click(screen.getByRole('button', { name: 'AirTrail' }));
expect(screen.getByRole('button', { name: 'AirTrail' })).toHaveAttribute('aria-expanded', 'true');
expect(screen.getByRole('textbox', { name: 'Address' })).toBeVisible();
expect(localStorage.getItem(settingsCardOpenKey('airtrail'))).toBe('1');
unmount();
render(card({ collapsible: true, id: 'airtrail' }));
expect(screen.getByRole('textbox', { name: 'Address' })).toBeVisible();
fireEvent.click(screen.getByRole('button', { name: 'AirTrail' }));
expect(screen.queryByRole('textbox', { name: 'Address' })).toBeNull();
expect(localStorage.getItem(settingsCardOpenKey('airtrail'))).toBe('0');
});
it('FE-SETTINGS-KIT-003: a link to the card opens it', () => {
window.location.hash = '#airtrail';
render(card({ collapsible: true, id: 'airtrail' }));
expect(screen.getByRole('textbox', { name: 'Address' })).toBeVisible();
});
it('FE-SETTINGS-KIT-004: a card that is not collapsible has no toggle and shows its body', () => {
render(card());
expect(screen.queryByRole('button', { name: 'AirTrail' })).toBeNull();
expect(screen.getByRole('heading', { name: 'AirTrail' })).toBeVisible();
expect(screen.getByRole('textbox', { name: 'Address' })).toBeVisible();
});
});
+159 -102
View File
@@ -1,5 +1,6 @@
import { useEffect, useId, useRef, useState, type ReactNode } from 'react'
import { ChevronDown, type LucideIcon } from 'lucide-react'
import { createContext, useContext, type ReactNode } from 'react'
import { Info, type LucideIcon } from 'lucide-react'
import { Tooltip } from '../shared/Tooltip'
import { NEUTRAL_TINT, fs } from '../shared/DialogShell'
/*
@@ -12,6 +13,29 @@ import { NEUTRAL_TINT, fs } from '../shared/DialogShell'
type IconLike = LucideIcon | React.ComponentType<{ className?: string; size?: number; strokeWidth?: number }>
/**
* A card's or a row's icon in its framed tile. A lucide glyph sits in the middle
* at glyph size; a brand mark that is itself a badge (`fill`, as Dawarich's and
* AirTrail's are) fills the tile corner to corner, the way the addon manager
* shows them, instead of floating small inside a second frame.
*/
function IconTile({ icon: Icon, fill = false, tone = 'neutral' }: {
icon: IconLike
fill?: boolean
tone?: 'neutral' | 'danger'
}) {
const box = 'h-8 w-8 rounded-[10px]'
if (fill) {
const Mark = Icon as React.ComponentType<{ fill?: boolean }>
return <span className={`block flex-none overflow-hidden shadow-sm ${box}`}><Mark fill /></span>
}
return (
<span className={`grid flex-none place-items-center bg-surface-card shadow-sm ${box} ${tone === 'danger' ? 'text-danger' : 'text-content-secondary'}`}>
<Icon size={15} strokeWidth={2} />
</span>
)
}
/** The page's own bar: icon tile, title and subtitle on the left, actions on the right. */
export function SettingsHeader({ icon: Icon, title, subtitle, actions }: {
icon: IconLike
@@ -33,56 +57,89 @@ export function SettingsHeader({ icon: Icon, title, subtitle, actions }: {
)
}
/** Where a collapsible card remembers that its reader left it open. */
export const settingsCardOpenKey = (id: string) => `trek-settings-open-${id}`
/** True inside a card's body: a card there is drawn as a block of it (see SettingsCard). */
const CardBodyContext = createContext(false)
/**
* Whether a collapsible card is open. Shut by default, so a page lists what it
* holds instead of everything at once; open when the address names it (a link
* to `#id` lands on it ready to use), or when its reader left it open last time.
* The browser remembers that per card as a convenience: without storage (a
* private window, blocked site data) every card simply starts shut.
* One part of a settings card: a rounded white block with a small head row
* (title, a pill, an action) and its content under a hairline. Every part of a
* card is one of these, so a card reads as separate, labelled pieces.
*/
export function useCardOpen(id: string | undefined, collapsible: boolean): [boolean, () => void] {
const [open, setOpen] = useState(() => {
if (!collapsible) return true
if (id && window.location.hash === `#${id}`) return true
if (!id) return false
try {
return localStorage.getItem(settingsCardOpenKey(id)) === '1'
} catch {
// Storage refused: the card starts shut, as it would on a first visit.
return false
}
})
const toggle = () => {
const next = !open
setOpen(next)
if (!id) return
try {
localStorage.setItem(settingsCardOpenKey(id), next ? '1' : '0')
} catch {
// Storage refused: the card still opens, it is only not remembered.
}
}
return [!collapsible || open, toggle]
export function SettingsBlock({ id, title, hint, badge, action, tone = 'neutral', flush = false, children }: {
id?: string
/** The content runs to the block's edges: for a list of rows, which bring their own padding. */
flush?: boolean
title: ReactNode
hint?: ReactNode
badge?: ReactNode
action?: ReactNode
tone?: 'neutral' | 'danger'
children?: ReactNode
}) {
return (
<section id={id} className="overflow-hidden rounded-[12px] border border-edge-faint bg-surface-card">
{/* A tinted head row, so a block's title reads as its heading. */}
<div className="flex min-w-0 items-center gap-2 bg-surface-secondary px-3.5 py-2.5">
<div className="min-w-0 flex-1">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<h3 className={`m-0 truncate font-semibold ${tone === 'danger' ? 'text-danger' : 'text-content'}`} style={fs(13, 'body')}>{title}</h3>
{badge}
</div>
{hint && <p className="m-0 mt-0.5 leading-snug text-content-faint" style={fs(11.5)}>{hint}</p>}
</div>
{action && <div className="flex flex-none items-center gap-1.5">{action}</div>}
</div>
{children != null && (
// Inside the block its own rows and fields: a card in it is not nested again.
// Its hairline goes with it, so a block whose content renders nothing is its head alone.
<div className={`flex flex-col border-t border-edge-faint empty:hidden ${flush ? 'divide-y divide-edge-faint' : 'gap-3 p-3.5'}`}>
<CardBodyContext.Provider value={false}>{children}</CardBodyContext.Provider>
</div>
)}
</section>
)
}
/**
* A card's head band: icon tile, title with its badge, a short hint under it,
* an action on the right. The same over a card of fields and over a list of
* rows, so every group on a settings page starts the same way.
*/
function CardHead({ icon: Icon, iconFill = false, title, hint, badge, action, tone = 'neutral' }: {
icon?: IconLike
iconFill?: boolean
title: ReactNode
hint?: ReactNode
badge?: ReactNode
action?: ReactNode
tone?: 'neutral' | 'danger'
}) {
const tint = tone === 'danger' ? 'color-mix(in srgb, var(--danger) 9%, transparent)' : NEUTRAL_TINT
return (
<div className="flex items-center gap-3 border-b border-edge-faint px-4 py-3" style={{ background: tint }}>
{Icon && <IconTile icon={Icon} fill={iconFill} tone={tone} />}
<div className="min-w-0 flex-1">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<h2 className="m-0 truncate font-bold text-content" style={fs(14, 'body')}>{title}</h2>
{badge}
</div>
{hint && <p className="m-0 mt-0.5 text-content-faint" style={fs(11.5)}>{hint}</p>}
</div>
{action && <div className="flex flex-none items-center gap-1.5">{action}</div>}
</div>
)
}
/**
* A group of settings: a card with a head band (icon tile, title, an optional
* badge and an action on the right) and its body below. `tone` tints the band:
* neutral for ordinary groups, danger for the destructive ones.
*
* `collapsible` shuts the body until the head is clicked. The badge is then the
* card's state at a glance ("Connected", "Off"), so a shut card still says what
* matters. The title holds the button (a heading inside a button is not valid
* HTML), stretched over the whole head; the badge and the action sit above it
* and stay clickable on their own. The body stays mounted while shut, so
* nothing typed into it is lost. A collapsible card needs an `id` to be linked
* to and remembered.
*/
export function SettingsCard({ icon: Icon, title, hint, badge, action, tone = 'neutral', children, id, bodyClassName, flush = false, collapsible = false }: {
export function SettingsCard({ icon: Icon, iconFill = false, title, hint, badge, action, tone = 'neutral', children, id, bodyClassName, flush = false }: {
icon?: IconLike
/** The icon is a brand badge that fills its tile (see IconTile). */
iconFill?: boolean
title: ReactNode
/** One line under the title in the band. */
hint?: ReactNode
@@ -95,68 +152,47 @@ export function SettingsCard({ icon: Icon, title, hint, badge, action, tone = 'n
bodyClassName?: string
/** Inside another card: no gap below, the parent spaces its children. */
flush?: boolean
/** Shut until its head is clicked; see above. */
collapsible?: boolean
}) {
const tint = tone === 'danger' ? 'color-mix(in srgb, var(--danger) 9%, transparent)' : NEUTRAL_TINT
const [open, toggle] = useCardOpen(id, collapsible)
const bodyId = useId()
const hasBody = children != null
const ref = useRef<HTMLElement>(null)
// A link to `#id` opened this card; bring it into view, as the router does not scroll to an anchor.
useEffect(() => {
if (id && window.location.hash === `#${id}`) ref.current?.scrollIntoView?.({ block: 'start' })
}, [id])
// Inside another card's body a card is a block of it, never a card in a card:
// the rounded white block every part of a card is, with a small head row.
const nested = useContext(CardBodyContext)
if (nested) {
return (
<SettingsBlock id={id} title={title} hint={hint} badge={badge} action={action} tone={tone}>
{children}
</SettingsBlock>
)
}
return (
<section ref={ref} id={id} className={`${flush ? '' : 'mb-5 '}overflow-hidden rounded-2xl border border-edge-faint bg-surface-secondary`}>
<div
className={`relative flex items-center gap-3 px-4 py-3 ${hasBody && open ? 'border-b border-edge-faint' : ''} ${collapsible ? 'transition-colors hover:brightness-[1.03] has-[:focus-visible]:outline has-[:focus-visible]:outline-2 has-[:focus-visible]:-outline-offset-2 has-[:focus-visible]:outline-[color:var(--text-primary)]' : ''}`}
style={{ background: tint }}
>
{Icon && (
<span className={`grid h-8 w-8 flex-none place-items-center rounded-[10px] bg-surface-card shadow-sm ${tone === 'danger' ? 'text-danger' : 'text-content-secondary'}`}>
<Icon size={15} strokeWidth={2} />
</span>
)}
<div className="min-w-0 flex-1">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<h2 className="m-0 truncate font-bold text-content" style={fs(14, 'body')}>
{collapsible ? (
<button
type="button"
onClick={toggle}
aria-expanded={open}
aria-controls={hasBody ? bodyId : undefined}
className="cursor-pointer border-0 bg-transparent p-0 text-start font-[inherit] text-inherit outline-none after:absolute after:inset-0 after:content-['']"
>
{title}
</button>
) : title}
</h2>
{badge && <span className="relative z-[1] inline-flex">{badge}</span>}
</div>
{hint && <p className="m-0 mt-0.5 text-content-faint" style={fs(11.5)}>{hint}</p>}
</div>
{action && <div className="relative z-[1] flex flex-none items-center gap-1.5">{action}</div>}
{collapsible && (
<ChevronDown
size={16}
strokeWidth={2}
aria-hidden
className={`pointer-events-none flex-none text-content-faint transition-transform duration-200 motion-reduce:transition-none ${open ? 'rotate-180' : ''}`}
/>
)}
</div>
{/* The hidden wrapper has no classes of its own: a display utility on it would beat the attribute. */}
{hasBody && (
<div id={bodyId} hidden={!open}>
<div className={bodyClassName ?? 'flex flex-col gap-4 p-4'}>{children}</div>
<section id={id} className={`${flush ? '' : 'mb-5 '}overflow-hidden rounded-2xl border border-edge-faint bg-surface-secondary`}>
<CardHead icon={Icon} iconFill={iconFill} title={title} hint={hint} badge={badge} action={action} tone={tone} />
{children != null && (
<div className={bodyClassName ?? 'flex flex-col gap-4 p-4'}>
<CardBodyContext.Provider value>{children}</CardBodyContext.Provider>
</div>
)}
</section>
)
}
/**
* Two columns from xl up, for a settings page whose cards are mostly "label
* left, control right": on a wide screen one column left the middle empty while
* the page scrolled on. Two explicit columns, grouped by subject, rather than a
* grid over a flat list, which pairs cards row by row and leaves a hole under
* the shorter of two. A column with nothing to show drops out and the other
* takes the whole width. The cards carry their own bottom margin, so only the
* gap between the columns is set.
*/
export function SettingsColumns({ left, right }: { left: ReactNode; right: ReactNode }) {
return (
<div className="xl:flex xl:items-start xl:gap-5">
<div className="min-w-0 flex-1 empty:hidden">{left}</div>
<div className="min-w-0 flex-1 empty:hidden">{right}</div>
</div>
)
}
/** A white box of rows separated by hairlines, the way the bag sidebar lists its bags. */
export function SettingRows({ children, className = '' }: { children: ReactNode; className?: string }) {
return (
@@ -184,12 +220,14 @@ export function SettingRow({ label, hint, control, children, stacked = false, di
htmlFor?: string
}) {
return (
<div className={`px-3.5 py-3 ${dimmed ? 'opacity-60' : ''}`}>
<div className={`px-4 py-3 ${dimmed ? 'opacity-60' : ''}`}>
<div className={stacked ? 'flex flex-col gap-2.5' : 'flex flex-wrap items-center gap-x-4 gap-y-2'}>
{/* The basis only in a row: in the stacked column it would read as a height. */}
<div className={stacked ? 'min-w-0' : 'min-w-0 flex-1 basis-56'}>
<label htmlFor={htmlFor} className="block font-medium text-content" style={fs(13, 'body')}>{label}</label>
{hint && <p className="m-0 mt-0.5 leading-snug text-content-faint" style={fs(11.5)}>{hint}</p>}
<div className="flex min-w-0 items-center gap-1.5">
<label htmlFor={htmlFor} className="font-medium text-content" style={fs(13, 'body')}>{label}</label>
{hint && <HintMark hint={hint} />}
</div>
</div>
{control && <div className={stacked ? 'min-w-0' : 'flex flex-none items-center gap-2'}>{control}</div>}
</div>
@@ -198,6 +236,25 @@ export function SettingRow({ label, hint, control, children, stacked = false, di
)
}
/**
* A row's explanation as a small mark beside its label: the row stays one line,
* the sentence opens on hover or focus. The text is in the page for a screen
* reader all the same, read right after the label.
*/
export function HintMark({ hint }: { hint: ReactNode }) {
const text = typeof hint === 'string' ? hint : ''
return (
<>
<span className="sr-only">{hint}</span>
<Tooltip label={text || ' '} content={text ? undefined : hint} placement="top">
<span aria-hidden className="inline-flex flex-none cursor-help text-content-faint transition-colors hover:text-content-muted">
<Info size={13} strokeWidth={2} />
</span>
</Tooltip>
</>
)
}
/** A quiet line of explanation inside a card body. */
export function SettingsHint({ children, className = '' }: { children: ReactNode; className?: string }) {
return <p className={`m-0 leading-normal text-content-faint ${className}`} style={fs(11.5)}>{children}</p>
@@ -245,9 +302,9 @@ export function StatusPill({ tone = 'neutral', children, icon }: { tone?: 'neutr
)
}
/** The buttons of a settings card, in the dialogs' shapes. */
export const SETTINGS_BUTTON = 'inline-flex items-center justify-center gap-1.5 rounded-[10px] bg-surface-card px-3.5 py-2 font-medium text-content shadow-sm ring-1 ring-edge-faint hover:bg-surface-secondary disabled:cursor-default disabled:opacity-50'
export const SETTINGS_BUTTON_PRIMARY = 'inline-flex items-center justify-center gap-1.5 rounded-[10px] bg-accent px-4 py-2 font-medium text-accent-text hover:opacity-90 disabled:cursor-default disabled:opacity-50'
export const SETTINGS_BUTTON_DANGER = 'inline-flex items-center justify-center gap-1.5 rounded-[10px] bg-danger-soft px-3.5 py-2 font-medium text-danger hover:opacity-90 disabled:cursor-default disabled:opacity-50'
/** The buttons of a settings card, in the dialogs' shapes and one size (text-control) everywhere. */
export const SETTINGS_BUTTON = 'inline-flex items-center justify-center gap-1.5 rounded-[10px] bg-surface-card px-3.5 py-2 font-medium text-content shadow-sm ring-1 ring-edge-faint hover:bg-surface-secondary disabled:cursor-default disabled:opacity-50 text-control'
export const SETTINGS_BUTTON_PRIMARY = 'inline-flex items-center justify-center gap-1.5 rounded-[10px] bg-accent px-4 py-2 font-medium text-accent-text hover:opacity-90 disabled:cursor-default disabled:opacity-50 text-control'
export const SETTINGS_BUTTON_DANGER = 'inline-flex items-center justify-center gap-1.5 rounded-[10px] bg-danger-soft px-3.5 py-2 font-medium text-danger hover:opacity-90 disabled:cursor-default disabled:opacity-50 text-control'
/** A round icon action in a band or a row, named by its tooltip. */
export const SETTINGS_ICON_BUTTON = 'grid h-8 w-8 flex-none place-items-center rounded-[10px] bg-surface-card text-content-muted shadow-sm ring-1 ring-edge-faint hover:text-content disabled:cursor-default disabled:opacity-50'
@@ -108,8 +108,6 @@ export function useMcpIntegration({ enabled, reloadClientsAfterWrite }: McpInteg
// MCP sub-tab state
const [activeMcpTab, setActiveMcpTab] = useState<'oauth' | 'apitokens'>('oauth');
const [configOpenOAuth, setConfigOpenOAuth] = useState(false);
const [configOpenToken, setConfigOpenToken] = useState(false);
// MCP state
const [mcpTokens, setMcpTokens] = useState<McpToken[]>([]);
@@ -327,10 +325,6 @@ export function useMcpIntegration({ enabled, reloadClientsAfterWrite }: McpInteg
setOauthIsMachine,
activeMcpTab,
setActiveMcpTab,
configOpenOAuth,
setConfigOpenOAuth,
configOpenToken,
setConfigOpenToken,
mcpTokens,
mcpModalOpen,
setMcpModalOpen,
+4 -1
View File
@@ -75,7 +75,7 @@ export default function MAdmin() {
const admin = useAdmin()
const {
demoMode, mcpEnabled, devMode, managed, navigate,
activeTab, setActiveTab, stats, serverTimezone,
activeTab: deskTab, setActiveTab, stats, serverTimezone,
bagTrackingEnabled, collabFeatures,
setShowCreateUser,
updateInfo, setShowUpdateModal,
@@ -107,6 +107,9 @@ export default function MAdmin() {
// The install's own tabs (managed/index.tsx), after the built-in ones as on the desktop.
...managedAdminTabs.map(tab => ({ id: tab.id, label: tab.label, icon: tab.Icon })),
]
// The desktop splits the settings into Security and Services; the phone keeps
// them in its one Settings section, so either desktop tab (and a link to it) opens that.
const activeTab = deskTab === 'security' || deskTab === 'services' ? 'settings' : deskTab
const activeSection = sections.find((s) => s.id === activeTab) ?? sections[0]
return (
+23 -16
View File
@@ -16,7 +16,7 @@ import AuditLogPanel from '../components/Admin/AuditLogPanel'
import AdminMcpTokensPanel from '../components/Admin/AdminMcpTokensPanel'
import AdminPluginsPanel from '../components/Admin/AdminPluginsPanel'
import AdminStoragePanel from '../components/Admin/storage/AdminStoragePanel'
import { Users, Map, Briefcase, Shield, FileText, RotateCcw, Save, SlidersHorizontal, UserCog, Puzzle, Blocks, Settings as SettingsIcon, Bell, Database, ScrollText, KeyRound, GitBranch, Bug, HardDrive } from 'lucide-react'
import { Users, Map, Briefcase, Shield, FileText, RotateCcw, Save, SlidersHorizontal, UserCog, Puzzle, Blocks, ShieldCheck, Compass, Bell, Database, ScrollText, KeyRound, GitBranch, Bug, HardDrive } from 'lucide-react'
import PageSidebar, { type PageSidebarTab } from '../components/Layout/PageSidebar'
import { useAdmin } from './admin/useAdmin'
import AdminUpdateBanner from './admin/AdminUpdateBanner'
@@ -51,28 +51,30 @@ function AdminPageDesktop(): React.ReactElement {
const gUsers = t('admin.group.users')
const gConfig = t('admin.group.config')
const gIntegration = t('admin.group.integration')
const gMaintenance = t('admin.group.maintenance')
const GROUP_LABELS = { users: gUsers, config: gConfig, integration: gIntegration, maintenance: gMaintenance }
const GROUP_LABELS = { users: gUsers, config: gConfig, maintenance: gMaintenance }
const TABS: PageSidebarTab[] = [
{ id: 'users', label: t('admin.tabs.users'), icon: Users, group: gUsers },
{ id: 'defaults', label: t('admin.tabs.defaults'), icon: UserCog, group: gUsers },
{ id: 'config', label: t('admin.tabs.config'), icon: SlidersHorizontal, group: gConfig },
{ id: 'settings', label: t('admin.tabs.settings'), icon: SettingsIcon, group: gConfig },
// By what an admin comes for, not by how it is built: who may sign in, what
// the install talks to, which features it offers and what they show.
{ id: 'security', label: t('admin.tabs.security'), icon: ShieldCheck, group: gConfig },
{ id: 'services', label: t('admin.tabs.services'), icon: Compass, group: gConfig },
{ id: 'addons', label: t('admin.tabs.addons'), icon: Puzzle, group: gConfig },
{ id: 'config', label: t('admin.tabs.config'), icon: SlidersHorizontal, group: gConfig },
{ id: 'notifications', label: t('admin.tabs.notifications'), icon: Bell, group: gConfig },
...(mcpEnabled ? [{ id: 'mcp-tokens', label: t('admin.tabs.mcpTokens'), icon: KeyRound, group: gConfig }] : []),
// Plugins run third-party code that can send data anywhere; a hosted instance
// offers none, so there is nothing to manage here (TREK_PLUGINS_ENABLED is off).
...(managed ? [] : [{ id: 'plugins', label: t('admin.tabs.plugins'), icon: Blocks, group: gConfig }]),
// Storage backends and their credentials are hoster-level configuration —
// the server refuses the whole surface in managed mode (MANAGED_FORBIDDEN).
...(managed ? [] : [{ id: 'storage', label: t('admin.tabs.storage'), icon: HardDrive, group: gConfig }]),
{ id: 'notifications', label: t('admin.tabs.notifications'), icon: Bell, group: gIntegration },
...(mcpEnabled ? [{ id: 'mcp-tokens', label: t('admin.tabs.mcpTokens'), icon: KeyRound, group: gIntegration }] : []),
// Releases and update cadence belong to whoever operates the install.
...(managed ? [] : [{ id: 'github', label: t('admin.tabs.github'), icon: GitBranch, group: gIntegration }]),
...(managed ? [] : [{ id: 'storage', label: t('admin.tabs.storage'), icon: HardDrive, group: gMaintenance }]),
// Backups run off-volume on a managed install; the tab would offer a
// schedule that competes with the real one.
...(managed ? [] : [{ id: 'backup', label: t('admin.tabs.backup'), icon: Database, group: gMaintenance }]),
// Releases and update cadence belong to whoever operates the install.
...(managed ? [] : [{ id: 'github', label: t('admin.tabs.github'), icon: GitBranch, group: gMaintenance }]),
{ id: 'audit', label: t('admin.tabs.audit'), icon: ScrollText, group: gMaintenance },
...(devMode ? [{ id: 'dev-notifications', label: 'Dev: Notifications', icon: Bug, group: gMaintenance }] : []),
// Empty in this repository — see client/src/managed. Appended per group
@@ -128,7 +130,6 @@ function AdminPageDesktop(): React.ReactElement {
<button type="button"
onClick={saveDemoBaseline}
className={`${SETTINGS_BUTTON_PRIMARY} flex-none`}
style={fs(13, 'body')}
>
<Save size={14} strokeWidth={2.1} />
{t('admin.demoBaseline.button')}
@@ -144,7 +145,6 @@ function AdminPageDesktop(): React.ReactElement {
onTabChange={setActiveTab}
footer=""
>
{/* Tab content */}
{activeTab === 'users' && (
<AdminUsersTab admin={admin} t={t} locale={locale} />
)}
@@ -159,14 +159,21 @@ function AdminPageDesktop(): React.ReactElement {
{activeTab === 'addons' && (
<div className="space-y-6">
<AddonManager bagTrackingEnabled={bagTrackingEnabled} onToggleBagTracking={toggleBagTracking} collabFeatures={collabFeatures} onToggleCollabFeature={toggleCollabFeature} />
<AddonManager
bagTrackingEnabled={bagTrackingEnabled}
onToggleBagTracking={toggleBagTracking}
collabFeatures={collabFeatures}
onToggleCollabFeature={toggleCollabFeature}
// How the integrations reach the world: live flight status, booking mails.
aside={<AdminSettingsTab admin={admin} t={t} part="features" />}
/>
<PaddleRegionsPanel />
</div>
)}
{activeTab === 'settings' && (
<AdminSettingsTab admin={admin} t={t} />
)}
{activeTab === 'security' && <AdminSettingsTab admin={admin} t={t} part="security" />}
{activeTab === 'services' && <AdminSettingsTab admin={admin} t={t} part="services" />}
{activeTab === 'notifications' && (
<AdminNotificationsTab admin={admin} t={t} />
-3
View File
@@ -6,7 +6,6 @@ import { buildUser } from '../../tests/helpers/factories';
import { useAuthStore } from '../store/authStore';
import { useAddonStore } from '../store/addonStore';
import SettingsPage from './SettingsPage';
import { openSettingsCards } from '../../tests/helpers/settingsCards';
// Mock heavy settings sub-tabs to focus on page-level concerns
vi.mock('../components/Settings/DisplaySettingsTab', () => ({
@@ -36,8 +35,6 @@ vi.mock('../components/Settings/AboutTab', () => ({
}));
beforeEach(() => {
// The cards start shut; these tests read into them.
openSettingsCards();
resetAllStores();
seedStore(useAuthStore, { isAuthenticated: true, user: buildUser() });
});
@@ -11,10 +11,15 @@ const EYEBROW = 'text-center font-geist font-bold uppercase tracking-[.08em] tex
// Per-event × per-channel admin notification preference matrix.
// Loads its own data and auto-saves each toggle.
export default function AdminNotificationsPanel({ t, toast }: { t: (k: string) => string; toast: ReturnType<typeof useToast> }) {
export default function AdminNotificationsPanel({ t, toast, bare = false }: {
t: (k: string) => string
toast: ReturnType<typeof useToast>
/** Inside the admin notifications card, which carries the title: the matrix alone. */
bare?: boolean
}) {
const { matrix, saving, visibleChannels, toggle } = useAdminNotificationMatrix(t, toast)
const card = (children: React.ReactNode) => (
const card = (children: React.ReactNode) => bare ? <>{children}</> : (
<SettingsCard icon={BellRing} title={t('admin.tabs.notifications')} hint={t('admin.notifications.adminNotificationsHint')}>
{children}
</SettingsCard>
+99 -119
View File
@@ -1,6 +1,5 @@
import React, { useId } from 'react'
import { Bell, BellRing, CalendarClock, Mail, Save, Send, Smartphone, Webhook } from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import { Bell, BellRing, Mail, Save, Send, Webhook } from 'lucide-react'
import type { TranslationFn } from '../../types'
import type { useAdmin } from './useAdmin'
import AdminNotificationsPanel from './AdminNotificationsPanel'
@@ -9,7 +8,7 @@ import { SWITCH_ONLY_CHANNELS, useNotificationChannels } from '../../components/
import { MASK, useAdminNotificationSettings } from '../../components/Admin/useAdminNotificationSettings'
import ToggleSwitch from '../../components/Settings/ToggleSwitch'
import {
SETTINGS_BUTTON, SETTINGS_BUTTON_DANGER, SETTINGS_BUTTON_PRIMARY, SettingRow, SettingRows, SettingsCard,
SETTINGS_BUTTON, SETTINGS_BUTTON_DANGER, SETTINGS_BUTTON_PRIMARY, SettingRow, SettingRows, SettingsCard, SettingsColumns,
} from '../../components/Settings/settingsKit'
import { EditorField, GRID_2, INPUT } from '../../components/shared/dialogParts'
import { fs } from '../../components/shared/DialogShell'
@@ -19,9 +18,6 @@ interface AdminNotificationsTabProps {
t: TranslationFn
}
/** The glyph on each switch-only channel's card; anything else is the push channel. */
const CHANNEL_ICONS: Record<string, LucideIcon> = { webhook: Webhook, ntfy: BellRing }
/** The save and test buttons under a card's fields, on a hairline of their own. */
function CardActions({ children }: { children: React.ReactNode }): React.ReactElement {
return <div className="flex flex-wrap items-center gap-2 border-t border-edge-faint pt-4" style={fs(13, 'body')}>{children}</div>
@@ -78,115 +74,102 @@ export default function AdminNotificationsTab({ admin, t }: AdminNotificationsTa
</EditorField>
)
const tripRemindersCard = (
<SettingsCard
icon={CalendarClock}
title={t('admin.notifications.tripReminders.title')}
hint={t('admin.notifications.tripReminders.hint')}
action={
<ToggleSwitch
on={tripRemindersActive}
label={t('admin.notifications.tripReminders.title')}
onToggle={toggleTripReminders}
/>
}
/>
)
return (<>
{/* Two columns from xl up, the same shape as the Settings tab. Most of these
cards are a title with a switch on the far right, so on a wide screen the
middle stayed empty while the page still scrolled past the three tall ones.
Two explicit columns rather than a grid over the flat list: a plain grid
pairs cards row by row and leaves a hole under the shorter one, and the SMTP
card is taller than all the toggle rows together. Grouped by audience, not by
height — the channels a user can receive on the left, everything the operator
sends or receives themselves on the right. The channel switches stay
together because they all write the same notification_channels list.
On a managed install the two admin-target cards are gone and the right column
is the matrix alone — still a column, so the grid keeps working. Each card
brings its own bottom margin, so the columns only need the gap between them. */}
<div className="grid grid-cols-1 gap-x-5 xl:grid-cols-2 xl:items-start">
<div className="min-w-0">
{/* The relay is the operator's: their host, their credential, their sending
reputation. An instance that could point it elsewhere would send under a
domain it does not own. */}
{!managed && (
<SettingsCard
icon={Mail}
title={t('admin.notifications.emailPanel.title')}
hint={t('admin.smtp.hint')}
action={<ToggleSwitch on={emailActive} label={t('admin.notifications.emailPanel.title')} onToggle={() => channels.toggle('email')} />}
>
{/* Greyed and out of reach while the channel is off, never hidden. */}
<div data-testid="smtp-fields" className={`flex flex-col gap-4 transition-opacity ${!emailActive ? 'opacity-50 pointer-events-none' : ''}`}>
{smtpLoaded && (<>
<div className={GRID_2}>
{smtpField(SMTP_FIELDS[0])}
{smtpField(SMTP_FIELDS[1])}
</div>
<div className={GRID_2}>
{smtpField(SMTP_FIELDS[2])}
{smtpField(SMTP_FIELDS[3])}
</div>
{smtpField(SMTP_FIELDS[4])}
</>)}
<SettingRows>
<SettingRow
label={t('admin.smtp.skipTls')}
hint={t('admin.smtp.skipTlsHint')}
control={
<ToggleSwitch
on={smtpValues.smtp_skip_tls_verify === 'true'}
label={t('admin.smtp.skipTls')}
onToggle={() => {
const newVal = smtpValues.smtp_skip_tls_verify === 'true' ? 'false' : 'true'
setSmtpValues(prev => ({ ...prev, smtp_skip_tls_verify: newVal }))
}}
/>
}
{/* Every channel users can receive on is one switch in one card, the SMTP
relay's fields a section under them; the operator's own targets and the
admin-only matrix on the right. */}
<SettingsColumns
left={
<SettingsCard icon={Bell} title={t('admin.notifications.channels')}>
<SettingRows>
{/* The relay is the operator's on a managed install: their host, their
credential, their sending reputation. */}
{!managed && (
<SettingRow
label={t('admin.notifications.emailPanel.title')}
hint={t('admin.smtp.hint')}
control={<ToggleSwitch on={emailActive} label={t('admin.notifications.emailPanel.title')} onToggle={() => channels.toggle('email')} />}
/>
)}
{SWITCH_ONLY_CHANNELS.map(ch => (
<SettingRow
key={ch.id}
label={t(ch.titleKey)}
hint={t(ch.hintKey)}
control={<ToggleSwitch on={channels.isActive(ch.id)} label={t(ch.titleKey)} onToggle={() => channels.toggle(ch.id)} />}
/>
))}
<SettingRow
label={t('admin.notifications.inappPanel.title')}
hint={t('admin.notifications.inappPanel.hint')}
control={<LockedSwitch />}
/>
<SettingRow
label={t('admin.notifications.tripReminders.title')}
hint={t('admin.notifications.tripReminders.hint')}
control={
<ToggleSwitch
on={tripRemindersActive}
label={t('admin.notifications.tripReminders.title')}
onToggle={toggleTripReminders}
/>
</SettingRows>
</div>
<CardActions>
<button type="button" onClick={saveNotifications} className={SETTINGS_BUTTON_PRIMARY}>
<Save size={14} strokeWidth={2.2} />{t('common.save')}
</button>
<button type="button"
onClick={testSmtp}
disabled={!smtpConfigured}
className={SETTINGS_BUTTON}
>
<Send size={14} strokeWidth={2.2} />{t('admin.smtp.testButton')}
</button>
</CardActions>
</SettingsCard>
)}
}
/>
</SettingRows>
{/* Webhook, Ntfy and Web Push: a title, a hint and the switch each. Nothing
to set below them, so the card is its head band alone. */}
{SWITCH_ONLY_CHANNELS.map(ch => (
<SettingsCard
key={ch.id}
icon={CHANNEL_ICONS[ch.id] ?? Smartphone}
title={t(ch.titleKey)}
hint={t(ch.hintKey)}
action={<ToggleSwitch on={channels.isActive(ch.id)} label={t(ch.titleKey)} onToggle={() => channels.toggle(ch.id)} />}
/>
))}
{/* In-App Panel */}
<SettingsCard
icon={Bell}
title={t('admin.notifications.inappPanel.title')}
hint={t('admin.notifications.inappPanel.hint')}
action={<LockedSwitch />}
/>
{!managed && tripRemindersCard}
</div>
<div className="min-w-0">
{/* The SMTP relay, a section of this card: greyed and out of reach while
the email channel is off, never hidden. */}
{!managed && (
<SettingsCard icon={Mail} title={t('admin.notifications.emailPanel.title')}>
<div data-testid="smtp-fields" className={`flex flex-col gap-4 transition-opacity ${!emailActive ? 'opacity-50 pointer-events-none' : ''}`}>
{smtpLoaded && (<>
<div className={GRID_2}>
{smtpField(SMTP_FIELDS[0])}
{smtpField(SMTP_FIELDS[1])}
</div>
<div className={GRID_2}>
{smtpField(SMTP_FIELDS[2])}
{smtpField(SMTP_FIELDS[3])}
</div>
{smtpField(SMTP_FIELDS[4])}
</>)}
<SettingRows>
<SettingRow
label={t('admin.smtp.skipTls')}
hint={t('admin.smtp.skipTlsHint')}
control={
<ToggleSwitch
on={smtpValues.smtp_skip_tls_verify === 'true'}
label={t('admin.smtp.skipTls')}
onToggle={() => {
const newVal = smtpValues.smtp_skip_tls_verify === 'true' ? 'false' : 'true'
setSmtpValues(prev => ({ ...prev, smtp_skip_tls_verify: newVal }))
}}
/>
}
/>
</SettingRows>
</div>
<CardActions>
<button type="button" onClick={saveNotifications} className={SETTINGS_BUTTON_PRIMARY}>
<Save size={14} strokeWidth={2.2} />{t('common.save')}
</button>
<button type="button"
onClick={testSmtp}
disabled={!smtpConfigured}
className={SETTINGS_BUTTON}
>
<Send size={14} strokeWidth={2.2} />{t('admin.smtp.testButton')}
</button>
</CardActions>
</SettingsCard>
)}
</SettingsCard>
}
right={
// What the operator receives about running the instance: one card, the
// two targets as its blocks, then which event goes out over which channel.
<SettingsCard icon={BellRing} title={t('admin.notifications.adminAlerts')} hint={t('admin.notifications.adminNotificationsHint')}>
{/* Admin alerts are about running the instance (version notices, and what else
lands there later). On a managed install those go to whoever runs it. */}
{!managed && (<>
@@ -258,7 +241,6 @@ export default function AdminNotificationsTab({ admin, t }: AdminNotificationsTa
<button type="button"
onClick={clearNtfyToken}
className={`${SETTINGS_BUTTON_DANGER} flex-none`}
style={fs(13, 'body')}
>
{t('common.clear')}
</button>
@@ -286,12 +268,10 @@ export default function AdminNotificationsTab({ admin, t }: AdminNotificationsTa
{/* The matrix decides which of those channels each admin-only event goes out
over, so it belongs under the targets it routes to rather than across the
full width below both columns. */}
<AdminNotificationsPanel t={t} toast={toast} />
{/* Without the admin alert cards the right column is short, so the
reminder switch moves over on a managed install. */}
{managed && tripRemindersCard}
</div>
</div>
<AdminNotificationsPanel t={t} toast={toast} bare />
</SettingsCard>
}
/>
{/* What users' own notifications start as, across the full width: one row per
user event, one column per channel users can pick (#1536). */}
@@ -13,9 +13,20 @@ import AdminSettingsTab from './AdminSettingsTab';
type Spy = ReturnType<typeof vi.fn>;
type Spies = Record<string, Spy> & { toast: Record<string, Spy> };
/** The three tabs the settings are split into (security, services, features), on one page. */
function AllParts({ admin, t }: { admin: AdminHook; t: ReturnType<typeof useTranslation>['t'] }) {
return (
<>
<AdminSettingsTab admin={admin} t={t} part="security" />
<AdminSettingsTab admin={admin} t={t} part="services" />
<AdminSettingsTab admin={admin} t={t} part="features" />
</>
);
}
function Harness({ admin }: { admin: AdminHook }) {
const { t } = useTranslation();
return <AdminSettingsTab admin={admin} t={t} />;
return <AllParts admin={admin} t={t} />;
}
function renderTab(overrides: Record<string, unknown> = {}) {
@@ -71,7 +82,7 @@ function OidcHarness() {
const { t } = useTranslation();
const [oidcConfig, setOidcConfig] = React.useState<OidcConfig>(EMPTY_OIDC);
const admin = buildAdminHook({ oidcConfig, setOidcConfig });
return <AdminSettingsTab admin={admin} t={t} />;
return <AllParts admin={admin} t={t} />;
}
beforeEach(() => {
@@ -88,7 +99,8 @@ describe('AdminSettingsTab', () => {
expect(screen.getByRole('heading', { name: 'Authentication Methods' })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'Passkey login' })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: /require two-factor/i })).toBeInTheDocument();
// Requiring two-factor is a switch of the sign-in card now, not a card of its own.
expect(screen.getByText(/require two-factor/i)).toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'Allowed File Types' })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'API Keys' })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: /single sign-on/i })).toBeInTheDocument();
@@ -340,13 +352,11 @@ describe('AdminSettingsTab', () => {
expect(screen.queryByRole('button', { name: 'Automatic' })).not.toBeInTheDocument();
});
it('FE-ADMSET-022f: a managed install moves file types to the right column', () => {
// With place search and the keys gone the right column would stand empty.
it('FE-ADMSET-022f: a managed install keeps the file types card on the services tab', () => {
// Place search and the keys are the operator's there; the file types stay the admin's.
renderTab({ managed: true });
const right = screen.getByRole('heading', { name: 'Allowed File Types' }).closest('.min-w-0');
expect(right).not.toBeNull();
expect(within(right as HTMLElement).queryByRole('heading', { name: /two-factor/i })).not.toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'Allowed File Types' })).toBeInTheDocument();
});
it('FE-ADMSET-023: the maps Test button is disabled without a key', () => {
+127 -71
View File
@@ -10,7 +10,7 @@ import CustomSelect from '../../components/shared/CustomSelect'
import { fs } from '../../components/shared/DialogShell'
import { EditorField, GRID_2, INPUT } from '../../components/shared/dialogParts'
import {
SETTINGS_BUTTON, SETTINGS_BUTTON_DANGER, SETTINGS_BUTTON_PRIMARY, SettingRow, SettingRows, SettingsCard, SettingsHint, StatusPill,
SETTINGS_BUTTON, SETTINGS_BUTTON_DANGER, SETTINGS_BUTTON_PRIMARY, SettingRow, SettingRows, SettingsCard, SettingsColumns, SettingsHint, StatusPill,
} from '../../components/Settings/settingsKit'
import GoogleOptions from './GoogleOptions'
import GoogleDailyLimitRow from '../../components/Admin/GoogleDailyLimitRow'
@@ -26,6 +26,12 @@ import type { useAdmin } from './useAdmin'
interface AdminSettingsTabProps {
admin: ReturnType<typeof useAdmin>
t: TranslationFn
/**
* Which tab this renders: sign-in and its safety (security), what the
* install talks to outside (services), or the setup of the features the
* addons tab switches on (features).
*/
part: 'security' | 'services' | 'features'
}
/**
@@ -102,7 +108,7 @@ function KeyField({ shown, value, onChange, inputProps, label, onToggle }: {
function SaveBar({ onClick, saving, label }: { onClick: () => void; saving: boolean; label: string }) {
return (
<div className="flex justify-end">
<button type="button" onClick={onClick} disabled={saving} className={SETTINGS_BUTTON_PRIMARY} style={fs(13, 'body')}>
<button type="button" onClick={onClick} disabled={saving} className={SETTINGS_BUTTON_PRIMARY}>
{saving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
{label}
</button>
@@ -110,9 +116,9 @@ function SaveBar({ onClick, saving, label }: { onClick: () => void; saving: bool
)
}
// "Settings" admin tab: auth methods, require-MFA, allowed file types, API keys,
// OIDC config and the danger zone. Pure layout around the useAdmin hook.
export default function AdminSettingsTab({ admin, t }: AdminSettingsTabProps): React.ReactElement {
// The admin's security, services and feature setup tabs, cut from what was one
// "Settings" tab. Pure layout around the useAdmin hook.
export default function AdminSettingsTab({ admin, t, part }: AdminSettingsTabProps): React.ReactElement {
const {
toast,
placesPhotosEnabled, placesAutocompleteEnabled, placesDetailsEnabled, placesEnrichEnabled,
@@ -143,8 +149,6 @@ export default function AdminSettingsTab({ admin, t }: AdminSettingsTabProps): R
const passwordLoginLocked = !passwordLogin && !oidcLogin
const oidcLoginLocked = !passwordLogin && oidcLogin
// On a managed install the right column loses place search, routing and the
// keys to the operator, so file types move over and the columns stay even.
const fileTypesCard = (
<SettingsCard icon={FileType} title={t('admin.fileTypes')} hint={t('admin.fileTypesHint')}>
<div>
@@ -166,18 +170,9 @@ export default function AdminSettingsTab({ admin, t }: AdminSettingsTabProps): R
</SettingsCard>
)
return (
<div>
{/* Two columns from xl up. Nearly every card here is "label left,
switch right", so on a wide screen the middle stayed empty while the
page scrolled for ages. Two explicit columns rather than a grid over
the flat list: a plain grid pairs cards row by row and leaves a hole
under whichever of the pair is shorter, and these differ by hundreds
of pixels. Grouped by subject, not by height — access and identity
left, integrations and data right. The cards carry their own bottom
margin, so only the column gap is set here. */}
<div className="grid grid-cols-1 gap-x-5 xl:grid-cols-2 xl:items-start">
<div className="min-w-0">
// Each card is built once; the parts below pick theirs.
const authCard = (
<>
{/* Authentication Methods */}
<SettingsCard icon={LockKeyhole} title={t('admin.authMethods')}>
{envOverrideOidcOnly && <Callout>{t('admin.envOverrideHint')}</Callout>}
@@ -251,12 +246,7 @@ export default function AdminSettingsTab({ admin, t }: AdminSettingsTabProps): R
}
/>
)}
</SettingRows>
</SettingsCard>
{/* Passkey (WebAuthn) login */}
<SettingsCard icon={Fingerprint} title={t('admin.passkey.title')} hint={t('admin.passkey.cardHint')}>
<SettingRows>
{/* Passkeys and the 2FA rule are ways in too, so they sit with the others. */}
<SettingRow
label={t('admin.passkey.login')}
hint={t('admin.passkey.loginHint')}
@@ -268,17 +258,38 @@ export default function AdminSettingsTab({ admin, t }: AdminSettingsTabProps): R
/>
}
/>
<SettingRow
label={t('admin.requireMfa')}
hint={t('admin.requireMfaHint')}
control={
<ToggleSwitch
on={requireMfa}
label={t('admin.requireMfa')}
onToggle={() => handleToggleRequireMfa(!requireMfa)}
/>
}
/>
</SettingRows>
{passkeyLogin && !passkeyConfigured && <Callout>{t('admin.passkey.notConfigured')}</Callout>}
</SettingsCard>
</>
)
// Where passkeys bind: an expert setting, a row of the setup list. The switch
// to offer passkeys at all is in the sign-in card.
const passkeyCard = (
<>
{/* The domain passkeys bind to and the origins that may present them
follow from the address the instance is served on, which the operator
owns. Getting either wrong invalidates every enrolled passkey, and on
a shared parent domain a wrong RP ID reaches past this instance
entirely — so they are pinned per container, not offered here. The
switch above stays: whether to offer passkeys at all is a house rule. */}
{!managed && (<>
entirely — so they are pinned per container, not offered here. */}
{!managed && (
<SettingsCard
icon={Fingerprint}
title={t('admin.passkey.title')}
hint={t('admin.passkey.cardHint')}
badge={webauthnRpId ? <StatusPill>{webauthnRpId}</StatusPill> : undefined}
>
<EditorField label={t('admin.passkey.rpId')} htmlFor="admin-webauthn-rp-id" hint={t('admin.passkey.rpIdHint')}>
<input
id="admin-webauthn-rp-id"
@@ -300,32 +311,27 @@ export default function AdminSettingsTab({ admin, t }: AdminSettingsTabProps): R
/>
</EditorField>
<SaveBar onClick={handleSaveWebauthn} saving={savingWebauthn} label={t('common.save')} />
</>)}
</SettingsCard>
{/* Require 2FA for all users */}
<SettingsCard icon={ShieldCheck} title={t('admin.requireMfa')}>
<SettingRows>
<SettingRow
label={t('admin.requireMfa')}
hint={t('admin.requireMfaHint')}
control={
<ToggleSwitch
on={requireMfa}
label={t('admin.requireMfa')}
onToggle={() => handleToggleRequireMfa(!requireMfa)}
/>
}
/>
</SettingRows>
</SettingsCard>
</SettingsCard>
)}
</>
)
const oidcCard = (
<>
{/* An issuer the instance names can assert any address as verified, and the
discovery calls leave from inside the operator’s network. Sign-on is theirs
to wire, so the fields are not offered. */}
{!managed && (
/* OIDC / SSO Configuration */
<SettingsCard icon={LogIn} title={t('admin.oidcTitle')} hint={t('admin.oidcSubtitle')}>
<SettingsCard
icon={LogIn}
title={t('admin.oidcTitle')}
hint={t('admin.oidcSubtitle')}
badge={
oidcConfigured
? <StatusPill tone="success" icon={<span className="h-1.5 w-1.5 rounded-full bg-current" />}>{t('settings.configured')}</StatusPill>
: <StatusPill icon={<span className="h-1.5 w-1.5 rounded-full bg-current" />}>{t('settings.notSetUp')}</StatusPill>
}
>
<div className={GRID_2}>
<EditorField label={t('admin.oidcDisplayName')} htmlFor="oidc-display-name">
<input
@@ -390,9 +396,10 @@ export default function AdminSettingsTab({ admin, t }: AdminSettingsTabProps): R
/>
</SettingsCard>
)}
{!managed && fileTypesCard}
</>
)
const flightCard = (
<>
{/* Live flight status: open ADS-B data, no key, on by default; the admin may keep it off. */}
<SettingsCard icon={Plane} title={t('flightLive.adminTitle')} hint={managed ? t('flightLive.adminHintManaged') : t('flightLive.adminHint')}>
<SettingRows>
@@ -410,19 +417,17 @@ export default function AdminSettingsTab({ admin, t }: AdminSettingsTabProps): R
/>
</SettingRows>
</SettingsCard>
</>
)
const mailCard = (
<>
{/* Booking mail import: how forwarded booking mails reach this install. On a managed
install the operator runs the TREK Cloud relay, so the card is not shown there. */}
{!managed && <MailIngestAdminCard />}
</div>
<div className="min-w-0">
{managed && fileTypesCard}
{/* Inside the place search card elsewhere; that card is the operator's here,
sharing place details is still the admin's decision. */}
{managed && <PlaceContribPanel hideWhenSwitchedOff />}
</>
)
const servicesCard = (
<>
{/* Place search and routing: TREK's own services first, then which provider
answers search and transit, and the search log. Apart from the keys, so a
keyless install reads its whole setup in one card. Everything here saves
@@ -539,7 +544,10 @@ export default function AdminSettingsTab({ admin, t }: AdminSettingsTabProps): R
</SettingsCard>
)}
</>
)
const apiKeysCard = (
<>
{/* API Keys: only the keyed providers, Google, Unsplash and Amap. They are
the operator's on a managed install, so the card is not shown there
(server managed.ts), and neither is the provider pick in the card
@@ -570,7 +578,6 @@ export default function AdminSettingsTab({ admin, t }: AdminSettingsTabProps): R
onClick={() => handleValidateKey('maps')}
disabled={!mapsKeyTestable || validating.maps}
className={`${SETTINGS_BUTTON} flex-none whitespace-nowrap`}
style={fs(13, 'body')}
>
{validating.maps ? (
<Loader2 size={14} className="animate-spin" />
@@ -695,9 +702,10 @@ export default function AdminSettingsTab({ admin, t }: AdminSettingsTabProps): R
<SaveBar onClick={handleSaveApiKeys} saving={savingKeys} label={t('common.save')} />
</SettingsCard>
)}
</div>
</div>
</>
)
const dangerCard = (
<>
{/* Full width, and last. A destructive action should not sit beside a
harmless toggle where a mis-aimed click can reach it. */}
{/* Rotating the secret signs every user out and fixes nothing an instance admin
@@ -713,7 +721,6 @@ export default function AdminSettingsTab({ admin, t }: AdminSettingsTabProps): R
<button type="button"
onClick={() => setShowRotateJwtModal(true)}
className={SETTINGS_BUTTON_DANGER}
style={fs(13, 'body')}
>
<RefreshCw size={14} />
{t('admin.rotateJwt.button')}
@@ -723,6 +730,55 @@ export default function AdminSettingsTab({ admin, t }: AdminSettingsTabProps): R
</SettingRows>
</SettingsCard>
)}
</div>
</>
)
// What used to be one "Settings" tab, split by what an admin comes for:
// who may sign in and how (security), what the install talks to outside
// (services), and how the features in the addons tab are set up there.
if (part === 'features') {
return (
<>
{flightCard}
{mailCard}
</>
)
}
if (part === 'services') {
// Place search and the services on the left, the keys and file types on the right.
return (
<SettingsColumns
left={<>
{/* Inside the place search card elsewhere; that card is the operator's here,
sharing place details is still the admin's decision. */}
{managed && <PlaceContribPanel hideWhenSwitchedOff />}
{servicesCard}
</>}
right={<>
{apiKeysCard}
{fileTypesCard}
</>}
/>
)
}
// Who may sign in and what it takes (the passkey domain, single sign-on),
// with the one destructive action in its own card.
return (
// Split by length, not by kind: single sign-on is the one long form, so the
// right column holds it and the short danger zone under it.
<SettingsColumns
left={
<>
{authCard}
{passkeyCard}
</>
}
right={
<>
{oidcCard}
{dangerCard}
</>
}
/>
)
}
@@ -297,7 +297,6 @@ export default function AdminUserModals({ admin, t }: AdminUserModalsProps): Rea
if (editingUser) setPasskeyResetUser(editingUser);
}}
className={`${SETTINGS_BUTTON_DANGER} flex-none`}
style={fs(12.5, 'body')}
>
<Fingerprint size={14} /> {t('admin.passkey.reset')}
</button>
@@ -313,7 +312,6 @@ export default function AdminUserModals({ admin, t }: AdminUserModalsProps): Rea
type="button"
onClick={() => setMfaResetUser(editingUser)}
className={`${SETTINGS_BUTTON_DANGER} flex-none`}
style={fs(12.5, 'body')}
>
<ShieldOff size={14} /> {t('admin.mfa.reset')}
</button>
@@ -359,7 +357,6 @@ export default function AdminUserModals({ admin, t }: AdminUserModalsProps): Rea
target="_blank"
rel="noopener noreferrer"
className={SETTINGS_BUTTON}
style={fs(13, 'body')}
>
<ExternalLink size={14} strokeWidth={2.1} className="flex-none" />
{t('admin.update.button')}
@@ -525,7 +522,6 @@ function SsoUnlinkSection({
type="button"
onClick={() => setAsking(true)}
className={`${SETTINGS_BUTTON_DANGER} flex-none`}
style={fs(12.5, 'body')}
>
<Unlink size={14} /> {t('admin.sso.unlink')}
</button>
+2 -2
View File
@@ -58,7 +58,7 @@ export default function AdminUsersTab({ admin, t, locale }: AdminUsersTabProps):
title={t('admin.tabs.users')}
hint={<>{users.length} {t('admin.stats.users', { count: users.length })}</>}
action={
<button type="button" onClick={() => setShowCreateUser(true)} className={SETTINGS_BUTTON_PRIMARY} style={fs(12.5, 'body')}>
<button type="button" onClick={() => setShowCreateUser(true)} className={SETTINGS_BUTTON_PRIMARY}>
<UserPlus size={14} strokeWidth={2.1} />
{t('admin.createUser')}
</button>
@@ -146,7 +146,7 @@ export default function AdminUsersTab({ admin, t, locale }: AdminUsersTabProps):
title={t('admin.invite.title')}
hint={t('admin.invite.subtitle')}
action={
<button type="button" onClick={() => setShowCreateInvite(true)} className={SETTINGS_BUTTON} style={fs(12.5, 'body')}>
<button type="button" onClick={() => setShowCreateInvite(true)} className={SETTINGS_BUTTON}>
<Plus size={14} strokeWidth={2.2} />
{t('admin.invite.create')}
</button>
@@ -1,35 +0,0 @@
import { describe, expect, it } from 'vitest';
import { fireEvent, render, screen } from '../../../tests/helpers/render';
import FoldSummary from './FoldSummary';
describe('FoldSummary', () => {
it('heads the fold with a turning chevron and its own label', () => {
const { container } = render(
<details className="group">
<FoldSummary>
<span>More</span>
</FoldSummary>
<p>inside</p>
</details>
);
const summary = container.querySelector('summary') as HTMLElement;
expect(summary).toContainElement(screen.getByText('More'));
const chevron = summary.querySelector('svg') as SVGElement;
expect(chevron).toHaveAttribute('aria-hidden', 'true');
expect(chevron.getAttribute('class')).toContain('group-open:rotate-90');
expect(summary.firstElementChild).toBe(chevron);
});
it('opens and closes the fold it heads', () => {
const { container } = render(
<details className="group">
<FoldSummary>More</FoldSummary>
<p>inside</p>
</details>
);
const details = container.querySelector('details') as HTMLDetailsElement;
expect(details.open).toBe(false);
fireEvent.click(container.querySelector('summary') as HTMLElement);
expect(details.open).toBe(true);
});
});
-21
View File
@@ -1,21 +0,0 @@
import { ChevronRight } from 'lucide-react';
import React from 'react';
/**
* The clickable head of a native <details> fold on the admin settings page: a
* chevron that turns when the fold opens, then whatever the fold calls itself.
* The surrounding <details> needs the `group` class for the chevron to turn.
*/
export default function FoldSummary({ children }: { children: React.ReactNode }): React.ReactElement {
return (
<summary className="flex cursor-pointer list-none items-center gap-2.5 px-3.5 py-2.5 hover:bg-surface-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[color:var(--text-primary)] [&::-webkit-details-marker]:hidden">
<ChevronRight
size={15}
strokeWidth={2}
className="flex-none text-content-faint transition-transform group-open:rotate-90"
aria-hidden="true"
/>
{children}
</summary>
);
}
+7 -14
View File
@@ -1,7 +1,5 @@
import React from 'react'
import { fs } from '../../components/shared/DialogShell'
import { StatusPill } from '../../components/Settings/settingsKit'
import FoldSummary from './FoldSummary'
import { SettingsBlock, StatusPill } from '../../components/Settings/settingsKit'
interface GoogleOptionsProps {
/** "Google options" — the heading of the fold. */
@@ -19,19 +17,14 @@ interface GoogleOptionsProps {
* an admin opens this card at all. The switches are the opposite — set once,
* then never touched, and there are five of them.
*
* A native <details> rather than a state hook: keyboard operable and announced
* to screen readers without writing any of that, and it survives a re-render
* without state. Drawn as a box of rows (settingsKit's SettingRows), the
* summary being its first row.
* Shown open: the settings pages fold nothing away.
*/
export default function GoogleOptions({ title, summary, children }: GoogleOptionsProps): React.ReactElement {
return (
<details className="group overflow-hidden rounded-[12px] border border-edge-faint bg-surface-secondary">
<FoldSummary>
<span className="min-w-0 flex-1 truncate font-medium text-content" style={fs(13, 'body')}>{title}</span>
<StatusPill>{summary}</StatusPill>
</FoldSummary>
<div className="divide-y divide-edge-faint border-t border-edge-faint bg-surface-card">{children}</div>
</details>
// A block of the card: its title and the count of switches on as the block's
// head, its rows inside.
<SettingsBlock title={title} badge={<StatusPill>{summary}</StatusPill>} flush>
{children}
</SettingsBlock>
)
}
+7 -13
View File
@@ -1,6 +1,5 @@
import React from 'react'
import { fs } from '../../components/shared/DialogShell'
import { StatusPill } from '../../components/Settings/settingsKit'
import { SettingsBlock, StatusPill } from '../../components/Settings/settingsKit'
interface ProviderBlockProps {
title: string
@@ -17,21 +16,16 @@ interface ProviderBlockProps {
}
/**
* One provider inside the API card, in the same shape as the TREK block above
* it, so the page reads as a list of comparable options rather than one
* highlighted thing and some loose fields underneath. A white box on the
* card's grey body, its name in a head row, like a bag in the packing sidebar.
* One provider inside the API card: its name in a head row, a pill beside it,
* its fields below, as one rounded block of the card (SettingsBlock), so the
* card reads as a list of comparable options.
*/
export default function ProviderBlock({
title, badge, tone = 'muted', children,
}: ProviderBlockProps): React.ReactElement {
return (
<section className="overflow-hidden rounded-[14px] border border-edge-faint bg-surface-card">
<div className="flex min-w-0 items-center gap-2 border-b border-edge-faint px-3.5 py-2.5">
<h3 className="m-0 min-w-0 flex-1 truncate font-semibold text-content" style={fs(13, 'body')}>{title}</h3>
{badge && <StatusPill tone={tone === 'caution' ? 'warning' : 'neutral'}>{badge}</StatusPill>}
</div>
<div className="flex flex-col gap-3 p-3.5">{children}</div>
</section>
<SettingsBlock title={title} badge={badge ? <StatusPill tone={tone === 'caution' ? 'warning' : 'neutral'}>{badge}</StatusPill> : undefined}>
{children}
</SettingsBlock>
)
}
+14 -101
View File
@@ -1,19 +1,11 @@
import React from 'react'
import { Library, MapPin, Route, Search, X } from 'lucide-react'
import TrekMark from '../../components/shared/TrekMark'
import { fs } from '../../components/shared/DialogShell'
import { StatusPill } from '../../components/Settings/settingsKit'
import FoldSummary from './FoldSummary'
import ServiceIdentityPanel from '../../components/Admin/ServiceIdentityPanel'
import PlaceContribPanel from '../../components/Admin/PlaceContribPanel'
import type { TranslationFn } from '../../types'
import {
TREK_API_SOURCES as SOURCES,
trekApiAddressFeatures,
trekApiFacts,
trekApiFields,
trekApiRoutingFeatures,
} from '../../components/Admin/trekApiModel'
import { TREK_API_SOURCES as SOURCES, trekApiFacts } from '../../components/Admin/trekApiModel'
interface TrekApiCardProps {
t: TranslationFn
@@ -35,30 +27,13 @@ interface TrekApiCardProps {
* three places on the server rather than promised here, and the allowance below
* the facts is what the service itself reports for this installation.
*/
const CHIP = 'rounded-full border px-2.5 py-[3px] font-medium'
/** One of the lists under "more": an icon and a name, chips, a note. */
function FactGroup({ icon, title, note, children }: { icon: React.ReactNode; title: string; note: string; children: React.ReactNode }) {
return (
<div className="flex flex-col gap-2 px-3.5 py-3">
<p className="m-0 flex items-center gap-1.5 font-semibold text-content" style={fs(12.5, 'body')}>
{icon}
{title}
</p>
<ul className="m-0 flex list-none flex-wrap gap-1.5 p-0" style={fs(11.5)}>{children}</ul>
<p className="m-0 leading-normal text-content-faint" style={fs(11.5)}>{note}</p>
</div>
)
}
export default function TrekApiCard({ t, managed = false }: TrekApiCardProps): React.ReactElement {
const fields = trekApiFields(t)
const facts = trekApiFacts(t)
return (
<div className="overflow-hidden rounded-[14px] border bg-surface-card shadow-sm"
<section className="overflow-hidden rounded-[12px] border bg-surface-card"
style={{ borderColor: 'color-mix(in srgb, var(--accent) 40%, transparent)' }}>
<div className="flex flex-col gap-3 px-3.5 pb-3.5 pt-3.5">
<div className="flex flex-col gap-3 p-3.5">
{/* The recommendation, moved here from the Google field. It is the whole
point of the block: a key should be the exception, not the default.
A pill beside the mark rather than a banner, so it does not shout
@@ -72,13 +47,12 @@ export default function TrekApiCard({ t, managed = false }: TrekApiCardProps): R
{t('admin.trekApi.tagline')}
</p>
<ul className="m-0 grid list-none grid-cols-1 gap-x-4 gap-y-1.5 p-0 sm:grid-cols-2">
{/* What it offers as compact badges that wrap, not a list of rows. */}
<ul className="m-0 flex list-none flex-wrap gap-1.5 p-0">
{facts.map(({ Icon, text }) => (
<li key={text} className="flex min-w-0 items-center gap-2 text-content-secondary" style={fs(12, 'body')}>
<span className="grid h-6 w-6 flex-none place-items-center rounded-[8px] bg-surface-tertiary text-content-muted">
<Icon size={13} strokeWidth={2} aria-hidden="true" />
</span>
<span className="min-w-0">{text}</span>
<li key={text} className="inline-flex items-center gap-1.5 rounded-full border border-edge-faint bg-surface-secondary px-2.5 py-1 text-content-secondary" style={fs(11.5)}>
<Icon size={12} strokeWidth={2} aria-hidden="true" className="flex-none text-content-muted" />
{text}
</li>
))}
</ul>
@@ -87,72 +61,11 @@ export default function TrekApiCard({ t, managed = false }: TrekApiCardProps): R
<PlaceContribPanel />
</div>
<details className="group border-t border-edge-faint">
<FoldSummary>
<span className="font-medium text-content" style={fs(13, 'body')}>{t('admin.trekApi.more')}</span>
</FoldSummary>
<div className="divide-y divide-edge-faint border-t border-edge-faint bg-surface-secondary">
{/* The fields as chips rather than a paragraph. A list of what you
get is something you scan, not something you read, and a sentence
forces the reader to parse commas to answer "is the phone number
in there". One list per service, under the service's own name. */}
<FactGroup
icon={<MapPin size={14} strokeWidth={2.2} className="text-content-muted" aria-hidden="true" />}
title={t('admin.trekApi.quota.service.places')}
note={t('admin.trekApi.includedNote')}
>
{fields.map(field => (
<li key={field} className={`${CHIP} border-edge-faint bg-surface-card text-content-secondary`}>{field}</li>
))}
</FactGroup>
{/* Named as plainly as what IS included. An admin who switches the
source expecting ratings and a photograph of every restaurant
should find that out here and not three weeks later. */}
<FactGroup
icon={<X size={14} strokeWidth={2.4} className="text-content-faint" aria-hidden="true" />}
title={t('admin.trekApi.notIncluded')}
note={t('admin.trekApi.notIncludedNote')}
>
{[t('admin.trekApi.notRatings'), t('admin.trekApi.notPhotos')].map(item => (
<li key={item} className={`${CHIP} border-dashed border-edge text-content-faint`}>{item}</li>
))}
</FactGroup>
<FactGroup
icon={<Search size={14} strokeWidth={2.2} className="text-content-muted" aria-hidden="true" />}
title={t('admin.trekApi.quota.service.photon')}
note={t('admin.trekApi.addressNote')}
>
{trekApiAddressFeatures(t).map(item => (
<li key={item} className={`${CHIP} border-edge-faint bg-surface-card text-content-secondary`}>{item}</li>
))}
</FactGroup>
<FactGroup
icon={<Route size={14} strokeWidth={2.2} className="text-content-muted" aria-hidden="true" />}
title={t('admin.trekApi.quota.service.valhalla')}
note={t('admin.trekApi.routingNote')}
>
{trekApiRoutingFeatures(t).map(item => (
<li key={item} className={`${CHIP} border-edge-faint bg-surface-card text-content-secondary`}>{item}</li>
))}
</FactGroup>
{/* Attribution, and not in the small print: the licences require the
sources to be named, and naming them is also the answer to "where
does this actually come from". */}
<FactGroup
icon={<Library size={14} strokeWidth={2} className="text-content-faint" aria-hidden="true" />}
title={t('admin.trekApi.sourcesLabel')}
note={t('admin.trekApi.sourcesNote')}
>
{SOURCES.map(source => (
<li key={source} className={`${CHIP} border-edge-faint bg-surface-card text-content-secondary`}>{source}</li>
))}
</FactGroup>
</div>
</details>
</div>
{/* The sources, named because their licences ask for it, in one line under
the card instead of a list of every field the service answers. */}
<p className="m-0 border-t border-edge-faint px-3.5 py-2.5 leading-snug text-content-faint" style={fs(11.5)}>
<span className="font-semibold text-content-muted">{t('admin.trekApi.sourcesLabel')}:</span> {SOURCES.join(', ')}
</p>
</section>
)
}
+10 -2
View File
@@ -21,10 +21,17 @@ import { passwordErrorKey } from '../../utils/passwordError'
* sidebar. Labels and icons stay in AdminPage; this is only the vocabulary.
*/
const ADMIN_TAB_IDS = [
'users', 'defaults', 'config', 'settings', 'addons', 'plugins', 'storage',
'users', 'defaults', 'config', 'security', 'services', 'addons', 'plugins', 'storage',
'notifications', 'mcp-tokens', 'github', 'backup', 'audit', 'dev-notifications',
]
/**
* Tab ids that were renamed, so a bookmark, a wiki page or a mail that names the
* old one still lands. "Settings" was one tab for sign-in, services and the
* danger zone; it opens on security, where the danger zone went too.
*/
const ADMIN_TAB_ALIASES: Record<string, string> = { settings: 'security' }
/**
* The ones a managed install deliberately does not offer.
*
@@ -64,7 +71,8 @@ export function useAdmin() {
// on every change, so the address bar keeps saying where the reader is.
const [searchParams, setSearchParams] = useSearchParams()
const [activeTab, setActiveTabState] = useState<string>(() => {
const asked = searchParams.get('tab')
const raw = searchParams.get('tab')
const asked = raw ? (ADMIN_TAB_ALIASES[raw] ?? raw) : null
const known = [...ADMIN_TAB_IDS, ...managedAdminTabs.map(tab => tab.id)]
return asked && known.includes(asked) ? asked : 'users'
})
+2
View File
@@ -116,6 +116,8 @@ export default {
subtitle: ['calc(18px * var(--fs-scale-subtitle, 1))', '1.35'],
body: ['calc(14px * var(--fs-scale-body, 1))', '1.5'],
caption: ['calc(12px * var(--fs-scale-caption, 1))', '1.4'],
// Buttons and segmented controls on the settings pages: between caption and body.
control: ['calc(13px * var(--fs-scale-body, 1))', '1.3'],
},
},
},
-26
View File
@@ -1,26 +0,0 @@
import { settingsCardOpenKey } from '../../src/components/Settings/settingsKit';
/** The photo services the settings tests connect, each its own card (`photos-<id>`). */
const PHOTO_PROVIDERS = ['immich', 'synology', 'piwigo', 'nextcloud', 'google'];
/** Every collapsible card of the integrations tab, by the id it is linked and remembered by. */
export const INTEGRATION_CARDS: readonly string[] = [
'mail-import',
'airtrail',
'dawarich',
'wanderer',
'ai-connection',
'ai-parsing',
'api-keys',
'mcp',
...PHOTO_PROVIDERS.map((id) => `photos-${id}`),
];
/**
* Leaves settings cards open, as if their reader had left them so: a test then
* reads into a card without clicking its head first. Storage is cleared after
* every test (tests/setup.ts), so call it in a beforeEach.
*/
export function openSettingsCards(ids: readonly string[] = INTEGRATION_CARDS): void {
for (const id of ids) localStorage.setItem(settingsCardOpenKey(id), '1');
}
+12 -4
View File
@@ -145,7 +145,7 @@ describe('MAdmin', () => {
fireEvent.click(screen.getByRole('button', { name: 'Administration' }));
// All standard sections are listed
expect(screen.getByRole('button', { name: 'User Defaults' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Defaults' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Backup' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Audit' })).toBeInTheDocument();
@@ -156,6 +156,14 @@ describe('MAdmin', () => {
expect(screen.queryByText('alice')).not.toBeInTheDocument();
});
it('FE-MOB-ADMIN-002b: a link to the desktop Security or Services tab opens the Settings section', async () => {
for (const tab of ['security', 'services', 'settings']) {
const { unmount } = render(<MAdmin />, { initialEntries: [`/admin?tab=${tab}`] });
await waitFor(() => expect(screen.getByText('Authentication Methods')).toBeInTheDocument());
unmount();
}
});
it('FE-MOB-ADMIN-003: the back button leaves for the dashboard', async () => {
renderAdmin();
await waitFor(() => expect(screen.getByText('alice')).toBeInTheDocument());
@@ -180,7 +188,7 @@ describe('MAdmin', () => {
fireEvent.click(screen.getByRole('button', { name: 'Administration' }));
expect(screen.queryByRole('button', { name: 'MCP Access' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'MCP' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Dev: Notifications' })).not.toBeInTheDocument();
});
@@ -193,7 +201,7 @@ describe('MAdmin', () => {
renderAdmin();
await waitFor(() => expect(screen.getByText('alice')).toBeInTheDocument());
await openSection('MCP Access');
await openSection('MCP');
expect(screen.getByTestId('mcp-tokens-panel')).toBeInTheDocument();
});
@@ -212,7 +220,7 @@ describe('MAdmin', () => {
renderAdmin();
await waitFor(() => expect(screen.getByText('alice')).toBeInTheDocument());
await openSection('User Defaults');
await openSection('Defaults');
expect(screen.getByTestId('default-user-settings')).toBeInTheDocument();
await openSection('Personalization');
@@ -73,7 +73,7 @@ describe('MAdminMcpTokensPanel', () => {
it('FE-MOB-MCPTOK-001: renders the section header and both sub-section titles', async () => {
renderPanel();
expect(screen.getByText('MCP Access')).toBeInTheDocument();
expect(screen.getByText('MCP')).toBeInTheDocument();
expect(screen.getByText('Manage OAuth sessions and API tokens across all users')).toBeInTheDocument();
expect(screen.getByText('OAuth Sessions')).toBeInTheDocument();
expect(screen.getByText('API Tokens')).toBeInTheDocument();
+7 -23
View File
@@ -2,6 +2,8 @@ import type { TranslationStrings } from '../types';
const admin: TranslationStrings = {
'admin.notifications.title': 'الإشعارات',
'admin.notifications.channels': 'القنوات',
'admin.notifications.adminAlerts': 'تنبيهات المسؤول',
'admin.notifications.hint': 'اختر قناة إشعارات واحدة. يمكن تفعيل واحدة فقط في كل مرة.',
'admin.notifications.none': 'معطّل',
'admin.notifications.email': 'البريد الإلكتروني (SMTP)',
@@ -131,6 +133,8 @@ const admin: TranslationStrings = {
'admin.invite.createError': 'فشل إنشاء رابط الدعوة',
'admin.invite.deleteError': 'فشل حذف رابط الدعوة',
'admin.tabs.settings': 'الإعدادات',
'admin.tabs.security': 'الأمان',
'admin.tabs.services': 'الخدمات',
'admin.allowRegistration': 'السماح بالتسجيل',
'admin.allowRegistrationHint': 'يمكن للمستخدمين الجدد التسجيل بأنفسهم',
'admin.requireMfa': 'فرض المصادقة الثنائية (2FA)',
@@ -174,10 +178,8 @@ const admin: TranslationStrings = {
'admin.trekApi.included': 'مشمول',
'admin.trekApi.notIncluded': 'غير مشمول',
'admin.trekApi.quota.title': 'حصة اليوم',
'admin.trekApi.quota.used': 'المستخدَم',
'admin.trekApi.quota.remaining': 'المتبقي',
'admin.trekApi.quota.resets': 'إعادة الضبط عند',
'admin.trekApi.quota.limit': 'الحد',
'admin.trekApi.quota.key': 'المفتاح',
'admin.trekApi.quota.state.connected': 'متصل',
'admin.trekApi.quota.state.registering': 'جارٍ الإعداد',
@@ -297,8 +299,9 @@ const admin: TranslationStrings = {
'admin.collab.whatsnext.title': 'ما التالي',
'admin.collab.whatsnext.subtitle': 'اقتراحات الأنشطة والخطوات التالية',
'admin.tabs.config': 'التخصيص',
'admin.tabs.defaults': 'الإعدادات الافتراضية',
'admin.tabs.defaults': 'الافتراضيات',
'admin.defaultSettings.title': 'إعدادات المستخدم الافتراضية',
'admin.defaultSettings.routing': 'التوجيه',
'admin.defaultSettings.description':
'تعيين الإعدادات الافتراضية على مستوى النظام. سيرى المستخدمون الذين لم يغيروا إعدادًا هذه القيم. تحظى تغييراتهم دائمًا بالأولوية.',
'admin.defaultSettings.saved': 'تم حفظ الإعداد الافتراضي',
@@ -764,24 +767,6 @@ const admin: TranslationStrings = {
'admin.addons.toast.updated': 'تم تحديث الإضافة',
'admin.addons.toast.error': 'فشل تحديث الإضافة',
'admin.addons.group.count': '{enabled} من {total} مُفعّل',
'admin.addons.search': 'البحث في الإضافات',
'admin.addons.filter': 'عرض',
'admin.addons.filter.all': 'الكل',
'admin.addons.filter.on': 'مفعّلة',
'admin.addons.filter.off': 'معطّلة',
'admin.addons.noMatch': 'لا توجد إضافة تطابق البحث أو عامل التصفية.',
'admin.addons.subOptions': '{count} خيار',
'admin.addons.subOptions.zero': 'لا توجد خيارات',
'admin.addons.subOptions.one': 'خيار واحد',
'admin.addons.subOptions.two': 'خياران',
'admin.addons.subOptions.few': '{count} خيارات',
'admin.addons.subOptions.many': '{count} خيارًا',
'admin.addons.subOptionsOn': 'المُفعّل: {count}',
'admin.addons.subOptionsOn.zero': 'لا شيء مُفعّل',
'admin.addons.subOptionsOn.one': 'واحد مُفعّل',
'admin.addons.subOptionsOn.two': 'اثنان مُفعّلان',
'admin.addons.subOptionsOn.few': 'المُفعّل: {count}',
'admin.addons.subOptionsOn.many': 'المُفعّل: {count}',
'admin.toursWanderer.title': 'wanderer',
'admin.toursWanderer.subtitle': 'يمكن للمستخدمين ربط خادم wanderer الخاص بهم واستيراد دروبه كجولات',
'admin.toursHikingMap.title': 'خريطة المشي (OpenHikingMap)',
@@ -806,7 +791,7 @@ const admin: TranslationStrings = {
'admin.weather.requestsDesc': 'مجاني، بدون مفتاح API',
'admin.weather.locationHint':
'يعتمد الطقس على أول مكان بإحداثيات في كل يوم. إذا لم يكن هناك مكان مخصص ليوم ما، يُستخدم أي مكان من قائمة الأماكن كمرجع.',
'admin.tabs.mcpTokens': 'وصول MCP',
'admin.tabs.mcpTokens': 'MCP',
'admin.mcpTokens.title': 'وصول MCP',
'admin.mcpTokens.subtitle': 'إدارة جلسات OAuth ورموز API لجميع المستخدمين',
'admin.mcpTokens.sectionTitle': 'رموز API',
@@ -952,7 +937,6 @@ const admin: TranslationStrings = {
'يُستخدم لكل مستخدم لم يُدخل مفتاحه الخاص، حتى يحصل الخادم بأكمله على بلاطات CARTO بدون علامة مائية. يُخزَّن مشفّرًا.',
'admin.group.users': 'المستخدمون',
'admin.group.config': 'الإعدادات',
'admin.group.integration': 'التكاملات',
'admin.group.maintenance': 'الصيانة',
'admin.invite.tripLabel': 'إضافة إلى رحلة (اختياري)',
'admin.invite.tripNone': 'بدون رحلة',
-1
View File
@@ -37,7 +37,6 @@ const mailAdmin: TranslationStrings = {
'mailAdmin.problem.portInvalid': 'أدخل منفذاً بين 1 و65535.',
'mailAdmin.problem.templateToken': 'يحتاج العنوان إلى {token} في موضع رمز كل شخص.',
'mailAdmin.problem.dailyInvalid': 'أدخل عدداً صحيحاً من 0 إلى 1000.',
'mailAdmin.status.title': 'الحالة',
'mailAdmin.status.lastRead': 'آخر قراءة {time}',
'mailAdmin.status.never': 'أبداً',
'mailAdmin.status.mails': 'الرسائل، 24 ساعة',
+7 -15
View File
@@ -2,6 +2,8 @@ import type { TranslationStrings } from '../types';
const admin: TranslationStrings = {
'admin.notifications.title': 'Bildirişlər',
'admin.notifications.channels': 'Kanallar',
'admin.notifications.adminAlerts': 'Admin bildirişləri',
'admin.notifications.hint': 'Bir bildiriş kanalı seçin. Eyni anda yalnız biri aktiv ola bilər.',
'admin.notifications.none': 'Deaktivdir',
'admin.notifications.email': 'E-poçt (SMTP)',
@@ -130,6 +132,8 @@ const admin: TranslationStrings = {
'admin.invite.createError': 'Dəvət keçidi yaratmaq mümkün olmadı',
'admin.invite.deleteError': 'Dəvət keçidini silmək mümkün olmadı',
'admin.tabs.settings': 'Tənzimləmələr',
'admin.tabs.security': 'Təhlükəsizlik',
'admin.tabs.services': 'Xidmətlər',
'admin.allowRegistration': 'Qeydiyyata icazə ver',
'admin.allowRegistrationHint': 'Yeni istifadəçilər özləri qeydiyyatdan keçə bilərlər',
'admin.authMethods': 'Doğrulama üsulları',
@@ -191,10 +195,8 @@ const admin: TranslationStrings = {
'admin.trekApi.included': 'Daxildir',
'admin.trekApi.notIncluded': 'Daxil deyil',
'admin.trekApi.quota.title': 'Bugünkü limit',
'admin.trekApi.quota.used': 'İstifadə edilib',
'admin.trekApi.quota.remaining': 'Qalıb',
'admin.trekApi.quota.resets': 'Sıfırlanma vaxtı',
'admin.trekApi.quota.limit': 'Gündəlik limit',
'admin.trekApi.quota.key': 'Açar',
'admin.trekApi.quota.state.connected': 'Qoşulub',
'admin.trekApi.quota.state.registering': 'Qurulur',
@@ -320,8 +322,9 @@ const admin: TranslationStrings = {
'admin.collab.whatsnext.title': 'Növbədə nə var',
'admin.collab.whatsnext.subtitle': 'Fəaliyyət təklifləri və növbəti addımlar',
'admin.tabs.config': 'Fərdiləşdirmə',
'admin.tabs.defaults': 'İstifadəçi standartları',
'admin.tabs.defaults': 'Defoltlar',
'admin.defaultSettings.title': 'Standart istifadəçi tənzimləmələri',
'admin.defaultSettings.routing': 'Marşrutlar',
'admin.defaultSettings.description':
'Bütün sistem üçün standart dəyərləri təyin edin. Tənzimləməni dəyişməmiş istifadəçilər bu dəyərləri görəcəklər. Onların öz dəyişiklikləri həmişə üstün tutulur.',
'admin.defaultSettings.saved': 'Standart dəyər yadda saxlanıldı',
@@ -809,16 +812,6 @@ const admin: TranslationStrings = {
'admin.addons.toast.updated': 'Əlavə yeniləndi',
'admin.addons.toast.error': 'Əlavəni yeniləmək mümkün olmadı',
'admin.addons.group.count': '{total} əlavədən {enabled} aktivdir',
'admin.addons.search': 'Əlavələrdə axtar',
'admin.addons.filter': 'Göstər',
'admin.addons.filter.all': 'Hamısı',
'admin.addons.filter.on': 'Aktiv',
'admin.addons.filter.off': 'Deaktiv',
'admin.addons.noMatch': 'Axtarışa və ya filtrə uyğun əlavə yoxdur.',
'admin.addons.subOptions': '{count} seçim',
'admin.addons.subOptions.one': '{count} seçim',
'admin.addons.subOptionsOn': '{count} aktiv',
'admin.addons.subOptionsOn.one': '{count} aktiv',
'admin.toursWanderer.title': 'wanderer',
'admin.toursWanderer.subtitle':
'İstifadəçilər öz wanderer instansiyalarını qoşa və onun cığırlarını tur kimi idxal edə bilərlər',
@@ -844,7 +837,7 @@ const admin: TranslationStrings = {
'admin.weather.requestsDesc': 'Pulsuzdur, API açarı tələb olunmur',
'admin.weather.locationHint':
'Hava məlumatları hər gün üçün koordinatları olan ilk məkana əsaslanır. Günə heç bir məkan təyin edilməyibsə, məkan siyahısındakı istənilən məkan istinad kimi istifadə olunur.',
'admin.tabs.mcpTokens': 'MCP girişi',
'admin.tabs.mcpTokens': 'MCP',
'admin.mcpTokens.title': 'MCP girişi',
'admin.mcpTokens.subtitle': 'Bütün istifadəçilər üzrə OAuth sessiyalarını və API tokenlərini idarə edin',
'admin.mcpTokens.sectionTitle': 'API tokenləri',
@@ -949,7 +942,6 @@ const admin: TranslationStrings = {
'admin.passkey.resetDone.one': '{count} keçid açarı silindi',
'admin.group.users': 'İstifadəçilər',
'admin.group.config': 'Konfiqurasiya',
'admin.group.integration': 'İnteqrasiyalar',
'admin.group.maintenance': 'Texniki xidmət',
'admin.invite.tripLabel': 'Səyahətə əlavə et (istəyə bağlı)',
'admin.invite.tripNone': 'Səyahət yoxdur',
-1
View File
@@ -39,7 +39,6 @@ const mailAdmin: TranslationStrings = {
'mailAdmin.problem.portInvalid': '1 ilə 65535 arasında port daxil et.',
'mailAdmin.problem.templateToken': 'Ünvanda hər şəxsin kodu üçün {token} olmalıdır.',
'mailAdmin.problem.dailyInvalid': '0 ilə 1000 arasında tam ədəd daxil et.',
'mailAdmin.status.title': 'Status', // same-as-en
'mailAdmin.status.lastRead': 'Son oxuma {time}',
'mailAdmin.status.never': 'heç vaxt',
'mailAdmin.status.mails': 'Məktublar, 24 saat',
+7 -15
View File
@@ -2,6 +2,8 @@ import type { TranslationStrings } from '../types';
const admin: TranslationStrings = {
'admin.notifications.title': 'Notificações',
'admin.notifications.channels': 'Canais',
'admin.notifications.adminAlerts': 'Alertas do admin',
'admin.notifications.hint': 'Escolha um canal de notificação. Apenas um pode estar ativo por vez.',
'admin.notifications.none': 'Desativado',
'admin.notifications.email': 'E-mail (SMTP)',
@@ -74,6 +76,8 @@ const admin: TranslationStrings = {
'admin.invite.createError': 'Falha ao criar link de convite',
'admin.invite.deleteError': 'Falha ao excluir link de convite',
'admin.tabs.settings': 'Configurações',
'admin.tabs.security': 'Segurança',
'admin.tabs.services': 'Serviços',
'admin.allowRegistration': 'Permitir cadastro',
'admin.allowRegistrationHint': 'Novos usuários podem se cadastrar sozinhos',
'admin.authMethods': 'Métodos de autenticação',
@@ -135,10 +139,8 @@ const admin: TranslationStrings = {
'admin.trekApi.included': 'Incluído',
'admin.trekApi.notIncluded': 'Não incluído',
'admin.trekApi.quota.title': 'Cota de hoje',
'admin.trekApi.quota.used': 'Usado',
'admin.trekApi.quota.remaining': 'Restante',
'admin.trekApi.quota.resets': 'Reinicia às',
'admin.trekApi.quota.limit': 'Limite',
'admin.trekApi.quota.key': 'Chave',
'admin.trekApi.quota.state.connected': 'Conectado',
'admin.trekApi.quota.state.registering': 'Configurando',
@@ -261,8 +263,9 @@ const admin: TranslationStrings = {
'admin.collab.whatsnext.title': 'Próximos passos',
'admin.collab.whatsnext.subtitle': 'Sugestões de atividades e próximos passos',
'admin.tabs.config': 'Personalização',
'admin.tabs.defaults': 'Padrões do usuário',
'admin.tabs.defaults': 'Padrões',
'admin.defaultSettings.title': 'Configurações padrão do usuário',
'admin.defaultSettings.routing': 'Rotas',
'admin.defaultSettings.description':
'Defina padrões para toda a instância. Usuários que não alteraram uma configuração verão esses valores. As próprias alterações deles sempre têm prioridade.',
'admin.defaultSettings.saved': 'Padrão salvo',
@@ -718,16 +721,6 @@ const admin: TranslationStrings = {
'admin.addons.toast.updated': 'Complemento atualizado',
'admin.addons.toast.error': 'Falha ao atualizar complemento',
'admin.addons.group.count': '{enabled} de {total} ativados',
'admin.addons.search': 'Pesquisar complementos',
'admin.addons.filter': 'Mostrar',
'admin.addons.filter.all': 'Todos',
'admin.addons.filter.on': 'Ativos',
'admin.addons.filter.off': 'Inativos',
'admin.addons.noMatch': 'Nenhum complemento corresponde à pesquisa ou ao filtro.',
'admin.addons.subOptions': '{count} opções',
'admin.addons.subOptions.one': '{count} opção',
'admin.addons.subOptionsOn': '{count} ativas',
'admin.addons.subOptionsOn.one': '{count} ativa',
'admin.toursWanderer.title': 'wanderer',
'admin.toursWanderer.subtitle':
'Os usuários podem conectar a própria instância do wanderer e importar as trilhas dela como passeios',
@@ -802,7 +795,7 @@ const admin: TranslationStrings = {
'admin.update.wikiLink': 'Abrir o guia de atualização',
'admin.update.reloadHint': 'Recarregue a página em alguns segundos.',
'admin.tabs.permissions': 'Permissões',
'admin.tabs.mcpTokens': 'Acesso MCP',
'admin.tabs.mcpTokens': 'MCP',
'admin.mcpTokens.title': 'Acesso MCP',
'admin.mcpTokens.subtitle': 'Gerenciar sessões OAuth e tokens de API de todos os usuários',
'admin.mcpTokens.sectionTitle': 'Tokens de API',
@@ -924,7 +917,6 @@ const admin: TranslationStrings = {
"Usada para todos os usuários que não inseriram a própria chave, assim toda a instância recebe blocos do CARTO sem marca d'água. Armazenada de forma criptografada.",
'admin.group.users': 'Usuários',
'admin.group.config': 'Configuração',
'admin.group.integration': 'Integrações',
'admin.group.maintenance': 'Manutenção',
'admin.invite.tripLabel': 'Adicionar à viagem (opcional)',
'admin.invite.tripNone': 'Nenhuma viagem',
-1
View File
@@ -39,7 +39,6 @@ const mailAdmin: TranslationStrings = {
'mailAdmin.problem.portInvalid': 'Digite uma porta entre 1 e 65535.',
'mailAdmin.problem.templateToken': 'O endereço precisa de {token} para o código de cada pessoa.',
'mailAdmin.problem.dailyInvalid': 'Digite um número inteiro de 0 a 1000.',
'mailAdmin.status.title': 'Status', // same-as-en
'mailAdmin.status.lastRead': 'Última leitura {time}',
'mailAdmin.status.never': 'nunca',
'mailAdmin.status.mails': 'E-mails, 24 h',
+7 -15
View File
@@ -2,6 +2,8 @@ import type { TranslationStrings } from '../types';
const admin: TranslationStrings = {
'admin.notifications.title': 'Notificacions',
'admin.notifications.channels': 'Canals',
'admin.notifications.adminAlerts': 'Avisos d’administració',
'admin.notifications.hint': 'Tria un canal per rebre notificacions. Només un pot estar actiu alhora.',
'admin.notifications.none': 'Desactivat',
'admin.notifications.email': 'Correu (SMTP)',
@@ -80,6 +82,8 @@ const admin: TranslationStrings = {
'admin.invite.createError': "Error en crear l'enllaç",
'admin.invite.deleteError': "Error en eliminar l'enllaç",
'admin.tabs.settings': 'Configuració',
'admin.tabs.security': 'Seguretat',
'admin.tabs.services': 'Serveis',
'admin.allowRegistration': 'Permetre el registre',
'admin.allowRegistrationHint': 'Els nous usuaris es poden registrar per si mateixos',
'admin.authMethods': "Mètodes d'autenticació",
@@ -141,10 +145,8 @@ const admin: TranslationStrings = {
'admin.trekApi.included': 'Inclòs',
'admin.trekApi.notIncluded': 'No inclòs',
'admin.trekApi.quota.title': "Quota d'avui",
'admin.trekApi.quota.used': 'Utilitzat',
'admin.trekApi.quota.remaining': 'Restant',
'admin.trekApi.quota.resets': 'Es reinicia a les',
'admin.trekApi.quota.limit': 'Límit',
'admin.trekApi.quota.key': 'Clau',
'admin.trekApi.quota.state.connected': 'Connectat',
'admin.trekApi.quota.state.registering': "S'està configurant",
@@ -250,8 +252,9 @@ const admin: TranslationStrings = {
'admin.collab.whatsnext.title': 'Què ve després',
'admin.collab.whatsnext.subtitle': "Suggeriments d'activitats i propers passos",
'admin.tabs.config': 'Personalització',
'admin.tabs.defaults': 'Valors per defecte',
'admin.tabs.defaults': 'Predeterminats',
'admin.defaultSettings.title': 'Configuració per defecte dels usuaris',
'admin.defaultSettings.routing': 'Rutes',
'admin.defaultSettings.description':
'Estableix valors per defecte per a tota la instància. Els usuaris que no hagin canviat una opció veuran aquests valors. Els seus propis canvis sempre tenen prioritat.',
'admin.defaultSettings.saved': 'Per defecte desat',
@@ -314,16 +317,6 @@ const admin: TranslationStrings = {
'admin.addons.toast.updated': 'Complement actualitzat',
'admin.addons.toast.error': "No s'ha pogut actualitzar el complement",
'admin.addons.group.count': '{enabled} de {total} actius',
'admin.addons.search': 'Cerca complements',
'admin.addons.filter': 'Mostra',
'admin.addons.filter.all': 'Tots',
'admin.addons.filter.on': 'Actius',
'admin.addons.filter.off': 'Inactius',
'admin.addons.noMatch': 'Cap complement coincideix amb la cerca o el filtre.',
'admin.addons.subOptions': '{count} opcions',
'admin.addons.subOptions.one': '{count} opció',
'admin.addons.subOptionsOn': '{count} actives',
'admin.addons.subOptionsOn.one': '{count} activa',
'admin.toursWanderer.title': 'wanderer',
'admin.toursWanderer.subtitle':
'Els usuaris poden connectar la seva pròpia instància de wanderer i importar-ne els senders com a excursions',
@@ -349,7 +342,7 @@ const admin: TranslationStrings = {
'admin.weather.requestsDesc': 'Gratuït, sense necessitat de clau API',
'admin.weather.locationHint':
'El temps es basa en el primer lloc amb coordenades de cada dia. Si no hi ha cap lloc assignat a un dia, es fa servir com a referència qualsevol lloc de la llista.',
'admin.tabs.mcpTokens': 'Accés MCP',
'admin.tabs.mcpTokens': 'MCP',
'admin.mcpTokens.title': 'Accés MCP',
'admin.mcpTokens.subtitle': "Gestionar sessions OAuth i tokens d'API de tots els usuaris",
'admin.mcpTokens.sectionTitle': "Tokens d'API",
@@ -453,7 +446,6 @@ const admin: TranslationStrings = {
'admin.tabs.permissions': 'Permisos',
'admin.group.users': 'Usuaris',
'admin.group.config': 'Configuració',
'admin.group.integration': 'Integracions',
'admin.group.maintenance': 'Manteniment',
'admin.notifications.emailPanel.title': 'Correu (SMTP)',
'admin.notifications.webhookPanel.title': 'Webhook',
-1
View File
@@ -39,7 +39,6 @@ const mailAdmin: TranslationStrings = {
'mailAdmin.problem.portInvalid': 'Introdueix un port entre 1 i 65535.',
'mailAdmin.problem.templateToken': 'L’adreça necessita {token} per al codi de cada persona.',
'mailAdmin.problem.dailyInvalid': 'Introdueix un nombre enter de 0 a 1000.',
'mailAdmin.status.title': 'Estat',
'mailAdmin.status.lastRead': 'Darrera lectura {time}',
'mailAdmin.status.never': 'mai',
'mailAdmin.status.mails': 'Correus, 24 h',
+7 -17
View File
@@ -2,6 +2,8 @@ import type { TranslationStrings } from '../types';
const admin: TranslationStrings = {
'admin.notifications.title': 'Oznámení',
'admin.notifications.channels': 'Kanály',
'admin.notifications.adminAlerts': 'Upozornění pro správce',
'admin.notifications.hint': 'Vyberte kanál oznámení. Současně může být aktivní pouze jeden.',
'admin.notifications.none': 'Vypnuto',
'admin.notifications.email': 'E-mail (SMTP)',
@@ -78,6 +80,8 @@ const admin: TranslationStrings = {
'admin.invite.createError': 'Nepodařilo se vytvořit pozvánku',
'admin.invite.deleteError': 'Nepodařilo se smazat pozvánku',
'admin.tabs.settings': 'Nastavení',
'admin.tabs.security': 'Zabezpečení',
'admin.tabs.services': 'Služby',
'admin.allowRegistration': 'Povolit registraci',
'admin.allowRegistrationHint': 'Noví uživatelé se mohou sami registrovat',
'admin.authMethods': 'Metody ověřování',
@@ -136,10 +140,8 @@ const admin: TranslationStrings = {
'admin.trekApi.included': 'Obsahuje',
'admin.trekApi.notIncluded': 'Neobsahuje',
'admin.trekApi.quota.title': 'Dnešní limit',
'admin.trekApi.quota.used': 'Využito',
'admin.trekApi.quota.remaining': 'Zbývá',
'admin.trekApi.quota.resets': 'Obnoví se v',
'admin.trekApi.quota.limit': 'Denní limit',
'admin.trekApi.quota.key': 'Klíč',
'admin.trekApi.quota.state.connected': 'Připojeno',
'admin.trekApi.quota.state.registering': 'Nastavuje se',
@@ -262,8 +264,9 @@ const admin: TranslationStrings = {
'admin.collab.whatsnext.title': 'Co dál',
'admin.collab.whatsnext.subtitle': 'Návrhy aktivit a další kroky',
'admin.tabs.config': 'Personalizace',
'admin.tabs.defaults': 'Výchozí nastavení uživatele',
'admin.tabs.defaults': 'Výchozí',
'admin.defaultSettings.title': 'Výchozí nastavení uživatele',
'admin.defaultSettings.routing': 'Trasy',
'admin.defaultSettings.description':
'Nastavte výchozí hodnoty pro celou instanci. Uživatelé, kteří nezměnili nastavení, uvidí tyto hodnoty. Jejich vlastní změny mají vždy přednost.',
'admin.defaultSettings.saved': 'Výchozí nastavení uloženo',
@@ -717,18 +720,6 @@ const admin: TranslationStrings = {
'admin.addons.toast.updated': 'Doplněk byl aktualizován',
'admin.addons.toast.error': 'Aktualizace doplňku se nezdařila',
'admin.addons.group.count': '{enabled} z {total} zapnuto',
'admin.addons.search': 'Hledat doplňky',
'admin.addons.filter': 'Zobrazit',
'admin.addons.filter.all': 'Vše',
'admin.addons.filter.on': 'Zapnuté',
'admin.addons.filter.off': 'Vypnuté',
'admin.addons.noMatch': 'Hledání ani filtru neodpovídá žádný doplněk.',
'admin.addons.subOptions': '{count} možností',
'admin.addons.subOptions.one': '{count} možnost',
'admin.addons.subOptions.few': '{count} možnosti',
'admin.addons.subOptionsOn': 'zapnuto: {count}',
'admin.addons.subOptionsOn.one': 'zapnuto: {count}',
'admin.addons.subOptionsOn.few': 'zapnuto: {count}',
'admin.toursWanderer.title': 'wanderer',
'admin.toursWanderer.subtitle':
'Uživatelé mohou připojit vlastní instanci wanderer a importovat z ní trasy jako výlety',
@@ -760,7 +751,7 @@ const admin: TranslationStrings = {
'admin.audit.col.resource': 'Zdroj',
'admin.audit.col.ip': 'IP',
'admin.audit.col.details': 'Detaily',
'admin.tabs.mcpTokens': 'MCP přístup',
'admin.tabs.mcpTokens': 'MCP',
'admin.mcpTokens.title': 'MCP přístup',
'admin.mcpTokens.subtitle': 'Správa OAuth relací a API tokenů všech uživatelů',
'admin.mcpTokens.sectionTitle': 'API tokeny',
@@ -928,7 +919,6 @@ const admin: TranslationStrings = {
'Použije se pro každého uživatele, který nezadal vlastní klíč, takže celá instance získá dlaždice CARTO bez vodoznaku. Ukládá se šifrovaně.',
'admin.group.users': 'Uživatelé',
'admin.group.config': 'Konfigurace',
'admin.group.integration': 'Integrace',
'admin.group.maintenance': 'Údržba',
'admin.invite.tripLabel': 'Přidat k cestě (volitelné)',
'admin.invite.tripNone': 'Žádná cesta',
-1
View File
@@ -39,7 +39,6 @@ const mailAdmin: TranslationStrings = {
'mailAdmin.problem.portInvalid': 'Zadej port mezi 1 a 65535.',
'mailAdmin.problem.templateToken': 'Adresa musí obsahovat {token} pro kód každého uživatele.',
'mailAdmin.problem.dailyInvalid': 'Zadej celé číslo od 0 do 1000.',
'mailAdmin.status.title': 'Stav',
'mailAdmin.status.lastRead': 'Naposledy přečteno {time}',
'mailAdmin.status.never': 'nikdy',
'mailAdmin.status.mails': 'E-maily, 24 h',
+7 -15
View File
@@ -2,6 +2,8 @@ import type { TranslationStrings } from '../types';
const admin: TranslationStrings = {
'admin.notifications.title': 'Benachrichtigungen',
'admin.notifications.channels': 'Kanäle',
'admin.notifications.adminAlerts': 'Admin-Meldungen',
'admin.notifications.hint': 'Wählen Sie einen Benachrichtigungskanal. Es kann nur einer gleichzeitig aktiv sein.',
'admin.notifications.none': 'Deaktiviert',
'admin.notifications.email': 'E-Mail (SMTP)',
@@ -75,6 +77,8 @@ const admin: TranslationStrings = {
'admin.invite.createError': 'Fehler beim Erstellen des Einladungslinks',
'admin.invite.deleteError': 'Fehler beim Löschen des Einladungslinks',
'admin.tabs.settings': 'Einstellungen',
'admin.tabs.security': 'Sicherheit',
'admin.tabs.services': 'Dienste',
'admin.allowRegistration': 'Registrierung erlauben',
'admin.allowRegistrationHint': 'Neue Benutzer können sich selbst registrieren',
'admin.authMethods': 'Anmeldemethoden',
@@ -137,10 +141,8 @@ const admin: TranslationStrings = {
'admin.trekApi.included': 'Enthalten',
'admin.trekApi.notIncluded': 'Nicht enthalten',
'admin.trekApi.quota.title': 'Kontingent heute',
'admin.trekApi.quota.used': 'Verbraucht',
'admin.trekApi.quota.remaining': 'Verbleibend',
'admin.trekApi.quota.resets': 'Zurückgesetzt um',
'admin.trekApi.quota.limit': 'Tageslimit',
'admin.trekApi.quota.key': 'Schlüssel',
'admin.trekApi.quota.state.connected': 'Verbunden',
'admin.trekApi.quota.state.registering': 'Wird eingerichtet',
@@ -265,8 +267,9 @@ const admin: TranslationStrings = {
'admin.collab.whatsnext.title': 'Was kommt als Nächstes',
'admin.collab.whatsnext.subtitle': 'Aktivitätsvorschläge und nächste Schritte',
'admin.tabs.config': 'Personalisierung',
'admin.tabs.defaults': 'Benutzer-Standards',
'admin.tabs.defaults': 'Standards',
'admin.defaultSettings.title': 'Standard-Benutzereinstellungen',
'admin.defaultSettings.routing': 'Routing',
'admin.defaultSettings.description':
'Instanzweite Standards festlegen. Benutzer, die eine Einstellung nicht geändert haben, sehen diese Werte. Eigene Änderungen haben immer Vorrang.',
'admin.defaultSettings.saved': 'Standard gespeichert',
@@ -727,16 +730,6 @@ const admin: TranslationStrings = {
'admin.addons.toast.updated': 'Addon aktualisiert',
'admin.addons.toast.error': 'Addon konnte nicht aktualisiert werden',
'admin.addons.group.count': '{enabled} von {total} aktiviert',
'admin.addons.search': 'Addons durchsuchen',
'admin.addons.filter': 'Anzeigen',
'admin.addons.filter.all': 'Alle',
'admin.addons.filter.on': 'An',
'admin.addons.filter.off': 'Aus',
'admin.addons.noMatch': 'Kein Addon passt zur Suche oder zum Filter.',
'admin.addons.subOptions': '{count} Optionen',
'admin.addons.subOptions.one': '{count} Option',
'admin.addons.subOptionsOn': '{count} aktiv',
'admin.addons.subOptionsOn.one': '{count} aktiv',
'admin.toursWanderer.title': 'wanderer',
'admin.toursWanderer.subtitle':
'Nutzer können ihre eigene wanderer-Instanz verbinden und deren Trails als Touren importieren',
@@ -762,7 +755,7 @@ const admin: TranslationStrings = {
'admin.weather.requestsDesc': 'Kostenlos, kein API-Schlüssel erforderlich',
'admin.weather.locationHint':
'Das Wetter wird anhand des ersten Ortes mit Koordinaten im jeweiligen Tag berechnet. Ist kein Ort am Tag eingeplant, wird ein beliebiger Ort aus der Ortsliste als Referenz verwendet.',
'admin.tabs.mcpTokens': 'MCP-Zugang',
'admin.tabs.mcpTokens': 'MCP',
'admin.mcpTokens.title': 'MCP-Zugang',
'admin.mcpTokens.subtitle': 'OAuth-Sitzungen und API-Tokens aller Benutzer verwalten',
'admin.mcpTokens.sectionTitle': 'API-Tokens',
@@ -933,7 +926,6 @@ const admin: TranslationStrings = {
'Wird für jeden Nutzer verwendet, der keinen eigenen Key eingetragen hat, damit die ganze Instanz CARTO-Kacheln ohne Wasserzeichen bekommt. Verschlüsselt gespeichert.',
'admin.group.users': 'Benutzer',
'admin.group.config': 'Konfiguration',
'admin.group.integration': 'Integrationen',
'admin.group.maintenance': 'Wartung',
'admin.invite.tripLabel': 'Zu Trip hinzufügen (optional)',
'admin.invite.tripNone': 'Kein Trip',
-1
View File
@@ -39,7 +39,6 @@ const mailAdmin: TranslationStrings = {
'mailAdmin.problem.portInvalid': 'Gib einen Port zwischen 1 und 65535 ein.',
'mailAdmin.problem.templateToken': 'Die Adresse braucht {token} für den persönlichen Code.',
'mailAdmin.problem.dailyInvalid': 'Gib eine ganze Zahl von 0 bis 1000 ein.',
'mailAdmin.status.title': 'Status', // same-as-en
'mailAdmin.status.lastRead': 'Zuletzt gelesen {time}',
'mailAdmin.status.never': 'noch nie',
'mailAdmin.status.mails': 'Mails, 24 Std.',
+7 -15
View File
@@ -2,6 +2,8 @@ import type { TranslationStrings } from '../types';
const admin: TranslationStrings = {
'admin.notifications.title': 'Notifications',
'admin.notifications.channels': 'Channels',
'admin.notifications.adminAlerts': 'Admin alerts',
'admin.notifications.hint': 'Choose one notification channel. Only one can be active at a time.',
'admin.notifications.none': 'Disabled',
'admin.notifications.email': 'Email (SMTP)',
@@ -126,6 +128,8 @@ const admin: TranslationStrings = {
'admin.invite.createError': 'Failed to create invite link',
'admin.invite.deleteError': 'Failed to delete invite link',
'admin.tabs.settings': 'Settings',
'admin.tabs.security': 'Security',
'admin.tabs.services': 'Services',
'admin.allowRegistration': 'Allow Registration',
'admin.allowRegistrationHint': 'New users can register themselves',
'admin.authMethods': 'Authentication Methods',
@@ -186,10 +190,8 @@ const admin: TranslationStrings = {
'admin.trekApi.included': 'Included',
'admin.trekApi.notIncluded': 'Not included',
'admin.trekApi.quota.title': "Today's allowance",
'admin.trekApi.quota.used': 'Used',
'admin.trekApi.quota.remaining': 'Remaining',
'admin.trekApi.quota.resets': 'Resets at',
'admin.trekApi.quota.limit': 'Daily limit',
'admin.trekApi.quota.key': 'Key',
'admin.trekApi.quota.state.connected': 'Connected',
'admin.trekApi.quota.state.registering': 'Setting up',
@@ -311,8 +313,9 @@ const admin: TranslationStrings = {
'admin.collab.whatsnext.title': "What's Next",
'admin.collab.whatsnext.subtitle': 'Activity suggestions and next steps',
'admin.tabs.config': 'Personalization',
'admin.tabs.defaults': 'User Defaults',
'admin.tabs.defaults': 'Defaults',
'admin.defaultSettings.title': 'Default User Settings',
'admin.defaultSettings.routing': 'Routing',
'admin.defaultSettings.description':
'Set instance-wide defaults. Users who have not changed a setting will see these values. Their own changes always take priority.',
'admin.defaultSettings.saved': 'Default saved',
@@ -770,16 +773,6 @@ const admin: TranslationStrings = {
'admin.addons.toast.updated': 'Addon updated',
'admin.addons.toast.error': 'Failed to update addon',
'admin.addons.group.count': '{enabled} of {total} enabled',
'admin.addons.search': 'Search addons',
'admin.addons.filter': 'Show',
'admin.addons.filter.all': 'All',
'admin.addons.filter.on': 'On',
'admin.addons.filter.off': 'Off',
'admin.addons.noMatch': 'No addon matches the search or the filter.',
'admin.addons.subOptions': '{count} options',
'admin.addons.subOptions.one': '{count} option',
'admin.addons.subOptionsOn': '{count} on',
'admin.addons.subOptionsOn.one': '{count} on',
'admin.toursWanderer.title': 'wanderer',
'admin.toursWanderer.subtitle': 'Users can connect their own wanderer instance and import its trails as tours',
'admin.toursHikingMap.title': 'Hiking map (OpenHikingMap)',
@@ -804,7 +797,7 @@ const admin: TranslationStrings = {
'admin.weather.requestsDesc': 'Free, no API key required',
'admin.weather.locationHint':
'Weather is based on the first place with coordinates in each day. If no place is assigned to a day, any place from the place list is used as a reference.',
'admin.tabs.mcpTokens': 'MCP Access',
'admin.tabs.mcpTokens': 'MCP',
'admin.mcpTokens.title': 'MCP Access',
'admin.mcpTokens.subtitle': 'Manage OAuth sessions and API tokens across all users',
'admin.mcpTokens.sectionTitle': 'API Tokens',
@@ -904,7 +897,6 @@ const admin: TranslationStrings = {
'admin.passkey.resetDone.one': 'Removed {count} passkey',
'admin.group.users': 'Users',
'admin.group.config': 'Configuration',
'admin.group.integration': 'Integrations',
'admin.group.maintenance': 'Maintenance',
'admin.invite.tripLabel': 'Add to trip (optional)',
'admin.invite.tripNone': 'No trip',
-1
View File
@@ -38,7 +38,6 @@ const mailAdmin: TranslationStrings = {
'mailAdmin.problem.portInvalid': 'Enter a port between 1 and 65535.',
'mailAdmin.problem.templateToken': 'The address needs {token} for each person’s code.',
'mailAdmin.problem.dailyInvalid': 'Enter a whole number from 0 to 1000.',
'mailAdmin.status.title': 'Status',
'mailAdmin.status.lastRead': 'Last read {time}',
'mailAdmin.status.never': 'never',
'mailAdmin.status.mails': 'Mails, 24 h',
+8 -16
View File
@@ -2,6 +2,8 @@ import type { TranslationStrings } from '../types';
const admin: TranslationStrings = {
'admin.notifications.title': 'Notificaciones',
'admin.notifications.channels': 'Canales',
'admin.notifications.adminAlerts': 'Avisos de administración',
'admin.notifications.hint': 'Elija un canal de notificación. Solo uno puede estar activo a la vez.',
'admin.notifications.none': 'Desactivado',
'admin.notifications.email': 'Correo (SMTP)',
@@ -21,7 +23,7 @@ const admin: TranslationStrings = {
'admin.subtitle': 'Gestión de usuarios y ajustes del sistema',
'admin.tabs.users': 'Usuarios',
'admin.tabs.categories': 'Categorías',
'admin.tabs.backup': 'Copia de seguridad',
'admin.tabs.backup': 'Respaldo',
'admin.tabs.audit': 'Auditoría',
'admin.stats.users': 'Usuarios',
'admin.stats.users.one': 'Usuario',
@@ -75,6 +77,8 @@ const admin: TranslationStrings = {
'admin.invite.createError': 'Error al crear el enlace',
'admin.invite.deleteError': 'Error al eliminar el enlace',
'admin.tabs.settings': 'Ajustes',
'admin.tabs.security': 'Seguridad',
'admin.tabs.services': 'Servicios',
'admin.allowRegistration': 'Permitir el registro',
'admin.allowRegistrationHint': 'Los nuevos usuarios pueden registrarse por sí mismos',
'admin.authMethods': 'Métodos de autenticación',
@@ -136,10 +140,8 @@ const admin: TranslationStrings = {
'admin.trekApi.included': 'Incluido',
'admin.trekApi.notIncluded': 'No incluido',
'admin.trekApi.quota.title': 'Cupo de hoy',
'admin.trekApi.quota.used': 'Usado',
'admin.trekApi.quota.remaining': 'Restante',
'admin.trekApi.quota.resets': 'Se reinicia a las',
'admin.trekApi.quota.limit': 'Límite',
'admin.trekApi.quota.key': 'Clave',
'admin.trekApi.quota.state.connected': 'Conectado',
'admin.trekApi.quota.state.registering': 'Configurando',
@@ -262,8 +264,9 @@ const admin: TranslationStrings = {
'admin.collab.whatsnext.title': 'Qué sigue',
'admin.collab.whatsnext.subtitle': 'Sugerencias de actividades y próximos pasos',
'admin.tabs.config': 'Personalización',
'admin.tabs.defaults': 'Valores predeterminados',
'admin.tabs.defaults': 'Predeterminados',
'admin.defaultSettings.title': 'Configuración predeterminada de usuarios',
'admin.defaultSettings.routing': 'Rutas',
'admin.defaultSettings.description':
'Establece valores predeterminados para toda la instancia. Los usuarios que no hayan cambiado una opción verán estos valores. Sus propios cambios siempre tienen prioridad.',
'admin.defaultSettings.saved': 'Predeterminado guardado',
@@ -706,16 +709,6 @@ const admin: TranslationStrings = {
'admin.addons.toast.updated': 'Complemento actualizado',
'admin.addons.toast.error': 'No se pudo actualizar el complemento',
'admin.addons.group.count': '{enabled} de {total} activados',
'admin.addons.search': 'Buscar complementos',
'admin.addons.filter': 'Mostrar',
'admin.addons.filter.all': 'Todos',
'admin.addons.filter.on': 'Activos',
'admin.addons.filter.off': 'Inactivos',
'admin.addons.noMatch': 'Ningún complemento coincide con la búsqueda o el filtro.',
'admin.addons.subOptions': '{count} opciones',
'admin.addons.subOptions.one': '{count} opción',
'admin.addons.subOptionsOn': '{count} activas',
'admin.addons.subOptionsOn.one': '{count} activa',
'admin.toursWanderer.title': 'wanderer',
'admin.toursWanderer.subtitle':
'Los usuarios pueden conectar su propia instancia de wanderer e importar sus senderos como excursiones',
@@ -741,7 +734,7 @@ const admin: TranslationStrings = {
'admin.weather.requestsDesc': 'Gratis, sin necesidad de clave API',
'admin.weather.locationHint':
'El tiempo se basa en el primer lugar con coordenadas de cada día. Si no hay ningún lugar asignado a un día, se usa como referencia cualquier lugar de la lista.',
'admin.tabs.mcpTokens': 'Acceso MCP',
'admin.tabs.mcpTokens': 'MCP',
'admin.mcpTokens.title': 'Acceso MCP',
'admin.mcpTokens.subtitle': 'Gestionar sesiones OAuth y tokens de API de todos los usuarios',
'admin.mcpTokens.sectionTitle': 'Tokens de API',
@@ -940,7 +933,6 @@ const admin: TranslationStrings = {
'Se usa para cada usuario que no haya introducido su propia clave, de modo que toda la instancia obtenga teselas de CARTO sin marca de agua. Se almacena cifrada.',
'admin.group.users': 'Usuarios',
'admin.group.config': 'Configuración',
'admin.group.integration': 'Integraciones',
'admin.group.maintenance': 'Mantenimiento',
'admin.invite.tripLabel': 'Añadir a un viaje (opcional)',
'admin.invite.tripNone': 'Sin viaje',
-1
View File
@@ -39,7 +39,6 @@ const mailAdmin: TranslationStrings = {
'mailAdmin.problem.portInvalid': 'Introduce un puerto entre 1 y 65535.',
'mailAdmin.problem.templateToken': 'La dirección necesita {token} para el código de cada persona.',
'mailAdmin.problem.dailyInvalid': 'Introduce un número entero de 0 a 1000.',
'mailAdmin.status.title': 'Estado',
'mailAdmin.status.lastRead': 'Última lectura {time}',
'mailAdmin.status.never': 'nunca',
'mailAdmin.status.mails': 'Correos, 24 h',
+7 -15
View File
@@ -2,6 +2,8 @@ import type { TranslationStrings } from '../types';
const admin: TranslationStrings = {
'admin.notifications.title': 'Teavitused',
'admin.notifications.channels': 'Kanalid',
'admin.notifications.adminAlerts': 'Halduri teavitused',
'admin.notifications.hint': 'Vali üks teavituskanal. Korraga saab aktiivne olla ainult üks kanal.',
'admin.notifications.none': 'Keelatud',
'admin.notifications.email': 'E-post (SMTP)',
@@ -128,6 +130,8 @@ const admin: TranslationStrings = {
'admin.invite.createError': 'Kutselingi loomine ebaõnnestus',
'admin.invite.deleteError': 'Kutselingi kustutamine ebaõnnestus',
'admin.tabs.settings': 'Seaded',
'admin.tabs.security': 'Turvalisus',
'admin.tabs.services': 'Teenused',
'admin.allowRegistration': 'Luba registreerumine',
'admin.allowRegistrationHint': 'Uued kasutajad saavad ise registreeruda',
'admin.authMethods': 'Autentimisviisid',
@@ -188,10 +192,8 @@ const admin: TranslationStrings = {
'admin.trekApi.included': 'Olemas',
'admin.trekApi.notIncluded': 'Puudub',
'admin.trekApi.quota.title': 'Tänane kvoot',
'admin.trekApi.quota.used': 'Kasutatud',
'admin.trekApi.quota.remaining': 'Järel',
'admin.trekApi.quota.resets': 'Lähtestub kell',
'admin.trekApi.quota.limit': 'Limiit',
'admin.trekApi.quota.key': 'Võti',
'admin.trekApi.quota.state.connected': 'Ühendatud',
'admin.trekApi.quota.state.registering': 'Seadistamisel',
@@ -314,8 +316,9 @@ const admin: TranslationStrings = {
'admin.collab.whatsnext.title': 'Mis järgmiseks',
'admin.collab.whatsnext.subtitle': 'Tegevussoovitused ja järgmised sammud',
'admin.tabs.config': 'Isikupärastamine',
'admin.tabs.defaults': 'Kasutaja vaikevalikud',
'admin.tabs.defaults': 'Vaikeväärtused',
'admin.defaultSettings.title': 'Kasutajate vaikeseaded',
'admin.defaultSettings.routing': 'Marsruudid',
'admin.defaultSettings.description':
'Määra kogu paigalduse vaikeseaded. Neid väärtusi näevad kasutajad, kes pole vastavat seadet muutnud. Kasutaja enda muudatused on alati ülimuslikud.',
'admin.defaultSettings.saved': 'Vaikeväärtus salvestatud',
@@ -788,16 +791,6 @@ const admin: TranslationStrings = {
'admin.addons.toast.updated': 'Lisamoodul uuendatud',
'admin.addons.toast.error': 'Lisamooduli uuendamine ebaõnnestus',
'admin.addons.group.count': 'Lubatud {enabled}/{total}',
'admin.addons.search': 'Otsi lisasid',
'admin.addons.filter': 'Näita',
'admin.addons.filter.all': 'Kõik',
'admin.addons.filter.on': 'Sees',
'admin.addons.filter.off': 'Väljas',
'admin.addons.noMatch': 'Otsingule ega filtrile ei vasta ükski lisa.',
'admin.addons.subOptions': '{count} valikut',
'admin.addons.subOptions.one': '{count} valik',
'admin.addons.subOptionsOn': '{count} lubatud',
'admin.addons.subOptionsOn.one': '{count} lubatud',
'admin.toursWanderer.title': 'wanderer',
'admin.toursWanderer.subtitle':
'Kasutajad saavad ühendada oma wanderer-serveri ja importida selle matkarajad retkedena',
@@ -824,7 +817,7 @@ const admin: TranslationStrings = {
'admin.weather.requestsDesc': 'Tasuta, API-võtit pole vaja',
'admin.weather.locationHint':
'Ilm põhineb iga päeva esimesel koordinaatidega kohal. Kui päevale pole ühtegi kohta määratud, kasutatakse võrdluskohana mõnda kohta kohtade nimekirjast.',
'admin.tabs.mcpTokens': 'MCP juurdepääs',
'admin.tabs.mcpTokens': 'MCP',
'admin.mcpTokens.title': 'MCP juurdepääs',
'admin.mcpTokens.subtitle': 'Halda kõigi kasutajate OAuth-seansse ja API-pääsutõendeid',
'admin.mcpTokens.sectionTitle': 'API-pääsutõendid',
@@ -926,7 +919,6 @@ const admin: TranslationStrings = {
'admin.passkey.resetDone.one': 'Eemaldatud {count} pääsuvõti',
'admin.group.users': 'Kasutajad',
'admin.group.config': 'Seadistus',
'admin.group.integration': 'Integratsioonid',
'admin.group.maintenance': 'Hooldus',
'admin.invite.tripLabel': 'Lisa reisile (valikuline)',
'admin.invite.tripNone': 'Reis puudub',
-1
View File
@@ -39,7 +39,6 @@ const mailAdmin: TranslationStrings = {
'mailAdmin.problem.portInvalid': 'Sisesta port vahemikus 1 kuni 65535.',
'mailAdmin.problem.templateToken': 'Aadressis peab iga inimese koodi jaoks olema {token}.',
'mailAdmin.problem.dailyInvalid': 'Sisesta täisarv vahemikus 0 kuni 1000.',
'mailAdmin.status.title': 'Olek',
'mailAdmin.status.lastRead': 'Viimati loetud {time}',
'mailAdmin.status.never': 'mitte kunagi',
'mailAdmin.status.mails': 'Kirjad, 24 t',
+7 -15
View File
@@ -2,6 +2,8 @@ import type { TranslationStrings } from '../types';
const admin: TranslationStrings = {
'admin.notifications.title': 'Notifications', // same-as-en
'admin.notifications.channels': 'Canaux',
'admin.notifications.adminAlerts': 'Alertes admin',
'admin.notifications.hint': 'Choisissez un canal de notification. Un seul peut être actif à la fois.',
'admin.notifications.none': 'Désactivé',
'admin.notifications.email': 'E-mail (SMTP)',
@@ -74,6 +76,8 @@ const admin: TranslationStrings = {
'admin.invite.createError': 'Erreur lors de la création du lien',
'admin.invite.deleteError': 'Erreur lors de la suppression du lien',
'admin.tabs.settings': 'Paramètres',
'admin.tabs.security': 'Sécurité',
'admin.tabs.services': 'Services',
'admin.allowRegistration': 'Autoriser les inscriptions',
'admin.allowRegistrationHint': "Les nouveaux utilisateurs peuvent s'inscrire eux-mêmes",
'admin.authMethods': "Méthodes d'authentification",
@@ -137,10 +141,8 @@ const admin: TranslationStrings = {
'admin.trekApi.included': 'Inclus',
'admin.trekApi.notIncluded': 'Non inclus',
'admin.trekApi.quota.title': 'Quota du jour',
'admin.trekApi.quota.used': 'Utilisé',
'admin.trekApi.quota.remaining': 'Restant',
'admin.trekApi.quota.resets': 'Remise à zéro à',
'admin.trekApi.quota.limit': 'Limite',
'admin.trekApi.quota.key': 'Clé',
'admin.trekApi.quota.state.connected': 'Connecté',
'admin.trekApi.quota.state.registering': 'Configuration en cours',
@@ -266,8 +268,9 @@ const admin: TranslationStrings = {
'admin.collab.whatsnext.title': 'Et ensuite',
'admin.collab.whatsnext.subtitle': "Suggestions d'activités et prochaines étapes",
'admin.tabs.config': 'Personnalisation',
'admin.tabs.defaults': 'Valeurs par défaut',
'admin.tabs.defaults': 'Préréglages',
'admin.defaultSettings.title': 'Paramètres utilisateur par défaut',
'admin.defaultSettings.routing': 'Itinéraires',
'admin.defaultSettings.description':
"Définissez des valeurs par défaut pour toute l'instance. Les utilisateurs n'ayant pas modifié un paramètre verront ces valeurs. Leurs propres modifications ont toujours la priorité.",
'admin.defaultSettings.saved': 'Valeur par défaut enregistrée',
@@ -732,16 +735,6 @@ const admin: TranslationStrings = {
'admin.addons.toast.updated': 'Extension mise à jour',
'admin.addons.toast.error': "Échec de la mise à jour de l'extension",
'admin.addons.group.count': '{enabled} sur {total} activés',
'admin.addons.search': 'Rechercher des modules',
'admin.addons.filter': 'Afficher',
'admin.addons.filter.all': 'Tous',
'admin.addons.filter.on': 'Activés',
'admin.addons.filter.off': 'Désactivés',
'admin.addons.noMatch': 'Aucun module ne correspond à la recherche ou au filtre.',
'admin.addons.subOptions': '{count} options', // same-as-en
'admin.addons.subOptions.one': '{count} option', // same-as-en
'admin.addons.subOptionsOn': '{count} activées',
'admin.addons.subOptionsOn.one': '{count} activée',
'admin.toursWanderer.title': 'wanderer',
'admin.toursWanderer.subtitle':
'Les utilisateurs peuvent connecter leur propre instance wanderer et importer ses sentiers comme sorties',
@@ -781,7 +774,7 @@ const admin: TranslationStrings = {
'admin.audit.col.resource': 'Ressource',
'admin.audit.col.ip': 'IP',
'admin.audit.col.details': 'Détails',
'admin.tabs.mcpTokens': 'Accès MCP',
'admin.tabs.mcpTokens': 'MCP',
'admin.mcpTokens.title': 'Accès MCP',
'admin.mcpTokens.subtitle': 'Gérer les sessions OAuth et les tokens API de tous les utilisateurs',
'admin.mcpTokens.sectionTitle': 'Tokens API',
@@ -940,7 +933,6 @@ const admin: TranslationStrings = {
"Utilisée pour chaque utilisateur n'ayant pas saisi sa propre clé, ainsi toute l'instance reçoit les tuiles CARTO sans filigrane. Stockée de façon chiffrée.",
'admin.group.users': 'Utilisateurs',
'admin.group.config': 'Configuration', // same-as-en
'admin.group.integration': 'Intégrations',
'admin.group.maintenance': 'Maintenance', // same-as-en
'admin.invite.tripLabel': 'Ajouter à un voyage (facultatif)',
'admin.invite.tripNone': 'Aucun voyage',

Some files were not shown because too many files have changed in this diff Show More