mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-14 00:01:03 +00:00
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:
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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() });
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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': 'نمط الألوان',
|
||||
|
||||
@@ -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üş',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'Λειτουργία Χρώματος',
|
||||
|
||||
@@ -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': 'מצב צבע',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'カラーモード',
|
||||
|
||||
@@ -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': '색상 모드',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'Цветовая схема',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'โหมดสี',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'Кольорова схема',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': '顏色模式',
|
||||
|
||||
@@ -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': '颜色模式',
|
||||
|
||||
Reference in New Issue
Block a user