Files
TREK/client/src/components/Settings/ai/AiConnectionSection.tsx
T
Maurice 94ac7673a5 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.
2026-11-13 23:18:28 +01:00

318 lines
11 KiB
TypeScript

import type { AiConnectionKind, AiConnectionView } from '@trek/shared';
import { Loader2, LogOut, MessageCircle, Sparkles, Unplug } from 'lucide-react';
import React from 'react';
import { useTranslation } from '../../../i18n';
import { useAssistantCostEstimates, useAssistantMapFollow } from '../../Assistant/useAssistantMapFollow';
import DemoLock from '../../Demo/DemoLock';
import { useDemoAccountLock } from '../../Demo/useDemoLock';
import ConfirmDialog from '../../shared/ConfirmDialog';
import CustomSelect from '../../shared/CustomSelect';
import { fs } from '../../shared/DialogShell';
import Section from '../Section';
import {
SettingRow,
SettingRows,
SETTINGS_BUTTON,
SETTINGS_BUTTON_DANGER,
SETTINGS_BUTTON_PRIMARY,
SettingsHint,
StatusPill,
} from '../settingsKit';
import ToggleSwitch from '../ToggleSwitch';
import { aiSectionText, CONNECTION_STATUS } from './aiConnectModel';
import AiConnectPanel, { ProviderMark } from './AiConnectPanel';
import { useAiConnections } from './useAiConnections';
type AiConnections = ReturnType<typeof useAiConnections>;
/** What a connection that stopped working says before its one way back. */
const BROKEN: Partial<Record<AiConnectionView['status'], { messageKey: string }>> = {
needs_reauth: { messageKey: 'assistant.error.needsReauth' },
error: { messageKey: 'assistant.error.invalidKey' },
};
/** A connection as one row: the provider's tile, who it is and the detail under it, the actions on the right. */
function ConnectionRow({
kind,
title,
detail,
children,
}: {
kind: AiConnectionKind;
title: string;
detail?: React.ReactNode;
children?: React.ReactNode;
}) {
return (
<div className="flex flex-wrap items-center gap-x-3 gap-y-2.5 px-3.5 py-3">
<ProviderMark kind={kind} />
<div className="min-w-0 flex-1 basis-40">
<p className="m-0 truncate font-medium text-content" style={fs(13, 'body')}>
{title}
</p>
{detail && (
<p className="m-0 mt-0.5 truncate font-geist text-content-faint" style={fs(11.5)}>
{detail}
</p>
)}
</div>
{children && <div className="flex flex-wrap items-center gap-2">{children}</div>}
</div>
);
}
function OpenAssistantButton({ S }: { S: AiConnections }) {
const { t } = useTranslation();
if (!S.canOpen) return null;
return (
<button type="button" onClick={S.openAssistant} className={SETTINGS_BUTTON_PRIMARY}>
<MessageCircle size={14} strokeWidth={2.2} /> {t('assistant.connect.openAssistant')}
</button>
);
}
/** The user's own connection: who answers, which model, and the way out (or back in, once it broke). */
function OwnConnection({ S, own }: { S: AiConnections; own: AiConnectionView }) {
const { t } = useTranslation();
const { provider, models, busy, setModel, signOut, askDisconnect } = S.ownActions;
const broken = BROKEN[own.status];
const repairMode = S.repairMode;
return (
<SettingRows>
<ConnectionRow kind={own.kind} title={t('assistant.connect.connected', { provider })} detail={own.hint}>
{repairMode ? (
<button
type="button"
onClick={() => S.repair(repairMode)}
className={SETTINGS_BUTTON_PRIMARY}
>
{t(repairMode === 'chatgpt' ? 'assistant.errorAction.signIn' : 'assistant.errorAction.replaceKey')}
</button>
) : (
<OpenAssistantButton S={S} />
)}
{own.kind === 'chatgpt' && own.status === 'ok' ? (
<button
type="button"
onClick={() => void signOut()}
disabled={busy}
className={SETTINGS_BUTTON}
>
<LogOut size={14} strokeWidth={2} /> {t('assistant.connect.signOut')}
</button>
) : (
<button
type="button"
onClick={askDisconnect}
disabled={busy}
className={SETTINGS_BUTTON_DANGER}
>
<Unplug size={14} strokeWidth={2} /> {t('assistant.connect.disconnect')}
</button>
)}
</ConnectionRow>
{broken && (
<p role="alert" className="m-0 px-3.5 py-2.5 text-danger" style={fs(12, 'body')}>
{t(broken.messageKey, { provider })}
</p>
)}
{own.status === 'ok' && (
<SettingRow
label={t('assistant.connect.model')}
hint={t('assistant.connect.modelHint')}
dimmed={busy}
control={
<div className="w-56 max-w-full">
<CustomSelect
ariaLabel={t('assistant.connect.model')}
value={own.model}
onChange={(v) => void setModel(String(v))}
options={models}
size="sm"
disabled={busy}
searchable={models.length > 8}
/>
</div>
}
/>
)}
</SettingRows>
);
}
/** The admin's connection this user runs on, with today's share of the limit while the instance pays. */
function InstanceConnection({ S }: { S: AiConnections }) {
const { t } = useTranslation();
const instance = S.status?.instance;
if (!instance) return null;
return (
<SettingRows>
<ConnectionRow
kind={instance.kind}
title={t('assistant.connect.providedByAdmin')}
detail={`${S.instanceProvider}, ${instance.model}`}
>
<OpenAssistantButton S={S} />
{S.status?.canConnectOwn && !S.panel && (
<button type="button" onClick={S.connectOwn} className={SETTINGS_BUTTON}>
{t('assistant.errorAction.connectOwn')}
</button>
)}
</ConnectionRow>
{S.quota && (
<p className="m-0 px-3.5 py-2.5 tabular-nums text-content-muted" style={fs(12, 'body')}>
{t('assistant.connect.quota', { used: S.quota.used, count: S.quota.limit })}
</p>
)}
</SettingRows>
);
}
/** How the assistant behaves once it runs: whether the trip map follows it. */
function AssistantBehaviour() {
const { t } = useTranslation();
const follow = useAssistantMapFollow();
const costs = useAssistantCostEstimates();
return (
<SettingRows>
<SettingRow
label={t('assistant.settings.followMap')}
hint={t('assistant.settings.followMapHint')}
control={
<ToggleSwitch
on={follow.on}
onToggle={() => void follow.set(!follow.on)}
label={t('assistant.settings.followMap')}
/>
}
/>
<SettingRow
label={t('assistant.settings.costEstimates')}
hint={t('assistant.settings.costEstimatesHint')}
control={
<ToggleSwitch
on={costs.on}
onToggle={() => void costs.set(!costs.on)}
label={t('assistant.settings.costEstimates')}
/>
}
/>
</SettingRows>
);
}
/** Where the shared demo account would connect a provider of its own: shown, and locked. */
function LockedOwnConnection() {
const { t } = useTranslation();
return (
<DemoLock locked reason={t('demo.lock.ai')} size="row">
<SettingRows>
<SettingRow
label={t('demo.lock.aiTitle')}
control={
<button type="button" disabled className={SETTINGS_BUTTON}>
{t('assistant.errorAction.connectOwn')}
</button>
}
/>
</SettingRows>
</DemoLock>
);
}
/** The connect panel, opened on purpose or because nothing is connected yet. */
function ConnectPanel({ S }: { S: AiConnections }) {
const { t } = useTranslation();
return (
<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`}>
{t('common.cancel')}
</button>
)}
</div>
);
}
/** How long chats are kept, and for an admin the way to the setting for everyone. */
function SectionFooter({ S }: { S: AiConnections }) {
const { t } = useTranslation();
const retention = S.view !== 'setup' && !!S.retentionText;
if (!S.isAdmin && !retention) return null;
return (
<div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-1">
{S.view !== 'setup' && <SettingsHint>{S.retentionText}</SettingsHint>}
{S.isAdmin && (
<button
type="button"
onClick={S.openAdmin}
className="font-medium text-content-muted underline underline-offset-2 hover:text-content"
style={fs(12, 'body')}
>
{t('assistant.setup.forEveryone')}
</button>
)}
</div>
);
}
/**
* Settings → Integrations → the user's AI connection: connect a provider, see
* which one answers, pick its model, leave again. The assistant runs on it and
* AI parsing reads bookings with it, so it shows while either addon is on and
* says which it serves.
*/
export default function AiConnectionSection({
assistant = true,
parsing = false,
}: {
assistant?: boolean;
parsing?: boolean;
}): React.ReactElement | null {
const { t } = useTranslation();
const S = useAiConnections();
const demoLocked = useDemoAccountLock();
const text = aiSectionText(assistant, parsing);
if (S.view === 'hidden') return null;
const own = S.own;
return (
<Section
id="ai-connection"
title={t(text.titleKey)}
icon={Sparkles}
hint={t(text.hintKey)}
badge={
own ? (
<StatusPill tone={CONNECTION_STATUS[own.status].tone}>{t(CONNECTION_STATUS[own.status].labelKey)}</StatusPill>
) : undefined
}
>
{S.view === 'loading' && (
<div className="grid place-items-center py-6">
<Loader2 size={18} className="animate-spin text-content-faint" />
</div>
)}
{S.view === 'own' && own && <OwnConnection S={S} own={own} />}
{S.view === 'instance' && <InstanceConnection S={S} />}
{assistant && (S.view === 'own' || S.view === 'instance') && <AssistantBehaviour />}
{S.view === 'adminOnly' && !demoLocked && <SettingsHint>{t('assistant.setup.adminOnly')}</SettingsHint>}
{demoLocked && S.view !== 'loading' && <LockedOwnConnection />}
{S.panel && <ConnectPanel S={S} />}
<SectionFooter S={S} />
<ConfirmDialog
isOpen={S.ownActions.confirmingDisconnect}
onClose={S.ownActions.cancelDisconnect}
onConfirm={() => void S.ownActions.disconnect()}
title={t('assistant.connect.disconnect')}
message={t('assistant.connect.disconnectConfirm', { provider: S.ownActions.provider })}
confirmLabel={t('assistant.connect.disconnect')}
danger
/>
</Section>
);
}