feat(settings): collapsible settings cards, first on the integrations tab

The settings pages list everything at once, so they read as a wall of
forms. SettingsCard and Section take 'collapsible': the card starts shut
and its badge says its state ('Connected', 'Not set up'); its title is the
button, stretched over the head, the head's action stays its own button.
The body stays mounted while shut. A link to #id opens the card and
scrolls to it; the browser remembers a card left open.

Every integrations card is collapsible now; AI parsing shows its saved
model and MCP the apps signed in. The assistant's settings link opens the
AI connection card. Tests that read into a card open it first
(tests/helpers/settingsCards.ts).
This commit is contained in:
Maurice
2026-11-13 21:57:20 +01:00
parent 608f76f32c
commit 5fe3dea4cc
52 changed files with 355 additions and 16 deletions
@@ -28,7 +28,8 @@ import type { CardEntry } from './cards/cardTypes';
*/
/** Where the settings card for the user's own AI connection lives. */
export const AI_SETTINGS_PATH = '/settings?tab=integrations';
/** The AI connection card itself: the anchor opens it, shut as it otherwise starts. */
export const AI_SETTINGS_PATH = '/settings?tab=integrations#ai-connection';
type ToolCallPart = Extract<AssistantMessagePart, { type: 'tool-call' }>;
@@ -6,6 +6,7 @@ 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;
@@ -37,6 +38,8 @@ function toggleNextTo(label: string): HTMLElement {
}
beforeEach(() => {
// The cards start shut; these tests read into them.
openSettingsCards();
resetAllStores();
vi.clearAllMocks();
stubSettings();
@@ -40,6 +40,8 @@ export default function AirTrailConnectionSection(): React.ReactElement {
return (
<Section
id="airtrail"
collapsible
title={t('settings.airtrail.title')}
icon={AirTrailIcon}
badge={
@@ -9,6 +9,7 @@ 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);
@@ -21,6 +22,8 @@ beforeAll(() => {
});
beforeEach(() => {
// The cards start shut; these tests read into them.
openSettingsCards();
clipboardWriteText.mockReset();
clipboardWriteText.mockResolvedValue(undefined);
resetAllStores();
@@ -238,6 +238,8 @@ export default function ApiKeysSection(): React.ReactElement {
return (
<>
<Section
id="api-keys"
collapsible
title={t('settings.apiKeys.title')}
icon={KeyRound}
badge={keys.length > 0 ? <StatusPill>{keys.length}/{MAX_KEYS}</StatusPill> : undefined}
@@ -46,6 +46,7 @@ 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', () => ({
@@ -156,6 +157,8 @@ function testButton(): HTMLElement {
}
beforeEach(() => {
// The cards start shut; these tests read into them.
openSettingsCards();
resetAllStores();
vi.clearAllMocks();
stubSettings();
@@ -29,6 +29,8 @@ export default function DawarichConnectionSection(): React.ReactElement {
return (
<Section
id="dawarich"
collapsible
title={t('dawarich.title')}
icon={DawarichIcon}
badge={
@@ -10,6 +10,7 @@ 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, {
@@ -50,6 +51,8 @@ beforeAll(() => {
});
beforeEach(() => {
// The cards start shut; these tests read into them.
openSettingsCards();
clipboardWriteText.mockClear();
resetAllStores();
useAssistantStore.setState({ status: null });
@@ -346,9 +346,21 @@ function ApiTokensTab({ S }: { S: IntegrationsState }) {
}
function IntegrationsMcpSection({ S }: { S: IntegrationsState }) {
const { t, activeMcpTab, setActiveMcpTab, copiedKey, mcpEndpoint, handleCopy } = S
const { t, activeMcpTab, setActiveMcpTab, copiedKey, mcpEndpoint, handleCopy, oauthSessions } = S
// Connected means signed in now: an OAuth session, which is what an app holds while it reaches TREK.
const connected = oauthSessions.length
return (
<Section title={t('settings.mcp.title')} icon={Terminal}>
<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>
}
>
{/* Endpoint URL */}
<EditorField label={t('settings.mcp.endpoint')}>
<CopyValue value={mcpEndpoint} copied={copiedKey === 'endpoint'} label={t('settings.mcp.copy')} onCopy={() => handleCopy(mcpEndpoint, 'endpoint')} />
@@ -8,6 +8,7 @@ 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);
@@ -50,6 +51,8 @@ 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');
@@ -4,7 +4,8 @@ import { useTranslation } from '../../i18n'
import { useLlmConnection, type LlmProvider as Provider } from './useLlmConnection'
import Section from './Section'
import ToggleSwitch from './ToggleSwitch'
import { SETTINGS_BUTTON_PRIMARY, SettingRow, SettingRows, SettingsHint } from './settingsKit'
import { SETTINGS_BUTTON_PRIMARY, SettingRow, SettingRows, SettingsHint, StatusPill } from './settingsKit'
import { useSettingsStore } from '../../store/settingsStore'
import { EditorField, GRID_2, INPUT, Segmented } from '../shared/dialogParts'
import { fs } from '../shared/DialogShell'
@@ -42,7 +43,13 @@ export default function LlmConnectionSection(): React.ReactElement {
} = useLlmConnection()
return (
<Section title={t('settings.aiParsing.title')} icon={Sparkles}>
<Section
id="ai-parsing"
collapsible
title={t('settings.aiParsing.title')}
icon={Sparkles}
badge={<ParsingState />}
>
<SettingsHint>{t('settings.aiParsing.hint')}</SettingsHint>
{/* Two hosted providers only (#1772), so a segmented pair rather than a list. */}
@@ -103,3 +110,12 @@ export default function LlmConnectionSection(): React.ReactElement {
</Section>
)
}
/** The saved model at a glance on the shut card, or that none is set; not the form's unsaved value. */
function ParsingState() {
const { t } = useTranslation()
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>
}
@@ -30,6 +30,8 @@ export default function MailImportSection(): ReactElement | null {
return (
<Section
id="mail-import"
collapsible
title={t('mailInbox.settings.title')}
icon={Mail}
badge={
@@ -9,6 +9,7 @@ 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',
@@ -45,6 +46,8 @@ function seedMemoriesEnabled(providers = [fakeProvider]) {
}
beforeEach(() => {
// The cards start shut; these tests read into them.
openSettingsCards();
resetAllStores();
vi.clearAllMocks();
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true });
@@ -35,6 +35,8 @@ export default function PhotoProvidersSection(): React.ReactElement {
return (
<Section
id={`photos-${provider.id}`}
collapsible
key={provider.id}
title={provider.name || provider.id}
icon={Camera}
+6 -2
View File
@@ -14,13 +14,17 @@ interface SectionProps {
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. */
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, children }: SectionProps): React.ReactElement {
export default function Section({ title, icon, badge, hint, action, id, collapsible, children }: SectionProps): React.ReactElement {
return (
<SettingsCard icon={icon} title={title} badge={badge} hint={hint} action={action}>
<SettingsCard icon={icon} title={title} badge={badge} hint={hint} action={action} id={id} collapsible={collapsible}>
{children}
</SettingsCard>
)
@@ -4,6 +4,7 @@ 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';
@@ -31,6 +32,8 @@ function settingsRoute(state: { current: typeof disconnected }) {
}
beforeEach(() => {
// The cards start shut; these tests read into them.
openSettingsCards();
resetAllStores();
vi.clearAllMocks();
});
@@ -31,6 +31,8 @@ export default function WandererConnectionSection(): React.ReactElement {
return (
<Section
id="wanderer"
collapsible
title={t('wanderer.title')}
icon={WandererMark}
badge={
@@ -4,6 +4,7 @@ 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';
@@ -30,6 +31,8 @@ const INSTANCE: AiStatusResponse = {
};
beforeEach(() => {
// The cards start shut; these tests read into them.
openSettingsCards();
resetAllStores();
vi.clearAllMocks();
useAssistantStore.setState({ status: null });
@@ -4,6 +4,7 @@ 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';
@@ -48,6 +49,8 @@ function status(over: Partial<AiStatusResponse> = {}): AiStatusResponse {
}
beforeEach(() => {
// The cards start shut; these tests read into them.
openSettingsCards();
resetAllStores();
vi.clearAllMocks();
useAssistantStore.setState({ status: null });
@@ -282,6 +282,8 @@ export default function AiConnectionSection({
return (
<Section
id="ai-connection"
collapsible
title={t(text.titleKey)}
icon={Sparkles}
hint={t(text.hintKey)}
@@ -0,0 +1,65 @@
// 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();
});
});
+90 -9
View File
@@ -1,5 +1,5 @@
import type { ReactNode } from 'react'
import type { LucideIcon } from 'lucide-react'
import { useEffect, useId, useRef, useState, type ReactNode } from 'react'
import { ChevronDown, type LucideIcon } from 'lucide-react'
import { NEUTRAL_TINT, fs } from '../shared/DialogShell'
/*
@@ -33,12 +33,55 @@ 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}`
/**
* 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.
*/
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]
}
/**
* 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 }: {
export function SettingsCard({ icon: Icon, title, hint, badge, action, tone = 'neutral', children, id, bodyClassName, flush = false, collapsible = false }: {
icon?: IconLike
title: ReactNode
/** One line under the title in the band. */
@@ -52,11 +95,24 @@ 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])
return (
<section id={id} className={`${flush ? '' : 'mb-5 '}overflow-hidden rounded-2xl border border-edge-faint bg-surface-secondary`}>
<div className="flex items-center gap-3 border-b border-edge-faint px-4 py-3" style={{ background: tint }}>
<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} />
@@ -64,14 +120,39 @@ export function SettingsCard({ icon: Icon, title, hint, badge, action, tone = 'n
)}
<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}
<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="flex flex-none items-center gap-1.5">{action}</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>
{children != null && <div className={bodyClassName ?? 'flex flex-col gap-4 p-4'}>{children}</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>
</div>
)}
</section>
)
}
+3
View File
@@ -6,6 +6,7 @@ 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', () => ({
@@ -35,6 +36,8 @@ vi.mock('../components/Settings/AboutTab', () => ({
}));
beforeEach(() => {
// The cards start shut; these tests read into them.
openSettingsCards();
resetAllStores();
seedStore(useAuthStore, { isAuthenticated: true, user: buildUser() });
});
+26
View File
@@ -0,0 +1,26 @@
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');
}
+7
View File
@@ -75,6 +75,13 @@ const settings: TranslationStrings = {
'settings.weatherKeyHint': 'لبيانات الطقس.',
'settings.keyPlaceholder': 'أدخل المفتاح...',
'settings.configured': 'مُعدّ',
'settings.notSetUp': 'غير مُعدّ',
'settings.mcp.appsConnected': '{count} تطبيق متصل',
'settings.mcp.appsConnected.zero': '{count} تطبيقات متصلة',
'settings.mcp.appsConnected.one': '{count} تطبيق متصل',
'settings.mcp.appsConnected.two': '{count} تطبيقان متصلان',
'settings.mcp.appsConnected.few': '{count} تطبيقات متصلة',
'settings.mcp.appsConnected.many': '{count} تطبيقًا متصلًا',
'settings.saveKeys': 'حفظ المفاتيح',
'settings.display': 'العرض',
'settings.colorMode': 'نمط الألوان',
+3
View File
@@ -101,6 +101,9 @@ const settings: TranslationStrings = {
'settings.weatherKeyHint': 'Hava məlumatları üçün. openweathermap.org/api ünvanında pulsuzdur',
'settings.keyPlaceholder': 'Açarı daxil edin...',
'settings.configured': 'Konfiqurasiya edilib',
'settings.notSetUp': 'Qurulmayıb',
'settings.mcp.appsConnected': '{count} tətbiq qoşulub',
'settings.mcp.appsConnected.one': '{count} tətbiq qoşulub',
'settings.saveKeys': 'Açarları yadda saxla',
'settings.display': 'Görünüş',
+3
View File
@@ -82,6 +82,9 @@ const settings: TranslationStrings = {
'settings.weatherKeyHint': 'Para dados meteorológicos. Grátis em openweathermap.org/api',
'settings.keyPlaceholder': 'Digite a chave...',
'settings.configured': 'Configurada',
'settings.notSetUp': 'Não configurado',
'settings.mcp.appsConnected': '{count} apps conectados',
'settings.mcp.appsConnected.one': '{count} app conectado',
'settings.saveKeys': 'Salvar chaves',
'settings.display': 'Exibição',
'settings.colorMode': 'Tema de cores',
+3
View File
@@ -68,6 +68,9 @@ const settings: TranslationStrings = {
'settings.weatherKeyHint': 'Per a dades meteorològiques. Gratuïta a openweathermap.org/api',
'settings.keyPlaceholder': 'Introdueix la clau...',
'settings.configured': 'Configurat',
'settings.notSetUp': 'No configurat',
'settings.mcp.appsConnected': '{count} aplicacions connectades',
'settings.mcp.appsConnected.one': '{count} aplicació connectada',
'settings.saveKeys': 'Desa les claus',
'settings.display': 'Visualització',
'settings.colorMode': 'Mode de color',
+4
View File
@@ -79,6 +79,10 @@ const settings: TranslationStrings = {
'settings.weatherKeyHint': 'Pro předpověď počasí. Zdarma na openweathermap.org/api',
'settings.keyPlaceholder': 'Vložte klíč...',
'settings.configured': 'Nastaveno',
'settings.notSetUp': 'Nenastaveno',
'settings.mcp.appsConnected': '{count} aplikací připojeno',
'settings.mcp.appsConnected.one': '{count} aplikace připojena',
'settings.mcp.appsConnected.few': '{count} aplikace připojeny',
'settings.saveKeys': 'Uložit klíče',
'settings.display': 'Zobrazení',
'settings.colorMode': 'Barevné schéma',
+3
View File
@@ -81,6 +81,9 @@ const settings: TranslationStrings = {
'settings.weatherKeyHint': 'Für Wetterdaten. Kostenlos unter openweathermap.org/api',
'settings.keyPlaceholder': 'Schlüssel eingeben...',
'settings.configured': 'Konfiguriert',
'settings.notSetUp': 'Nicht eingerichtet',
'settings.mcp.appsConnected': '{count} Apps verbunden',
'settings.mcp.appsConnected.one': '{count} App verbunden',
'settings.saveKeys': 'Schlüssel speichern',
'settings.display': 'Darstellung',
'settings.colorMode': 'Farbmodus',
+3
View File
@@ -88,6 +88,9 @@ const settings: TranslationStrings = {
'settings.weatherKeyHint': 'For weather data. Free at openweathermap.org/api',
'settings.keyPlaceholder': 'Enter key...',
'settings.configured': 'Configured',
'settings.notSetUp': 'Not set up',
'settings.mcp.appsConnected': '{count} apps connected',
'settings.mcp.appsConnected.one': '{count} app connected',
'settings.saveKeys': 'Save Keys',
'settings.display': 'Display',
'settings.colorMode': 'Color Mode',
+3
View File
@@ -82,6 +82,9 @@ const settings: TranslationStrings = {
'settings.weatherKeyHint': 'Para datos meteorológicos. Gratis en openweathermap.org/api',
'settings.keyPlaceholder': 'Introduce la clave...',
'settings.configured': 'Configurado',
'settings.notSetUp': 'Sin configurar',
'settings.mcp.appsConnected': '{count} apps conectadas',
'settings.mcp.appsConnected.one': '{count} app conectada',
'settings.saveKeys': 'Guardar claves',
'settings.display': 'Visualización',
'settings.colorMode': 'Modo de color',
+3
View File
@@ -91,6 +91,9 @@ const settings: TranslationStrings = {
'settings.weatherKeyHint': 'Ilmaandmete jaoks. Tasuta aadressil openweathermap.org/api',
'settings.keyPlaceholder': 'Sisesta võti...',
'settings.configured': 'Seadistatud',
'settings.notSetUp': 'Seadistamata',
'settings.mcp.appsConnected': '{count} rakendust ühendatud',
'settings.mcp.appsConnected.one': '{count} rakendus ühendatud',
'settings.saveKeys': 'Salvesta võtmed',
'settings.display': 'Kuva',
'settings.colorMode': 'Värvirežiim',
+3
View File
@@ -84,6 +84,9 @@ const settings: TranslationStrings = {
'settings.weatherKeyHint': 'Pour les données météo. Gratuit sur openweathermap.org/api',
'settings.keyPlaceholder': 'Saisir la clé…',
'settings.configured': 'Configuré',
'settings.notSetUp': 'Non configuré',
'settings.mcp.appsConnected': '{count} applications connectées',
'settings.mcp.appsConnected.one': '{count} application connectée',
'settings.saveKeys': 'Enregistrer les clés',
'settings.display': 'Affichage',
'settings.colorMode': 'Mode de couleur',
+3
View File
@@ -86,6 +86,9 @@ const settings: TranslationStrings = {
'settings.weatherKeyHint': 'Για δεδομένα καιρού. Δωρεάν στο openweathermap.org/api',
'settings.keyPlaceholder': 'Εισάγετε κλειδί...',
'settings.configured': 'Διαμορφωμένο',
'settings.notSetUp': 'Δεν έχει ρυθμιστεί',
'settings.mcp.appsConnected': '{count} εφαρμογές συνδεδεμένες',
'settings.mcp.appsConnected.one': '{count} εφαρμογή συνδεδεμένη',
'settings.saveKeys': 'Αποθήκευση Κλειδιών',
'settings.display': 'Εμφάνιση',
'settings.colorMode': 'Λειτουργία Χρώματος',
+4
View File
@@ -88,6 +88,10 @@ const settings: TranslationStrings = {
'settings.weatherKeyHint': 'לנתוני מזג אוויר. חינם ב-openweathermap.org/api',
'settings.keyPlaceholder': 'הזינו מפתח...',
'settings.configured': 'מוגדר',
'settings.notSetUp': 'לא הוגדר',
'settings.mcp.appsConnected': '{count} אפליקציות מחוברות',
'settings.mcp.appsConnected.one': '{count} אפליקציה מחוברת',
'settings.mcp.appsConnected.two': '{count} אפליקציות מחוברות',
'settings.saveKeys': 'שמירת מפתחות',
'settings.display': 'תצוגה',
'settings.colorMode': 'מצב צבע',
+3
View File
@@ -82,6 +82,9 @@ const settings: TranslationStrings = {
'settings.weatherKeyHint': 'Időjárás adatokhoz. Ingyenes: openweathermap.org/api',
'settings.keyPlaceholder': 'Kulcs megadása...',
'settings.configured': 'Konfigurálva',
'settings.notSetUp': 'Nincs beállítva',
'settings.mcp.appsConnected': '{count} alkalmazás csatlakoztatva',
'settings.mcp.appsConnected.one': '{count} alkalmazás csatlakoztatva',
'settings.saveKeys': 'Kulcsok mentése',
'settings.display': 'Megjelenítés',
'settings.colorMode': 'Színmód',
+2
View File
@@ -80,6 +80,8 @@ const settings: TranslationStrings = {
'settings.weatherKeyHint': 'Untuk data cuaca. Gratis di openweathermap.org/api',
'settings.keyPlaceholder': 'Masukkan key...',
'settings.configured': 'Sudah dikonfigurasi',
'settings.notSetUp': 'Belum diatur',
'settings.mcp.appsConnected': '{count} aplikasi terhubung',
'settings.saveKeys': 'Simpan Keys',
'settings.display': 'Tampilan',
'settings.colorMode': 'Mode Warna',
+3
View File
@@ -82,6 +82,9 @@ const settings: TranslationStrings = {
'settings.weatherKeyHint': 'Per i dati meteo. Gratuita su openweathermap.org/api',
'settings.keyPlaceholder': 'Inserisci la chiave...',
'settings.configured': 'Configurata',
'settings.notSetUp': 'Non configurato',
'settings.mcp.appsConnected': '{count} app collegate',
'settings.mcp.appsConnected.one': '{count} app collegata',
'settings.saveKeys': 'Salva Chiavi',
'settings.display': 'Visualizzazione',
'settings.colorMode': 'Modalità Colore',
+2
View File
@@ -79,6 +79,8 @@ const settings: TranslationStrings = {
'settings.weatherKeyHint': '天気情報用。openweathermap.org/api で無料取得',
'settings.keyPlaceholder': 'キーを入力…',
'settings.configured': '設定済み',
'settings.notSetUp': '未設定',
'settings.mcp.appsConnected': '{count} 個のアプリが接続済み',
'settings.saveKeys': 'キーを保存',
'settings.display': '表示',
'settings.colorMode': 'カラーモード',
+2
View File
@@ -80,6 +80,8 @@ const settings: TranslationStrings = {
'settings.weatherKeyHint': '날씨 데이터용. openweathermap.org/api에서 무료 발급',
'settings.keyPlaceholder': '키 입력...',
'settings.configured': '설정됨',
'settings.notSetUp': '설정 안 됨',
'settings.mcp.appsConnected': '앱 {count}개 연결됨',
'settings.saveKeys': '키 저장',
'settings.display': '화면',
'settings.colorMode': '색상 모드',
+3
View File
@@ -81,6 +81,9 @@ const settings: TranslationStrings = {
'settings.weatherKeyHint': 'Voor weergegevens. Gratis op openweathermap.org/api',
'settings.keyPlaceholder': 'Sleutel invoeren...',
'settings.configured': 'Geconfigureerd',
'settings.notSetUp': 'Niet ingesteld',
'settings.mcp.appsConnected': '{count} apps verbonden',
'settings.mcp.appsConnected.one': '{count} app verbonden',
'settings.saveKeys': 'Sleutels opslaan',
'settings.display': 'Weergave',
'settings.colorMode': 'Kleurmodus',
+5
View File
@@ -80,6 +80,11 @@ const settings: TranslationStrings = {
'settings.weatherKeyHint': 'Do danych pogodowych. Bezpłatnie na openweathermap.org/api',
'settings.keyPlaceholder': 'Podaj klucz...',
'settings.configured': 'Skonfigurowano',
'settings.notSetUp': 'Nie skonfigurowano',
'settings.mcp.appsConnected': '{count} aplikacji połączonych',
'settings.mcp.appsConnected.one': '{count} aplikacja połączona',
'settings.mcp.appsConnected.few': '{count} aplikacje połączone',
'settings.mcp.appsConnected.many': '{count} aplikacji połączonych',
'settings.saveKeys': 'Zapisz klucze',
'settings.display': 'Preferencje',
'settings.colorMode': 'Motyw',
+5
View File
@@ -80,6 +80,11 @@ const settings: TranslationStrings = {
'settings.weatherKeyHint': 'Для данных о погоде. Бесплатно на openweathermap.org/api',
'settings.keyPlaceholder': 'Введите ключ...',
'settings.configured': 'Настроено',
'settings.notSetUp': 'Не настроено',
'settings.mcp.appsConnected': '{count} приложения подключено',
'settings.mcp.appsConnected.one': '{count} приложение подключено',
'settings.mcp.appsConnected.few': '{count} приложения подключены',
'settings.mcp.appsConnected.many': '{count} приложений подключено',
'settings.saveKeys': 'Сохранить ключи',
'settings.display': 'Отображение',
'settings.colorMode': 'Цветовая схема',
+4
View File
@@ -88,6 +88,10 @@ const settings: TranslationStrings = {
'settings.weatherKeyHint': 'Na dáta o počasí. Zadarmo na openweathermap.org/api',
'settings.keyPlaceholder': 'Zadajte kľúč...',
'settings.configured': 'Nastavené',
'settings.notSetUp': 'Nenastavené',
'settings.mcp.appsConnected': '{count} aplikácií pripojených',
'settings.mcp.appsConnected.one': '{count} aplikácia pripojená',
'settings.mcp.appsConnected.few': '{count} aplikácie pripojené',
'settings.saveKeys': 'Uložiť kľúče',
'settings.display': 'Zobrazenie',
'settings.colorMode': 'Farebný režim',
+3
View File
@@ -81,6 +81,9 @@ const settings: TranslationStrings = {
'settings.weatherKeyHint': 'För väderdata. Gratis på openweathermap.org/api',
'settings.keyPlaceholder': 'Ange nyckel...',
'settings.configured': 'Inställd',
'settings.notSetUp': 'Inte inställd',
'settings.mcp.appsConnected': '{count} appar anslutna',
'settings.mcp.appsConnected.one': '{count} app ansluten',
'settings.saveKeys': 'Spara nycklar',
'settings.display': 'Visning',
'settings.colorMode': 'Färgläge',
+2
View File
@@ -76,6 +76,8 @@ const settings: TranslationStrings = {
'settings.weatherKeyHint': 'สำหรับข้อมูลสภาพอากาศ ฟรีที่ openweathermap.org/api',
'settings.keyPlaceholder': 'ป้อนคีย์...',
'settings.configured': 'กำหนดค่าแล้ว',
'settings.notSetUp': 'ยังไม่ได้ตั้งค่า',
'settings.mcp.appsConnected': 'เชื่อมต่อแล้ว {count} แอป',
'settings.saveKeys': 'บันทึกคีย์',
'settings.display': 'จอแสดงผล',
'settings.colorMode': 'โหมดสี',
+3
View File
@@ -81,6 +81,9 @@ const settings: TranslationStrings = {
'settings.weatherKeyHint': 'Hava verisi için. Ücretsiz: openweathermap.org/api',
'settings.keyPlaceholder': 'Anahtarı girin...',
'settings.configured': 'Yapılandırıldı',
'settings.notSetUp': 'Ayarlanmadı',
'settings.mcp.appsConnected': '{count} uygulama bağlı',
'settings.mcp.appsConnected.one': '{count} uygulama bağlı',
'settings.saveKeys': 'Anahtarları Kaydet',
'settings.display': 'Görünüm',
'settings.colorMode': 'Renk Modu',
+5
View File
@@ -82,6 +82,11 @@ const settings: TranslationStrings = {
'settings.weatherKeyHint': 'Для даних про погоду. Безкоштовно на openweathermap.org/api',
'settings.keyPlaceholder': 'Введіть ключ...',
'settings.configured': 'Налаштовано',
'settings.notSetUp': 'Не налаштовано',
'settings.mcp.appsConnected': '{count} застосунку підключено',
'settings.mcp.appsConnected.one': '{count} застосунок підключено',
'settings.mcp.appsConnected.few': '{count} застосунки підключено',
'settings.mcp.appsConnected.many': '{count} застосунків підключено',
'settings.saveKeys': 'Зберегти ключі',
'settings.display': 'Відображення',
'settings.colorMode': 'Кольорова схема',
+2
View File
@@ -82,6 +82,8 @@ const settings: TranslationStrings = {
'settings.weatherKeyHint': 'Đối với dữ liệu thời tiết. Miễn phí tại openweathermap.org/api',
'settings.keyPlaceholder': 'Nhập khóa...',
'settings.configured': 'Đã định cấu hình',
'settings.notSetUp': 'Chưa thiết lập',
'settings.mcp.appsConnected': '{count} ứng dụng đã kết nối',
'settings.saveKeys': 'Lưu chìa khóa',
'settings.display': 'Hiển thị',
'settings.colorMode': 'Chế độ màu',
+2
View File
@@ -77,6 +77,8 @@ const settings: TranslationStrings = {
'settings.weatherKeyHint': '用於天氣資料。在 openweathermap.org/api 免費獲取',
'settings.keyPlaceholder': '輸入金鑰...',
'settings.configured': '已配置',
'settings.notSetUp': '未設定',
'settings.mcp.appsConnected': '已連接 {count} 個應用程式',
'settings.saveKeys': '儲存金鑰',
'settings.display': '顯示',
'settings.colorMode': '顏色模式',
+2
View File
@@ -77,6 +77,8 @@ const settings: TranslationStrings = {
'settings.weatherKeyHint': '用于天气数据。在 openweathermap.org/api 免费获取',
'settings.keyPlaceholder': '输入密钥...',
'settings.configured': '已配置',
'settings.notSetUp': '未设置',
'settings.mcp.appsConnected': '已连接 {count} 个应用',
'settings.saveKeys': '保存密钥',
'settings.display': '显示',
'settings.colorMode': '颜色模式',