mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-24 16:03:02 +00:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| eb9ad50dca | |||
| 280e55bbc9 | |||
| e84060cdf7 | |||
| fbc85b05a1 | |||
| 01333c6ff3 | |||
| aecef03090 |
@@ -3,7 +3,7 @@
|
|||||||
"@typescript-eslint/no-non-null-assertion": 384,
|
"@typescript-eslint/no-non-null-assertion": 384,
|
||||||
"@typescript-eslint/no-misused-promises": 849,
|
"@typescript-eslint/no-misused-promises": 849,
|
||||||
"no-console": 88,
|
"no-console": 88,
|
||||||
"complexity": 222,
|
"complexity": 221,
|
||||||
"@typescript-eslint/no-explicit-any": 146,
|
"@typescript-eslint/no-explicit-any": 146,
|
||||||
"trek/no-swallowed-catch": 130,
|
"trek/no-swallowed-catch": 130,
|
||||||
"max-params": 32,
|
"max-params": 32,
|
||||||
|
|||||||
@@ -72,7 +72,6 @@
|
|||||||
"components/Map/poiCategories.ts": 7,
|
"components/Map/poiCategories.ts": 7,
|
||||||
"components/Map/ratingBadge.ts": 2,
|
"components/Map/ratingBadge.ts": 2,
|
||||||
"components/Map/reservationsMapbox.ts": 1,
|
"components/Map/reservationsMapbox.ts": 1,
|
||||||
"components/Notifications/InAppNotificationItem.tsx": 1,
|
|
||||||
"components/PDF/TripPDF.tsx": 53,
|
"components/PDF/TripPDF.tsx": 53,
|
||||||
"components/Packing/PackingListPanelBagModal.tsx": 2,
|
"components/Packing/PackingListPanelBagModal.tsx": 2,
|
||||||
"components/Packing/PackingListPanelCategoryGroup.tsx": 5,
|
"components/Packing/PackingListPanelCategoryGroup.tsx": 5,
|
||||||
@@ -295,7 +294,6 @@
|
|||||||
"pages/FilesPage.tsx": 11,
|
"pages/FilesPage.tsx": 11,
|
||||||
"pages/ForgotPasswordPage.tsx": 18,
|
"pages/ForgotPasswordPage.tsx": 18,
|
||||||
"pages/HelpPage.tsx": 21,
|
"pages/HelpPage.tsx": 21,
|
||||||
"pages/InAppNotificationsPage.tsx": 2,
|
|
||||||
"pages/JoinTripPage.tsx": 4,
|
"pages/JoinTripPage.tsx": 4,
|
||||||
"pages/JourneyDetailPage.tsx": 53,
|
"pages/JourneyDetailPage.tsx": 53,
|
||||||
"pages/JourneyPage.tsx": 64,
|
"pages/JourneyPage.tsx": 64,
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import {
|
|||||||
type AiApiKeyConnectRequest,
|
type AiApiKeyConnectRequest,
|
||||||
type AiAuthStartResponse,
|
type AiAuthStartResponse,
|
||||||
type AiChatgptFinishRequest,
|
type AiChatgptFinishRequest,
|
||||||
type AiClaudeConnectorResponse,
|
|
||||||
type AiConnectionScope,
|
type AiConnectionScope,
|
||||||
type AiConnectionView,
|
type AiConnectionView,
|
||||||
type AiInstanceConnectionRequest,
|
type AiInstanceConnectionRequest,
|
||||||
@@ -233,7 +232,3 @@ export const aiAdminApi = {
|
|||||||
removeConnection: (): Promise<SuccessResponse> => apiClient.delete('/admin/ai/connection').then((r) => r.data),
|
removeConnection: (): Promise<SuccessResponse> => apiClient.delete('/admin/ai/connection').then((r) => r.data),
|
||||||
models: (): Promise<AiModelListResponse> => apiClient.get('/admin/ai/models', viaProvider).then((r) => r.data),
|
models: (): Promise<AiModelListResponse> => apiClient.get('/admin/ai/models', viaProvider).then((r) => r.data),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const claudeConnectorApi = {
|
|
||||||
get: (): Promise<AiClaudeConnectorResponse> => apiClient.get('/integrations/claude-connector').then((r) => r.data),
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { Bell, Trash2, CheckCheck } from 'lucide-react'
|
|||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
import { useInAppNotificationStore } from '../../store/inAppNotificationStore.ts'
|
import { useInAppNotificationStore } from '../../store/inAppNotificationStore.ts'
|
||||||
import { useAuthStore } from '../../store/authStore'
|
import { useAuthStore } from '../../store/authStore'
|
||||||
import InAppNotificationItem from '../Notifications/InAppNotificationItem.tsx'
|
import NotificationDayList from '../Notifications/NotificationDayList'
|
||||||
import ConfirmDialog from '../shared/ConfirmDialog'
|
import ConfirmDialog from '../shared/ConfirmDialog'
|
||||||
|
|
||||||
export default function InAppNotificationBell(): React.ReactElement {
|
export default function InAppNotificationBell(): React.ReactElement {
|
||||||
@@ -73,9 +73,9 @@ export default function InAppNotificationBell(): React.ReactElement {
|
|||||||
position: 'fixed',
|
position: 'fixed',
|
||||||
top: 'var(--nav-h)',
|
top: 'var(--nav-h)',
|
||||||
insetInlineEnd: 8,
|
insetInlineEnd: 8,
|
||||||
width: 380,
|
width: 400,
|
||||||
maxWidth: 'calc(100vw - 16px)',
|
maxWidth: 'calc(100vw - 16px)',
|
||||||
maxHeight: 'min(480px, calc(100vh - var(--nav-h) - 16px))',
|
maxHeight: 'min(560px, calc(100vh - var(--nav-h) - 16px))',
|
||||||
zIndex: 9999,
|
zIndex: 9999,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
@@ -119,7 +119,7 @@ export default function InAppNotificationBell(): React.ReactElement {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Notification list */}
|
{/* Notification list */}
|
||||||
<div className="overflow-y-auto flex-1 py-1 border-t border-edge-secondary">
|
<div className="overflow-y-auto flex-1 pb-1 border-t border-edge-secondary">
|
||||||
{isLoading && notifications.length === 0 ? (
|
{isLoading && notifications.length === 0 ? (
|
||||||
<div className="flex items-center justify-center py-10">
|
<div className="flex items-center justify-center py-10">
|
||||||
<div className="w-5 h-5 border-2 rounded-full animate-spin border-edge border-t-content" />
|
<div className="w-5 h-5 border-2 rounded-full animate-spin border-edge border-t-content" />
|
||||||
@@ -133,9 +133,7 @@ export default function InAppNotificationBell(): React.ReactElement {
|
|||||||
<p className="text-caption text-content-muted">{t('notifications.emptyDescription')}</p>
|
<p className="text-caption text-content-muted">{t('notifications.emptyDescription')}</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
notifications.slice(0, 10).map(n => (
|
<NotificationDayList notifications={notifications.slice(0, 10)} onClose={() => setOpen(false)} compact />
|
||||||
<InAppNotificationItem key={n.id} notification={n} onClose={() => setOpen(false)} />
|
|
||||||
))
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,29 +1,53 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { User, Check, X, ArrowRight, Trash2, CheckCheck, Mail } from 'lucide-react'
|
import { Check, X, ArrowRight, Trash2, CheckCheck, Mail } from 'lucide-react'
|
||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
import type { InAppNotification } from '../../store/inAppNotificationStore'
|
import type { InAppNotification } from '../../store/inAppNotificationStore'
|
||||||
import { compactTime, useNotificationItemActions } from './useNotificationItemActions'
|
import { compactTime, useNotificationItemActions } from './useNotificationItemActions'
|
||||||
|
import { KIND_LOOK, createdAt, kindOf } from './notificationModel'
|
||||||
function relativeTime(dateStr: string, locale: string): string {
|
|
||||||
return compactTime(dateStr, locale === 'ar' ? 'الآن' : 'just now')
|
|
||||||
}
|
|
||||||
|
|
||||||
interface NotificationItemProps {
|
interface NotificationItemProps {
|
||||||
notification: InAppNotification
|
notification: InAppNotification
|
||||||
onClose?: () => void
|
onClose?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The round tool beside a notification, quiet until it is pointed at. */
|
/** The quiet tool beside a notification, forward only when it is pointed at. */
|
||||||
const TOOL = 'grid place-items-center w-7 h-7 rounded-lg transition-colors text-content-faint hover:text-content hover:bg-surface-card focus-visible:bg-surface-card focus-visible:text-content'
|
const TOOL = 'grid place-items-center w-7 h-7 rounded-lg transition-colors text-content-faint hover:text-content hover:bg-surface-secondary focus-visible:bg-surface-secondary focus-visible:text-content'
|
||||||
|
|
||||||
/** An answer or a way on, as a pill under the words. */
|
/** An answer or a way on, as a button under the words. */
|
||||||
const PILL = 'inline-flex items-center gap-1.5 h-7 px-3 rounded-full text-caption font-medium transition-colors'
|
const ACTION = 'inline-flex items-center gap-1.5 h-8 px-3 rounded-lg text-caption font-semibold transition-colors'
|
||||||
|
|
||||||
|
/** Under an hour it reads "5m"; after that the clock time, since the list is headed by its day. */
|
||||||
|
function timeOf(value: string, locale: string, justNow: string): string {
|
||||||
|
const date = createdAt(value)
|
||||||
|
if (Date.now() - date.getTime() < 3_600_000) return compactTime(date.toISOString(), justNow)
|
||||||
|
return new Intl.DateTimeFormat(locale, { hour: '2-digit', minute: '2-digit' }).format(date)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The tile on the start side: the kind's symbol on its colour, and who sent it as a small face on its corner. */
|
||||||
|
function KindTile({ notification }: { notification: InAppNotification }) {
|
||||||
|
const { icon: Icon, tone } = KIND_LOOK[kindOf(notification.title_key)]
|
||||||
|
const initial = notification.sender_username?.charAt(0).toUpperCase()
|
||||||
|
return (
|
||||||
|
<span className="relative block self-start flex-shrink-0">
|
||||||
|
<span className={`grid place-items-center w-10 h-10 rounded-xl ${tone}`}>
|
||||||
|
<Icon className="w-[18px] h-[18px]" aria-hidden />
|
||||||
|
</span>
|
||||||
|
{notification.sender_avatar ? (
|
||||||
|
<img src={notification.sender_avatar} alt="" className="absolute -bottom-1 -end-1 w-5 h-5 rounded-full object-cover ring-2 ring-surface-card" />
|
||||||
|
) : initial ? (
|
||||||
|
<span aria-hidden className="absolute -bottom-1 -end-1 grid place-items-center w-5 h-5 rounded-full text-caption leading-none font-bold bg-accent text-accent-text ring-2 ring-surface-card">
|
||||||
|
{initial}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One notification, as a card in the list: who sent it, what it says, and what
|
* One notification: a tile that says at a glance what it is about and who sent
|
||||||
* can be done with it. Unread ones carry a dot in the accent and a faint
|
* it, the message itself, and what can be done with it. Unread ones are set in
|
||||||
* ground; the tools to mark and delete come forward when the card is pointed
|
* bold with a dot in the accent; marking and deleting come forward when the row
|
||||||
* at or focused, so a list reads as messages rather than as rows of buttons.
|
* is pointed at or focused, so a list reads as messages rather than as buttons.
|
||||||
*/
|
*/
|
||||||
export default function InAppNotificationItem({ notification, onClose }: NotificationItemProps): React.ReactElement {
|
export default function InAppNotificationItem({ notification, onClose }: NotificationItemProps): React.ReactElement {
|
||||||
const { t, locale } = useTranslation()
|
const { t, locale } = useTranslation()
|
||||||
@@ -32,112 +56,96 @@ export default function InAppNotificationItem({ notification, onClose }: Notific
|
|||||||
|
|
||||||
const titleText = t(notification.title_key, notification.title_params)
|
const titleText = t(notification.title_key, notification.title_params)
|
||||||
const bodyText = t(notification.text_key, notification.text_params)
|
const bodyText = t(notification.text_key, notification.text_params)
|
||||||
const hasUnknownTitle = titleText === notification.title_key
|
|
||||||
const hasUnknownBody = bodyText === notification.text_key
|
|
||||||
const unread = !notification.is_read
|
const unread = !notification.is_read
|
||||||
const answered = notification.response !== null
|
const answered = notification.response !== null
|
||||||
|
const { positive_text_key: yesKey, negative_text_key: noKey, navigate_text_key: goKey } = notification
|
||||||
|
const asks = notification.type === 'boolean' && yesKey && noKey
|
||||||
|
const leads = notification.type === 'navigate' && goKey && notification.navigate_target
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="group relative flex gap-3.5 px-4 py-3.5 transition-colors hover:bg-surface-hover focus-within:bg-surface-hover">
|
||||||
className={`group relative mx-2 my-1 rounded-xl px-3 py-3 transition-colors hover:bg-surface-hover focus-within:bg-surface-hover ${unread ? 'bg-surface-secondary' : ''}`}
|
<KindTile notification={notification} />
|
||||||
>
|
|
||||||
{unread && <span aria-hidden className="absolute start-1 top-5 w-1.5 h-1.5 rounded-full bg-accent" />}
|
|
||||||
|
|
||||||
<div className="flex gap-3 items-start">
|
<div className="flex-1 min-w-0">
|
||||||
{/* Sender avatar */}
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="flex-shrink-0">
|
<p className={`text-body leading-snug text-content ${unread ? 'font-semibold' : 'font-medium'}`}>{titleText}</p>
|
||||||
{notification.sender_avatar ? (
|
<div className="relative flex-shrink-0 h-6 flex items-center gap-2">
|
||||||
<img src={notification.sender_avatar} alt="" className="w-9 h-9 rounded-xl object-cover" />
|
<span className="text-caption tabular-nums text-content-faint transition-opacity group-hover:opacity-0 group-focus-within:opacity-0">
|
||||||
) : (
|
{timeOf(notification.created_at, locale, t('common.justNow'))}
|
||||||
<div className="w-9 h-9 rounded-xl flex items-center justify-center text-body font-semibold text-accent bg-accent-subtle">
|
</span>
|
||||||
{notification.sender_username
|
{unread && (
|
||||||
? notification.sender_username.charAt(0).toUpperCase()
|
<span aria-hidden className="w-2 h-2 rounded-full bg-accent transition-opacity group-hover:opacity-0 group-focus-within:opacity-0" />
|
||||||
: <User className="w-4 h-4" />}
|
)}
|
||||||
</div>
|
<div className="absolute end-0 top-1/2 -translate-y-1/2 flex items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100">
|
||||||
)}
|
{notification.is_read ? (
|
||||||
</div>
|
<button type="button" onClick={() => markUnread(notification.id)} title={t('notifications.markUnread')} aria-label={t('notifications.markUnread')} className={TOOL}>
|
||||||
|
<Mail className="w-3.5 h-3.5" />
|
||||||
{/* Content */}
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="flex items-start justify-between gap-2">
|
|
||||||
<p className={`text-body leading-snug text-content ${unread ? 'font-semibold' : 'font-medium'}`}>
|
|
||||||
{hasUnknownTitle ? notification.title_key : titleText}
|
|
||||||
</p>
|
|
||||||
<div className="relative flex-shrink-0 h-7 flex items-center">
|
|
||||||
<span className="text-caption tabular-nums text-content-faint transition-opacity group-hover:opacity-0 group-focus-within:opacity-0">
|
|
||||||
{relativeTime(notification.created_at, locale)}
|
|
||||||
</span>
|
|
||||||
<div className="absolute end-0 top-0 flex items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100">
|
|
||||||
{notification.is_read ? (
|
|
||||||
<button type="button" onClick={() => markUnread(notification.id)} title={t('notifications.markUnread')} aria-label={t('notifications.markUnread')} className={TOOL}>
|
|
||||||
<Mail className="w-3.5 h-3.5" />
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<button type="button" onClick={() => markRead(notification.id)} title={t('notifications.markRead')} aria-label={t('notifications.markRead')} className={TOOL}>
|
|
||||||
<CheckCheck className="w-3.5 h-3.5" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => deleteNotification(notification.id)}
|
|
||||||
title={t('notifications.delete')}
|
|
||||||
aria-label={t('notifications.delete')}
|
|
||||||
className={`${TOOL} hover:text-danger focus-visible:text-danger`}
|
|
||||||
>
|
|
||||||
<Trash2 className="w-3.5 h-3.5" />
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
) : (
|
||||||
|
<button type="button" onClick={() => markRead(notification.id)} title={t('notifications.markRead')} aria-label={t('notifications.markRead')} className={TOOL}>
|
||||||
|
<CheckCheck className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => deleteNotification(notification.id)}
|
||||||
|
title={t('notifications.delete')}
|
||||||
|
aria-label={t('notifications.delete')}
|
||||||
|
className={`${TOOL} hover:text-danger focus-visible:text-danger`}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<p className="text-caption mt-0.5 leading-relaxed text-content-muted">
|
<p className="text-body mt-0.5 leading-relaxed text-content-muted">{bodyText}</p>
|
||||||
{hasUnknownBody ? notification.text_key : bodyText}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* Boolean actions */}
|
{asks && (
|
||||||
{notification.type === 'boolean' && notification.positive_text_key && notification.negative_text_key && (
|
<div className="flex flex-wrap gap-2 mt-3">
|
||||||
<div className="flex gap-2 mt-2.5">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleRespond('positive')}
|
|
||||||
disabled={responding || answered}
|
|
||||||
className={`${PILL} ${
|
|
||||||
notification.response === 'positive'
|
|
||||||
? 'bg-accent text-accent-text'
|
|
||||||
: `bg-surface-card shadow-sm ${notification.response === 'negative' ? 'text-content-faint opacity-50' : 'text-content hover:bg-surface-tertiary'}`
|
|
||||||
} ${answered || responding ? 'cursor-default' : 'cursor-pointer'}`}
|
|
||||||
>
|
|
||||||
<Check className="w-3.5 h-3.5" />
|
|
||||||
{t(notification.positive_text_key)}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleRespond('negative')}
|
|
||||||
disabled={responding || answered}
|
|
||||||
className={`${PILL} ${
|
|
||||||
notification.response === 'negative'
|
|
||||||
? 'bg-danger text-white'
|
|
||||||
: `bg-surface-card shadow-sm ${notification.response === 'positive' ? 'text-content-faint opacity-50' : 'text-content hover:bg-surface-tertiary'}`
|
|
||||||
} ${answered || responding ? 'cursor-default' : 'cursor-pointer'}`}
|
|
||||||
>
|
|
||||||
<X className="w-3.5 h-3.5" />
|
|
||||||
{t(notification.negative_text_key)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Navigate action */}
|
|
||||||
{notification.type === 'navigate' && notification.navigate_text_key && notification.navigate_target && (
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleNavigate}
|
onClick={() => handleRespond('positive')}
|
||||||
className={`${PILL} mt-2.5 bg-surface-card shadow-sm text-content hover:bg-accent hover:text-accent-text focus-visible:bg-accent focus-visible:text-accent-text`}
|
disabled={responding || answered}
|
||||||
|
className={`${ACTION} ${
|
||||||
|
notification.response === 'positive'
|
||||||
|
? 'bg-success-soft text-success'
|
||||||
|
: answered
|
||||||
|
? 'border border-edge text-content-faint'
|
||||||
|
: 'bg-accent text-accent-text hover:bg-accent-hover'
|
||||||
|
} disabled:cursor-default`}
|
||||||
>
|
>
|
||||||
{t(notification.navigate_text_key)}
|
<Check className="w-3.5 h-3.5" />
|
||||||
<ArrowRight className="w-3.5 h-3.5 rtl:-scale-x-100" />
|
{t(yesKey)}
|
||||||
</button>
|
</button>
|
||||||
)}
|
<button
|
||||||
</div>
|
type="button"
|
||||||
|
onClick={() => handleRespond('negative')}
|
||||||
|
disabled={responding || answered}
|
||||||
|
className={`${ACTION} ${
|
||||||
|
notification.response === 'negative'
|
||||||
|
? 'bg-danger-soft text-danger'
|
||||||
|
: answered
|
||||||
|
? 'border border-edge text-content-faint'
|
||||||
|
: 'border border-edge text-content hover:bg-surface-secondary'
|
||||||
|
} disabled:cursor-default`}
|
||||||
|
>
|
||||||
|
<X className="w-3.5 h-3.5" />
|
||||||
|
{t(noKey)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{leads && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleNavigate}
|
||||||
|
className={`${ACTION} mt-3 border border-edge text-content hover:bg-accent hover:text-accent-text hover:border-transparent focus-visible:bg-accent focus-visible:text-accent-text`}
|
||||||
|
>
|
||||||
|
{t(goKey)}
|
||||||
|
<ArrowRight className="w-3.5 h-3.5 rtl:-scale-x-100" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import { useTranslation } from '../../i18n';
|
||||||
|
import type { InAppNotification } from '../../store/inAppNotificationStore';
|
||||||
|
import InAppNotificationItem from './InAppNotificationItem';
|
||||||
|
import { groupByDay } from './notificationModel';
|
||||||
|
|
||||||
|
interface NotificationDayListProps {
|
||||||
|
notifications: InAppNotification[];
|
||||||
|
/** Closes the bell's panel after a notification led somewhere. */
|
||||||
|
onClose?: () => void;
|
||||||
|
/** The bell's panel: headings stick to its top and the days need no cards of their own. */
|
||||||
|
compact?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The notifications cut into days, each under its heading (Today, Yesterday,
|
||||||
|
* a weekday, then the date). On the page every day is a card of its own; in
|
||||||
|
* the bell's panel the headings stay at the top while the day scrolls by.
|
||||||
|
*/
|
||||||
|
export default function NotificationDayList({
|
||||||
|
notifications,
|
||||||
|
onClose,
|
||||||
|
compact = false,
|
||||||
|
}: NotificationDayListProps): React.ReactElement {
|
||||||
|
const { locale } = useTranslation();
|
||||||
|
const groups = useMemo(() => groupByDay(notifications, locale), [notifications, locale]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={compact ? '' : 'flex flex-col gap-6'}>
|
||||||
|
{groups.map((group) => (
|
||||||
|
<section key={group.key} aria-label={group.label}>
|
||||||
|
<h2
|
||||||
|
className={
|
||||||
|
compact
|
||||||
|
? 'sticky top-0 z-10 bg-surface-card px-4 pb-1.5 pt-3 text-caption font-semibold text-content-muted'
|
||||||
|
: 'mb-2 px-1 text-caption font-semibold text-content-muted'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{group.label}
|
||||||
|
</h2>
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
compact
|
||||||
|
? 'divide-y divide-edge-secondary'
|
||||||
|
: 'divide-y divide-edge-secondary overflow-hidden rounded-2xl border border-edge bg-surface-card'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{group.items.map((n) => (
|
||||||
|
<InAppNotificationItem key={n.id} notification={n} onClose={onClose} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
/**
|
||||||
|
* FE-NOTIF-MODEL-001 to FE-NOTIF-MODEL-007
|
||||||
|
*
|
||||||
|
* What a notification's tile shows, how the server's time is read, and how the
|
||||||
|
* list is cut into days under headings in the reader's language.
|
||||||
|
*/
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { render, screen } from '../../../tests/helpers/render';
|
||||||
|
import type { InAppNotification } from '../../store/inAppNotificationStore';
|
||||||
|
import NotificationDayList from './NotificationDayList';
|
||||||
|
import { KIND_LOOK, createdAt, dayLabel, groupByDay, kindOf } from './notificationModel';
|
||||||
|
|
||||||
|
const note = (id: number, created_at: string, title_key = 'notif.trip_invite.title'): InAppNotification => ({
|
||||||
|
id,
|
||||||
|
type: 'simple',
|
||||||
|
scope: 'trip',
|
||||||
|
target: 1,
|
||||||
|
sender_id: null,
|
||||||
|
sender_username: null,
|
||||||
|
sender_avatar: null,
|
||||||
|
recipient_id: 1,
|
||||||
|
title_key,
|
||||||
|
title_params: {},
|
||||||
|
text_key: 'notifications.empty',
|
||||||
|
text_params: {},
|
||||||
|
positive_text_key: null,
|
||||||
|
negative_text_key: null,
|
||||||
|
response: null,
|
||||||
|
navigate_text_key: null,
|
||||||
|
navigate_target: null,
|
||||||
|
is_read: false,
|
||||||
|
created_at,
|
||||||
|
});
|
||||||
|
|
||||||
|
const NOW = new Date(2026, 9, 24, 15, 0);
|
||||||
|
const at = (days: number, hour = 10) => new Date(2026, 9, 24 - days, hour, 0);
|
||||||
|
|
||||||
|
describe('notificationModel', () => {
|
||||||
|
it('FE-NOTIF-MODEL-001: every known title has its kind, anything else is the plain bell', () => {
|
||||||
|
expect(kindOf('notif.collab_message.title')).toBe('message');
|
||||||
|
expect(kindOf('notif.replica_failure.title')).toBe('alert');
|
||||||
|
expect(kindOf('plugin.something.title')).toBe('other');
|
||||||
|
for (const look of Object.values(KIND_LOOK)) expect(look.tone).toMatch(/^bg-/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("FE-NOTIF-MODEL-002: the server's UTC text is read as UTC, an ISO string as it is", () => {
|
||||||
|
expect(createdAt('2026-10-24 08:30:00').toISOString()).toBe('2026-10-24T08:30:00.000Z');
|
||||||
|
expect(createdAt('2026-10-24T08:30:00.000Z').toISOString()).toBe('2026-10-24T08:30:00.000Z');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-NOTIF-MODEL-003: today and yesterday by name, the week by weekday, older by date', () => {
|
||||||
|
expect(dayLabel(at(0), 'en', NOW)).toBe('Today');
|
||||||
|
expect(dayLabel(at(1), 'en', NOW)).toBe('Yesterday');
|
||||||
|
expect(dayLabel(at(3), 'en', NOW)).toBe('Wednesday');
|
||||||
|
expect(dayLabel(at(20), 'en', NOW)).toBe('October 4');
|
||||||
|
expect(dayLabel(new Date(2025, 11, 31), 'en', NOW)).toBe('December 31, 2025');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-NOTIF-MODEL-004: the headings follow the language', () => {
|
||||||
|
expect(dayLabel(at(0), 'de', NOW)).toBe('Heute');
|
||||||
|
expect(dayLabel(at(1), 'de', NOW)).toBe('Gestern');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-NOTIF-MODEL-005: the list is cut into days in the order it came', () => {
|
||||||
|
const groups = groupByDay(
|
||||||
|
[note(1, at(0, 12).toISOString()), note(2, at(0, 9).toISOString()), note(3, at(2).toISOString())],
|
||||||
|
'en',
|
||||||
|
NOW
|
||||||
|
);
|
||||||
|
expect(groups.map((g) => [g.label, g.items.map((n) => n.id)])).toEqual([
|
||||||
|
['Today', [1, 2]],
|
||||||
|
['Thursday', [3]],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-NOTIF-MODEL-006: an empty list has no days', () => {
|
||||||
|
expect(groupByDay([], 'en', NOW)).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('NotificationDayList', () => {
|
||||||
|
it('FE-NOTIF-MODEL-007: each day is a section under its heading', () => {
|
||||||
|
const now = new Date();
|
||||||
|
const yesterday = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1, 12);
|
||||||
|
render(<NotificationDayList notifications={[note(1, now.toISOString()), note(2, yesterday.toISOString())]} />);
|
||||||
|
expect(screen.getByRole('heading', { name: 'Today' })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('heading', { name: 'Yesterday' })).toBeInTheDocument();
|
||||||
|
expect(screen.getAllByRole('region')).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import type { LucideIcon } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
Backpack,
|
||||||
|
Bell,
|
||||||
|
BookmarkPlus,
|
||||||
|
CalendarDays,
|
||||||
|
Images,
|
||||||
|
ListChecks,
|
||||||
|
MessageCircle,
|
||||||
|
Mountain,
|
||||||
|
Plane,
|
||||||
|
Sparkles,
|
||||||
|
Ticket,
|
||||||
|
TriangleAlert,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
import type { InAppNotification } from '../../store/inAppNotificationStore';
|
||||||
|
|
||||||
|
/** What a notification is about, read from its title key: it picks the symbol and colour of its tile. */
|
||||||
|
export type NotificationKind =
|
||||||
|
| 'trip'
|
||||||
|
| 'message'
|
||||||
|
| 'booking'
|
||||||
|
| 'packing'
|
||||||
|
| 'todo'
|
||||||
|
| 'vacay'
|
||||||
|
| 'collection'
|
||||||
|
| 'photos'
|
||||||
|
| 'tour'
|
||||||
|
| 'update'
|
||||||
|
| 'alert'
|
||||||
|
| 'other';
|
||||||
|
|
||||||
|
const KIND_BY_TITLE: Record<string, NotificationKind> = {
|
||||||
|
'notif.trip_invite.title': 'trip',
|
||||||
|
'notif.trip_reminder.title': 'trip',
|
||||||
|
'notif.collab_message.title': 'message',
|
||||||
|
'notif.booking_change.title': 'booking',
|
||||||
|
'notif.packing_tagged.title': 'packing',
|
||||||
|
'notif.todo_due.title': 'todo',
|
||||||
|
'notif.vacay_invite.title': 'vacay',
|
||||||
|
'notif.vacay_share.title': 'vacay',
|
||||||
|
'notif.collection_invite.title': 'collection',
|
||||||
|
'notif.photos_shared.title': 'photos',
|
||||||
|
'notif.wanderer_source_hint.title': 'tour',
|
||||||
|
'notif.paddle_region_requested.title': 'tour',
|
||||||
|
'notif.version_available.title': 'update',
|
||||||
|
'notifications.versionAvailable.title': 'update',
|
||||||
|
'notif.replica_failure.title': 'alert',
|
||||||
|
'notifications.synologySessionCleared.title': 'alert',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function kindOf(titleKey: string): NotificationKind {
|
||||||
|
return KIND_BY_TITLE[titleKey] ?? 'other';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The symbol of each kind and the tinted tile it sits on. */
|
||||||
|
export const KIND_LOOK: Record<NotificationKind, { icon: LucideIcon; tone: string }> = {
|
||||||
|
trip: { icon: Plane, tone: 'bg-info-soft text-info' },
|
||||||
|
message: { icon: MessageCircle, tone: 'bg-success-soft text-success' },
|
||||||
|
booking: { icon: Ticket, tone: 'bg-warning-soft text-warning' },
|
||||||
|
packing: { icon: Backpack, tone: 'bg-warning-soft text-warning' },
|
||||||
|
todo: { icon: ListChecks, tone: 'bg-success-soft text-success' },
|
||||||
|
vacay: { icon: CalendarDays, tone: 'bg-info-soft text-info' },
|
||||||
|
collection: { icon: BookmarkPlus, tone: 'bg-accent-subtle text-accent' },
|
||||||
|
photos: { icon: Images, tone: 'bg-warning-soft text-warning' },
|
||||||
|
tour: { icon: Mountain, tone: 'bg-success-soft text-success' },
|
||||||
|
update: { icon: Sparkles, tone: 'bg-accent-subtle text-accent' },
|
||||||
|
alert: { icon: TriangleAlert, tone: 'bg-danger-soft text-danger' },
|
||||||
|
other: { icon: Bell, tone: 'bg-surface-secondary text-content-secondary' },
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When a notification was made. The server writes `YYYY-MM-DD HH:MM:SS` in UTC,
|
||||||
|
* which a bare `new Date()` would read as local time.
|
||||||
|
*/
|
||||||
|
export function createdAt(value: string): Date {
|
||||||
|
return new Date(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}/.test(value) ? `${value.replace(' ', 'T')}Z` : value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dayStart = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The heading of the day a notification belongs to, in the reader's language:
|
||||||
|
* today and yesterday by name, the rest of the week by weekday, anything older
|
||||||
|
* by its date (with the year when it is not this one).
|
||||||
|
*/
|
||||||
|
export function dayLabel(date: Date, locale: string, now: Date = new Date()): string {
|
||||||
|
const days = Math.round((dayStart(now) - dayStart(date)) / 86_400_000);
|
||||||
|
if (days <= 1) {
|
||||||
|
const word = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' }).format(-Math.max(days, 0), 'day');
|
||||||
|
return word.charAt(0).toLocaleUpperCase(locale) + word.slice(1);
|
||||||
|
}
|
||||||
|
if (days < 7) return new Intl.DateTimeFormat(locale, { weekday: 'long' }).format(date);
|
||||||
|
return new Intl.DateTimeFormat(locale, {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
...(date.getFullYear() === now.getFullYear() ? {} : { year: 'numeric' }),
|
||||||
|
}).format(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotificationGroup {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
items: InAppNotification[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The list cut into days, in the order it came (newest first). */
|
||||||
|
export function groupByDay(list: InAppNotification[], locale: string, now: Date = new Date()): NotificationGroup[] {
|
||||||
|
const groups: NotificationGroup[] = [];
|
||||||
|
for (const n of list) {
|
||||||
|
const date = createdAt(n.created_at);
|
||||||
|
const key = String(dayStart(date));
|
||||||
|
const current = groups[groups.length - 1];
|
||||||
|
if (current?.key === key) current.items.push(n);
|
||||||
|
else groups.push({ key, label: dayLabel(date, locale, now), items: [n] });
|
||||||
|
}
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
@@ -33,12 +33,6 @@ function mcpEndpointRow() {
|
|||||||
|
|
||||||
const clipboardWriteText = vi.fn().mockResolvedValue(undefined);
|
const clipboardWriteText = vi.fn().mockResolvedValue(undefined);
|
||||||
|
|
||||||
const LAN_CONNECTOR = {
|
|
||||||
mcpUrl: 'http://192.168.1.20:3000/mcp',
|
|
||||||
deepLink: 'https://claude.ai/customize/connectors?modal=add-custom-connector&connectorName=TREK&connectorUrl=x',
|
|
||||||
qrSvg: '<svg xmlns="http://www.w3.org/2000/svg"></svg>',
|
|
||||||
};
|
|
||||||
|
|
||||||
function enableAddons(ids: string[]) {
|
function enableAddons(ids: string[]) {
|
||||||
seedStore(useAddonStore, {
|
seedStore(useAddonStore, {
|
||||||
addons: ids.map(id => ({ id, name: id, type: 'integration', icon: '', enabled: true })),
|
addons: ids.map(id => ({ id, name: id, type: 'integration', icon: '', enabled: true })),
|
||||||
@@ -71,7 +65,6 @@ beforeEach(() => {
|
|||||||
http.get('/api/addons', () => HttpResponse.json({ addons: [] })),
|
http.get('/api/addons', () => HttpResponse.json({ addons: [] })),
|
||||||
http.get('/api/oauth/clients', () => HttpResponse.json({ clients: [] })),
|
http.get('/api/oauth/clients', () => HttpResponse.json({ clients: [] })),
|
||||||
http.get('/api/oauth/sessions', () => HttpResponse.json({ sessions: [] })),
|
http.get('/api/oauth/sessions', () => HttpResponse.json({ sessions: [] })),
|
||||||
http.get('/api/integrations/claude-connector', () => HttpResponse.json(LAN_CONNECTOR)),
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1026,25 +1019,4 @@ describe('IntegrationsTab – failure toasts', () => {
|
|||||||
render(<IntegrationsTab />);
|
render(<IntegrationsTab />);
|
||||||
expect(screen.queryByText('What would you like to chat with?')).not.toBeInTheDocument();
|
expect(screen.queryByText('What would you like to chat with?')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('FE-COMP-INTEGRATIONS-049: the Claude card explains why a LAN install cannot be added', async () => {
|
|
||||||
enableMcp();
|
|
||||||
render(<IntegrationsTab />);
|
|
||||||
expect(await screen.findByText('Claude can only reach TREK when it is available over https on the internet.')).toBeInTheDocument();
|
|
||||||
expect(screen.queryByRole('link', { name: /Open in Claude/ })).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('FE-COMP-INTEGRATIONS-050: the Claude card links a public install into Claude', async () => {
|
|
||||||
server.use(
|
|
||||||
http.get('/api/integrations/claude-connector', () =>
|
|
||||||
HttpResponse.json({ ...LAN_CONNECTOR, mcpUrl: 'https://trip.example.org/mcp' }),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
enableMcp();
|
|
||||||
render(<IntegrationsTab />);
|
|
||||||
const link = await screen.findByRole('link', { name: /Open in Claude/ });
|
|
||||||
expect(link).toHaveAttribute('href', LAN_CONNECTOR.deepLink);
|
|
||||||
expect(link).toHaveAttribute('target', '_blank');
|
|
||||||
expect(screen.getByText('https://trip.example.org/mcp')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import AirTrailConnectionSection from './AirTrailConnectionSection'
|
|||||||
import DawarichConnectionSection from './DawarichConnectionSection'
|
import DawarichConnectionSection from './DawarichConnectionSection'
|
||||||
import LlmConnectionSection from './LlmConnectionSection'
|
import LlmConnectionSection from './LlmConnectionSection'
|
||||||
import AiConnectionSection from './ai/AiConnectionSection'
|
import AiConnectionSection from './ai/AiConnectionSection'
|
||||||
import ClaudeConnectorCard from './ai/ClaudeConnectorCard'
|
|
||||||
import ApiKeysSection from './ApiKeysSection'
|
import ApiKeysSection from './ApiKeysSection'
|
||||||
import ScopeGroupPicker from '../OAuth/ScopeGroupPicker'
|
import ScopeGroupPicker from '../OAuth/ScopeGroupPicker'
|
||||||
import ConfirmDialog from '../shared/ConfirmDialog'
|
import ConfirmDialog from '../shared/ConfirmDialog'
|
||||||
@@ -45,7 +44,6 @@ export default function IntegrationsTab(): React.ReactElement {
|
|||||||
one should not have to read past a section about AI assistants. */}
|
one should not have to read past a section about AI assistants. */}
|
||||||
<ApiKeysSection />
|
<ApiKeysSection />
|
||||||
{/* TREK inside Claude rides on the MCP server, so it follows that addon. */}
|
{/* TREK inside Claude rides on the MCP server, so it follows that addon. */}
|
||||||
{S.mcpEnabled && <ClaudeConnectorCard />}
|
|
||||||
{S.mcpEnabled && <IntegrationsMcpSection S={S} />}
|
{S.mcpEnabled && <IntegrationsMcpSection S={S} />}
|
||||||
<McpTokenModals S={S} />
|
<McpTokenModals S={S} />
|
||||||
<OAuthClientModals S={S} />
|
<OAuthClientModals S={S} />
|
||||||
|
|||||||
@@ -3,8 +3,7 @@ import userEvent from '@testing-library/user-event';
|
|||||||
import type { AiStatusResponse } from '@trek/shared';
|
import type { AiStatusResponse } from '@trek/shared';
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { fireEvent, render, screen, waitFor } from '../../../../tests/helpers/render';
|
import { fireEvent, render, screen, waitFor } from '../../../../tests/helpers/render';
|
||||||
import { resetAllStores, seedStore } from '../../../../tests/helpers/store';
|
import { resetAllStores } from '../../../../tests/helpers/store';
|
||||||
import { useAddonStore } from '../../../store/addonStore';
|
|
||||||
import { useAssistantStore } from '../../../store/assistantStore';
|
import { useAssistantStore } from '../../../store/assistantStore';
|
||||||
|
|
||||||
const repo = vi.hoisted(() => ({
|
const repo = vi.hoisted(() => ({
|
||||||
@@ -173,17 +172,11 @@ describe('AiConnectPanel', () => {
|
|||||||
expect(screen.getByRole('button', { name: /Connect OpenRouter/ })).toBeInTheDocument();
|
expect(screen.getByRole('button', { name: /Connect OpenRouter/ })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('FE-COMP-AI-CONNECT-PANEL-008: "Using Claude?" shows with the MCP addon and the sharing details fold out', async () => {
|
it('FE-COMP-AI-CONNECT-PANEL-008: the sharing details fold out', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const { unmount } = render(<AiConnectPanel scope="user" onConnected={vi.fn()} />);
|
render(<AiConnectPanel scope="user" onConnected={vi.fn()} />);
|
||||||
expect(screen.queryByText('Using Claude? Connect in Claude')).not.toBeInTheDocument();
|
|
||||||
await user.click(screen.getByRole('button', { name: /What gets shared\?/ }));
|
await user.click(screen.getByRole('button', { name: /What gets shared\?/ }));
|
||||||
expect(screen.getByText('Not your password, your files or your other trips.')).toBeInTheDocument();
|
expect(screen.getByText('Not your password, your files or your other trips.')).toBeInTheDocument();
|
||||||
unmount();
|
|
||||||
|
|
||||||
seedStore(useAddonStore, { addons: [{ id: 'mcp', name: 'MCP', type: 'integration', icon: '', enabled: true }] });
|
|
||||||
render(<AiConnectPanel scope="user" onConnected={vi.fn()} />);
|
|
||||||
expect(screen.getByRole('button', { name: 'Using Claude? Connect in Claude' })).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('FE-COMP-AI-CONNECT-PANEL-009: the admin signing in with ChatGPT is told the plan is shared, and the sign-in is for everyone', async () => {
|
it('FE-COMP-AI-CONNECT-PANEL-009: the admin signing in with ChatGPT is told the plan is shared, and the sign-in is for everyone', async () => {
|
||||||
|
|||||||
@@ -2,14 +2,13 @@ import type { AiApiKeyKind, AiConnectionScope, AiParsingPrefill } from '@trek/sh
|
|||||||
import { ChevronRight, KeyRound, Loader2, Server, ShieldCheck } from 'lucide-react';
|
import { ChevronRight, KeyRound, Loader2, Server, ShieldCheck } from 'lucide-react';
|
||||||
import React, { useId, useState } from 'react';
|
import React, { useId, useState } from 'react';
|
||||||
import { useTranslation } from '../../../i18n';
|
import { useTranslation } from '../../../i18n';
|
||||||
import { useAddonStore } from '../../../store/addonStore';
|
|
||||||
import { useAssistantStore } from '../../../store/assistantStore';
|
import { useAssistantStore } from '../../../store/assistantStore';
|
||||||
import { AI_PROVIDERS, API_KEY_KINDS, providerLabel } from '../../Assistant/providerCatalog';
|
import { AI_PROVIDERS, API_KEY_KINDS, providerLabel } from '../../Assistant/providerCatalog';
|
||||||
import { fs } from '../../shared/DialogShell';
|
import { fs } from '../../shared/DialogShell';
|
||||||
import { EditorField, GRID_2, INPUT } from '../../shared/dialogParts';
|
import { EditorField, GRID_2, INPUT } from '../../shared/dialogParts';
|
||||||
import { SETTINGS_BUTTON, SETTINGS_BUTTON_PRIMARY, SettingsHint, StatusPill } from '../settingsKit';
|
import { SETTINGS_BUTTON, SETTINGS_BUTTON_PRIMARY, SettingsHint, StatusPill } from '../settingsKit';
|
||||||
import ChatGptPasteStep, { ConnectErrorNote } from './ChatGptPasteStep';
|
import ChatGptPasteStep, { ConnectErrorNote } from './ChatGptPasteStep';
|
||||||
import { CLAUDE_CONNECTOR_ANCHOR, type AiConnectMode } from './aiConnectModel';
|
import { type AiConnectMode } from './aiConnectModel';
|
||||||
import { useChatgptConnect, useCustomConnect, useKeyConnect, useOpenrouterStart } from './useAiConnect';
|
import { useChatgptConnect, useCustomConnect, useKeyConnect, useOpenrouterStart } from './useAiConnect';
|
||||||
|
|
||||||
interface AiConnectPanelProps {
|
interface AiConnectPanelProps {
|
||||||
@@ -399,7 +398,6 @@ export default function AiConnectPanel({ scope, onConnected, initialMode, prefil
|
|||||||
const chatgpt = useChatgptConnect({ active: mode === 'chatgpt', onConnected, scope });
|
const chatgpt = useChatgptConnect({ active: mode === 'chatgpt', onConnected, scope });
|
||||||
const keyConnect = useKeyConnect({ scope, prefill, onConnected });
|
const keyConnect = useKeyConnect({ scope, prefill, onConnected });
|
||||||
const custom = useCustomConnect({ prefill, onConnected });
|
const custom = useCustomConnect({ prefill, onConnected });
|
||||||
const mcpEnabled = useAddonStore((s) => s.isEnabled('mcp'));
|
|
||||||
|
|
||||||
// The privacy line names whoever would receive the messages, once that is known.
|
// The privacy line names whoever would receive the messages, once that is known.
|
||||||
const receivers: Record<AiConnectMode, string> = {
|
const receivers: Record<AiConnectMode, string> = {
|
||||||
@@ -450,18 +448,6 @@ export default function AiConnectPanel({ scope, onConnected, initialMode, prefil
|
|||||||
{mode === 'custom' && <CustomForm custom={custom} />}
|
{mode === 'custom' && <CustomForm custom={custom} />}
|
||||||
|
|
||||||
<PrivacyNote provider={mode ? receivers[mode] : ''} />
|
<PrivacyNote provider={mode ? receivers[mode] : ''} />
|
||||||
{scope === 'user' && mcpEnabled && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() =>
|
|
||||||
document.getElementById(CLAUDE_CONNECTOR_ANCHOR)?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
|
||||||
}
|
|
||||||
className="self-start font-medium text-content-muted underline underline-offset-2 hover:text-content"
|
|
||||||
style={fs(12, 'body')}
|
|
||||||
>
|
|
||||||
{t('assistant.connect.claudeLink')}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,63 +0,0 @@
|
|||||||
// FE-COMP-CLAUDE-CONNECTOR-001 to FE-COMP-CLAUDE-CONNECTOR-003: adding TREK to Claude as a connector.
|
|
||||||
import userEvent from '@testing-library/user-event';
|
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
||||||
import { render, screen, waitFor } from '../../../../tests/helpers/render';
|
|
||||||
|
|
||||||
const repo = vi.hoisted(() => ({ claudeConnector: vi.fn() }));
|
|
||||||
const clipboard = vi.hoisted(() => ({ copyText: vi.fn() }));
|
|
||||||
vi.mock('../../../repo/aiConnectionsRepo', () => ({ aiConnectionsRepo: repo }));
|
|
||||||
vi.mock('../../../utils/clipboard', () => clipboard);
|
|
||||||
|
|
||||||
const { default: ClaudeConnectorCard } = await import('./ClaudeConnectorCard');
|
|
||||||
|
|
||||||
const CONNECTOR = {
|
|
||||||
mcpUrl: 'https://trip.example.org/mcp',
|
|
||||||
deepLink:
|
|
||||||
'https://claude.ai/customize/connectors?modal=add-custom-connector&connectorName=TREK&connectorUrl=https%3A%2F%2Ftrip.example.org%2Fmcp',
|
|
||||||
qrSvg: '<svg xmlns="http://www.w3.org/2000/svg"><rect width="1" height="1"/></svg>',
|
|
||||||
};
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
clipboard.copyText.mockResolvedValue(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('ClaudeConnectorCard', () => {
|
|
||||||
it('FE-COMP-CLAUDE-CONNECTOR-001: links a public install into Claude, with a QR code that is an image, not markup', async () => {
|
|
||||||
repo.claudeConnector.mockResolvedValue(CONNECTOR);
|
|
||||||
const { container } = render(<ClaudeConnectorCard />);
|
|
||||||
const link = await screen.findByRole('link', { name: /Open in Claude/ });
|
|
||||||
expect(link).toHaveAttribute('href', CONNECTOR.deepLink);
|
|
||||||
expect(link).toHaveAttribute('rel', 'noopener noreferrer');
|
|
||||||
const qr = screen.getByAltText('QR code to add TREK in Claude');
|
|
||||||
expect(qr.getAttribute('src')).toMatch(/^data:image\/svg\+xml;charset=utf-8,/);
|
|
||||||
// The QR code's own markup never lands in the page; icons may draw rects of their own.
|
|
||||||
expect(container.querySelector('rect[width="1"][height="1"]')).toBeNull();
|
|
||||||
expect(document.getElementById('claude-connector')).not.toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('FE-COMP-CLAUDE-CONNECTOR-002: copies the address and shows the tick for a moment', async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
repo.claudeConnector.mockResolvedValue(CONNECTOR);
|
|
||||||
render(<ClaudeConnectorCard />);
|
|
||||||
await user.click(await screen.findByRole('button', { name: 'Copy address' }));
|
|
||||||
expect(clipboard.copyText).toHaveBeenCalledWith(CONNECTOR.mcpUrl);
|
|
||||||
await waitFor(() => expect(document.querySelector('.text-success')).not.toBeNull());
|
|
||||||
});
|
|
||||||
|
|
||||||
it('FE-COMP-CLAUDE-CONNECTOR-003: a plain-http or LAN install gets the reason instead of a link, and a failed read only the description', async () => {
|
|
||||||
repo.claudeConnector.mockResolvedValueOnce({ ...CONNECTOR, mcpUrl: 'http://192.168.1.20:3000/mcp' });
|
|
||||||
const { unmount } = render(<ClaudeConnectorCard />);
|
|
||||||
expect(
|
|
||||||
await screen.findByText('Claude can only reach TREK when it is available over https on the internet.')
|
|
||||||
).toBeInTheDocument();
|
|
||||||
expect(screen.queryByRole('link')).not.toBeInTheDocument();
|
|
||||||
unmount();
|
|
||||||
|
|
||||||
repo.claudeConnector.mockRejectedValueOnce(new Error('404'));
|
|
||||||
render(<ClaudeConnectorCard />);
|
|
||||||
expect(screen.getByText('Use TREK right inside Claude: add it as a connector, sign in, done.')).toBeInTheDocument();
|
|
||||||
await waitFor(() => expect(repo.claudeConnector).toHaveBeenCalledTimes(2));
|
|
||||||
expect(screen.queryByRole('link')).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
import { Check, Copy, ExternalLink, MessagesSquare } from 'lucide-react';
|
|
||||||
import React from 'react';
|
|
||||||
import { useTranslation } from '../../../i18n';
|
|
||||||
import { fs } from '../../shared/DialogShell';
|
|
||||||
import { Tooltip } from '../../shared/Tooltip';
|
|
||||||
import { SETTINGS_BUTTON_PRIMARY, SETTINGS_ICON_BUTTON, SettingsCard, SettingsHint } from '../settingsKit';
|
|
||||||
import { CLAUDE_CONNECTOR_ANCHOR } from './aiConnectModel';
|
|
||||||
import { useClaudeConnector } from './useClaudeConnector';
|
|
||||||
|
|
||||||
type ClaudeConnector = ReturnType<typeof useClaudeConnector>;
|
|
||||||
|
|
||||||
/** The way in: the button that adds TREK on claude.ai, the address to copy, and the QR code for a phone. */
|
|
||||||
function ConnectorLinks({
|
|
||||||
connector,
|
|
||||||
qrSrc,
|
|
||||||
copied,
|
|
||||||
copyUrl,
|
|
||||||
}: Omit<ClaudeConnector, 'connector' | 'reachable'> & { connector: NonNullable<ClaudeConnector['connector']> }) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
return (
|
|
||||||
<div className="flex flex-wrap items-center gap-5">
|
|
||||||
<div className="flex min-w-0 flex-1 basis-64 flex-col gap-3">
|
|
||||||
<div className="flex">
|
|
||||||
<a
|
|
||||||
href={connector.deepLink}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className={SETTINGS_BUTTON_PRIMARY}
|
|
||||||
style={fs(13, 'body')}
|
|
||||||
>
|
|
||||||
<ExternalLink size={14} strokeWidth={2.2} /> {t('assistant.claude.open')}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<code
|
|
||||||
dir="ltr"
|
|
||||||
className="block min-w-0 flex-1 truncate rounded-[10px] border border-edge-faint bg-surface-tertiary px-3 py-2 font-mono text-content"
|
|
||||||
style={fs(12, 'body')}
|
|
||||||
>
|
|
||||||
{connector.mcpUrl}
|
|
||||||
</code>
|
|
||||||
<Tooltip label={copied ? t('common.copied') : t('assistant.claude.copyUrl')}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => void copyUrl()}
|
|
||||||
aria-label={t('assistant.claude.copyUrl')}
|
|
||||||
className={SETTINGS_ICON_BUTTON}
|
|
||||||
>
|
|
||||||
{copied ? (
|
|
||||||
<Check size={15} strokeWidth={2.2} className="text-success" />
|
|
||||||
) : (
|
|
||||||
<Copy size={15} strokeWidth={2} />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<figure className="m-0 flex flex-none flex-col items-center gap-1.5 max-sm:hidden">
|
|
||||||
<img
|
|
||||||
src={qrSrc}
|
|
||||||
alt={t('assistant.claude.qrAlt')}
|
|
||||||
width={112}
|
|
||||||
height={112}
|
|
||||||
className="rounded-[10px] shadow-sm ring-1 ring-edge-faint"
|
|
||||||
/>
|
|
||||||
<figcaption className="text-content-faint" style={fs(11)}>
|
|
||||||
{t('assistant.claude.scan')}
|
|
||||||
</figcaption>
|
|
||||||
</figure>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* "Connect with Claude": TREK as a connector inside Claude, over the MCP
|
|
||||||
* server and its OAuth. One button adds it on claude.ai; the QR code does the
|
|
||||||
* same from a phone, so it only shows where a phone is not already in hand.
|
|
||||||
*/
|
|
||||||
export default function ClaudeConnectorCard(): React.ReactElement {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { connector, reachable, ...links } = useClaudeConnector();
|
|
||||||
|
|
||||||
// Null keeps the card to its band until the address is known.
|
|
||||||
let body: React.ReactNode = null;
|
|
||||||
if (connector && reachable) body = <ConnectorLinks connector={connector} {...links} />;
|
|
||||||
else if (connector) body = <SettingsHint>{t('assistant.claude.notPublic')}</SettingsHint>;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SettingsCard
|
|
||||||
id={CLAUDE_CONNECTOR_ANCHOR}
|
|
||||||
icon={MessagesSquare}
|
|
||||||
title={t('assistant.claude.title')}
|
|
||||||
hint={t('assistant.claude.description')}
|
|
||||||
>
|
|
||||||
{body}
|
|
||||||
</SettingsCard>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -9,9 +9,6 @@ import { OnlineOnlyError } from '../../../repo/onlineOnly';
|
|||||||
/** How a connection is being set up: a ChatGPT sign-in, a pasted key, or (admin only) an own server. */
|
/** How a connection is being set up: a ChatGPT sign-in, a pasted key, or (admin only) an own server. */
|
||||||
export type AiConnectMode = 'chatgpt' | 'key' | 'custom';
|
export type AiConnectMode = 'chatgpt' | 'key' | 'custom';
|
||||||
|
|
||||||
/** Where the Claude connector card sits, for the "Using Claude?" line to jump to. */
|
|
||||||
export const CLAUDE_CONNECTOR_ANCHOR = 'claude-connector';
|
|
||||||
|
|
||||||
/** The `code` of a refused AI request (`{ error, code }`), or null when the failure carries none. */
|
/** The `code` of a refused AI request (`{ error, code }`), or null when the failure carries none. */
|
||||||
export function refusalCode(err: unknown): string | null {
|
export function refusalCode(err: unknown): string | null {
|
||||||
if (err instanceof OnlineOnlyError) return 'offline';
|
if (err instanceof OnlineOnlyError) return 'offline';
|
||||||
|
|||||||
@@ -1,56 +0,0 @@
|
|||||||
import type { AiClaudeConnectorResponse } from '@trek/shared';
|
|
||||||
import { useEffect, useRef, useState } from 'react';
|
|
||||||
import { aiConnectionsRepo } from '../../../repo/aiConnectionsRepo';
|
|
||||||
import { copyText } from '../../../utils/clipboard';
|
|
||||||
import { isPublicHttpsUrl, svgDataUrl } from './aiConnectModel';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Adding TREK to Claude as a connector (no AI connection of TREK's own
|
|
||||||
* involved): the MCP address, the claude.ai link that adds it, and a QR code
|
|
||||||
* for doing it from a phone. Claude reaches TREK from the internet, so on a
|
|
||||||
* LAN or plain-http install the card explains that instead of offering a link
|
|
||||||
* that would fail after the user did everything right.
|
|
||||||
*/
|
|
||||||
export function useClaudeConnector() {
|
|
||||||
const [connector, setConnector] = useState<AiClaudeConnectorResponse | null>(null);
|
|
||||||
const [copied, setCopied] = useState(false);
|
|
||||||
const copiedTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let cancelled = false;
|
|
||||||
aiConnectionsRepo.claudeConnector().then(
|
|
||||||
(next) => {
|
|
||||||
if (!cancelled) setConnector(next);
|
|
||||||
},
|
|
||||||
() => {
|
|
||||||
// Without an answer the card keeps to its description; nothing to link to.
|
|
||||||
if (!cancelled) setConnector(null);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(
|
|
||||||
() => () => {
|
|
||||||
if (copiedTimer.current) clearTimeout(copiedTimer.current);
|
|
||||||
},
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
const copyUrl = async () => {
|
|
||||||
if (!connector || !(await copyText(connector.mcpUrl))) return;
|
|
||||||
setCopied(true);
|
|
||||||
if (copiedTimer.current) clearTimeout(copiedTimer.current);
|
|
||||||
copiedTimer.current = setTimeout(() => setCopied(false), 2000);
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
connector,
|
|
||||||
reachable: connector ? isPublicHttpsUrl(connector.mcpUrl) : false,
|
|
||||||
qrSrc: connector ? svgDataUrl(connector.qrSvg) : '',
|
|
||||||
copied,
|
|
||||||
copyUrl,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,15 +1,10 @@
|
|||||||
import { Check, Copy, ExternalLink, MessagesSquare } from 'lucide-react'
|
|
||||||
import { useTranslation } from '../../../i18n'
|
|
||||||
import MPhotoProvidersSection from './MPhotoProvidersSection'
|
import MPhotoProvidersSection from './MPhotoProvidersSection'
|
||||||
import MAirTrailConnectionSection from './MAirTrailConnectionSection'
|
import MAirTrailConnectionSection from './MAirTrailConnectionSection'
|
||||||
import MDawarichConnectionSection from './MDawarichConnectionSection'
|
import MDawarichConnectionSection from './MDawarichConnectionSection'
|
||||||
import MLlmConnectionSection from './MLlmConnectionSection'
|
import MLlmConnectionSection from './MLlmConnectionSection'
|
||||||
import MAiConnectionSection from './MAiConnectionSection'
|
import MAiConnectionSection from './MAiConnectionSection'
|
||||||
import MSettingsMcp from './MSettingsMcp'
|
import MSettingsMcp from './MSettingsMcp'
|
||||||
import { MSetButton, MSetCard, MSetHint } from './MSettingsUi'
|
|
||||||
import { useIntegrationGates } from '../../../components/Settings/useIntegrationGates'
|
import { useIntegrationGates } from '../../../components/Settings/useIntegrationGates'
|
||||||
import { useClaudeConnector } from '../../../components/Settings/ai/useClaudeConnector'
|
|
||||||
import { CLAUDE_CONNECTOR_ANCHOR } from '../../../components/Settings/ai/aiConnectModel'
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* "Integrations" section. The photo-provider / AirTrail / LLM connection forms
|
* "Integrations" section. The photo-provider / AirTrail / LLM connection forms
|
||||||
@@ -33,35 +28,7 @@ export default function MSettingsIntegrations() {
|
|||||||
a managed install. The per-user fallback exists for people who supply their
|
a managed install. The per-user fallback exists for people who supply their
|
||||||
own key, and there nobody does. */}
|
own key, and there nobody does. */}
|
||||||
{llmEnabled && ownParsing && !managed && <MLlmConnectionSection />}
|
{llmEnabled && ownParsing && !managed && <MLlmConnectionSection />}
|
||||||
{mcpEnabled && <MClaudeConnectorCard />}
|
|
||||||
{mcpEnabled && <MSettingsMcp />}
|
{mcpEnabled && <MSettingsMcp />}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* "Connect with Claude" on the phone: the button and the address only. The QR
|
|
||||||
* code is for reaching a phone from a desktop, and this is the phone.
|
|
||||||
*/
|
|
||||||
function MClaudeConnectorCard() {
|
|
||||||
const { t } = useTranslation()
|
|
||||||
const { connector, reachable, copied, copyUrl } = useClaudeConnector()
|
|
||||||
return (
|
|
||||||
<div id={CLAUDE_CONNECTOR_ANCHOR}>
|
|
||||||
<MSetCard title={t('assistant.claude.title')} icon={MessagesSquare} className="mt-3">
|
|
||||||
<MSetHint className="mb-3">{t('assistant.claude.description')}</MSetHint>
|
|
||||||
{connector && !reachable && <MSetHint>{t('assistant.claude.notPublic')}</MSetHint>}
|
|
||||||
{connector && reachable && (
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
<MSetButton onClick={() => window.open(connector.deepLink, '_blank', 'noopener,noreferrer')}>
|
|
||||||
<ExternalLink size={14} /> {t('assistant.claude.open')}
|
|
||||||
</MSetButton>
|
|
||||||
<MSetButton variant="ghost" onClick={() => void copyUrl()}>
|
|
||||||
{copied ? <Check size={14} /> : <Copy size={14} />} {copied ? t('common.copied') : t('assistant.claude.copyUrl')}
|
|
||||||
</MSetButton>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</MSetCard>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import HelpAnchor from '../components/Help/HelpAnchor'
|
|||||||
import EmptyState from '../components/shared/EmptyState'
|
import EmptyState from '../components/shared/EmptyState'
|
||||||
import ConfirmDialog from '../components/shared/ConfirmDialog'
|
import ConfirmDialog from '../components/shared/ConfirmDialog'
|
||||||
import { Spinner } from '../components/shared/Spinner'
|
import { Spinner } from '../components/shared/Spinner'
|
||||||
import InAppNotificationItem from '../components/Notifications/InAppNotificationItem.tsx'
|
import NotificationDayList from '../components/Notifications/NotificationDayList'
|
||||||
import { useInAppNotifications } from './inAppNotifications/useInAppNotifications'
|
import { useInAppNotifications } from './inAppNotifications/useInAppNotifications'
|
||||||
|
|
||||||
export default function InAppNotificationsPage(): React.ReactElement {
|
export default function InAppNotificationsPage(): React.ReactElement {
|
||||||
@@ -88,27 +88,25 @@ function InAppNotificationsPageDesktop(): React.ReactElement {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Notification list */}
|
{/* The list, one card per day. */}
|
||||||
<div className="rounded-2xl border overflow-hidden border-edge bg-surface-card py-1">
|
{isLoading && displayed.length === 0 ? (
|
||||||
{isLoading && displayed.length === 0 ? (
|
<div className="flex items-center justify-center py-16">
|
||||||
<div className="flex items-center justify-center py-16">
|
<Spinner className="w-6 h-6 border-2 border-edge border-t-current" />
|
||||||
<Spinner className="w-6 h-6 border-2 border-slate-200 border-t-current" />
|
</div>
|
||||||
</div>
|
) : displayed.length === 0 ? (
|
||||||
) : displayed.length === 0 ? (
|
<div className="rounded-2xl border border-edge bg-surface-card">
|
||||||
<EmptyState scene="notifications" title={t('notifications.empty')} />
|
<EmptyState scene="notifications" title={t('notifications.empty')} />
|
||||||
) : (
|
</div>
|
||||||
displayed.map(n => (
|
) : (
|
||||||
<InAppNotificationItem key={n.id} notification={n} />
|
<NotificationDayList notifications={displayed} />
|
||||||
))
|
)}
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Infinite scroll trigger */}
|
{/* Infinite scroll trigger */}
|
||||||
{hasMore && (
|
{hasMore && (
|
||||||
<div ref={loaderRef} className="flex items-center justify-center py-4">
|
<div ref={loaderRef} className="flex items-center justify-center py-4">
|
||||||
{isLoading && <Spinner className="w-5 h-5 border-2 border-slate-200 border-t-current" />}
|
{isLoading && <Spinner className="w-5 h-5 border-2 border-edge border-t-current" />}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
isOpen={confirmingDeleteAll}
|
isOpen={confirmingDeleteAll}
|
||||||
|
|||||||
@@ -126,6 +126,5 @@ describe('aiConnectionsRepo', () => {
|
|||||||
await expect(aiConnectionsRepo.disconnect()).rejects.toBeInstanceOf(OnlineOnlyError);
|
await expect(aiConnectionsRepo.disconnect()).rejects.toBeInstanceOf(OnlineOnlyError);
|
||||||
await expect(aiConnectionsRepo.chatgptStart()).rejects.toBeInstanceOf(OnlineOnlyError);
|
await expect(aiConnectionsRepo.chatgptStart()).rejects.toBeInstanceOf(OnlineOnlyError);
|
||||||
await expect(aiConnectionsRepo.adminSettings()).rejects.toBeInstanceOf(OnlineOnlyError);
|
await expect(aiConnectionsRepo.adminSettings()).rejects.toBeInstanceOf(OnlineOnlyError);
|
||||||
await expect(aiConnectionsRepo.claudeConnector()).rejects.toBeInstanceOf(OnlineOnlyError);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import {
|
|||||||
aiAdminConnectionResponseSchema,
|
aiAdminConnectionResponseSchema,
|
||||||
aiAdminSettingsSchema,
|
aiAdminSettingsSchema,
|
||||||
aiAuthStartResponseSchema,
|
aiAuthStartResponseSchema,
|
||||||
aiClaudeConnectorResponseSchema,
|
|
||||||
aiConnectionViewSchema,
|
aiConnectionViewSchema,
|
||||||
aiModelListResponseSchema,
|
aiModelListResponseSchema,
|
||||||
aiStatusResponseSchema,
|
aiStatusResponseSchema,
|
||||||
@@ -14,7 +13,7 @@ import {
|
|||||||
type AiOpenrouterFinishRequest,
|
type AiOpenrouterFinishRequest,
|
||||||
type AiPrivacyAckRequest,
|
type AiPrivacyAckRequest,
|
||||||
} from '@trek/shared';
|
} from '@trek/shared';
|
||||||
import { aiAdminApi, aiApi, claudeConnectorApi } from '../api/assistant';
|
import { aiAdminApi, aiApi } from '../api/assistant';
|
||||||
import { requireOnline } from './onlineOnly';
|
import { requireOnline } from './onlineOnly';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -114,11 +113,4 @@ export const aiConnectionsRepo = {
|
|||||||
requireOnline();
|
requireOnline();
|
||||||
return aiModelListResponseSchema.parse(await aiAdminApi.models()).models;
|
return aiModelListResponseSchema.parse(await aiAdminApi.models()).models;
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Claude connector ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async claudeConnector() {
|
|
||||||
requireOnline();
|
|
||||||
return aiClaudeConnectorResponseSchema.parse(await claudeConnectorApi.get());
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -17,5 +17,5 @@
|
|||||||
"src/nest/plugins/runtime/plugin-sdk.ts": 1093,
|
"src/nest/plugins/runtime/plugin-sdk.ts": 1093,
|
||||||
"src/nest/reservations/reservations.service.ts": 1237,
|
"src/nest/reservations/reservations.service.ts": 1237,
|
||||||
"src/nest/trips/trips.service.ts": 1006,
|
"src/nest/trips/trips.service.ts": 1006,
|
||||||
"src/nest/vacay/vacay.service.ts": 1416
|
"src/nest/vacay/vacay.service.ts": 1412
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,26 +0,0 @@
|
|||||||
import { ADDON_IDS } from '../../addons';
|
|
||||||
import { AddonGuard } from '../addons/addon.guard';
|
|
||||||
import { RequireAddon } from '../addons/require-addon.decorator';
|
|
||||||
import { JwtAuthGuard } from '../auth-core/jwt-auth.guard';
|
|
||||||
import { ResponseContract } from '../common/response-contract';
|
|
||||||
import { ClaudeConnectorService } from './claude-connector.service';
|
|
||||||
import { Controller, Get, UseGuards } from '@nestjs/common';
|
|
||||||
import { aiClaudeConnectorResponseSchema, type AiClaudeConnectorResponse } from '@trek/shared';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* /api/integrations/claude-connector — what the settings card needs to add
|
|
||||||
* TREK to claude.ai as a custom connector. It follows the MCP addon (the
|
|
||||||
* connector is the MCP endpoint), not the in-app assistant's addon.
|
|
||||||
*/
|
|
||||||
@Controller('api/integrations/claude-connector')
|
|
||||||
@UseGuards(AddonGuard, JwtAuthGuard)
|
|
||||||
@RequireAddon(ADDON_IDS.MCP, 'MCP')
|
|
||||||
export class ClaudeConnectorController {
|
|
||||||
constructor(private readonly connector: ClaudeConnectorService) {}
|
|
||||||
|
|
||||||
@Get()
|
|
||||||
@ResponseContract(aiClaudeConnectorResponseSchema)
|
|
||||||
async get(): Promise<AiClaudeConnectorResponse> {
|
|
||||||
return await this.connector.describe();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
import { OauthService } from './oauth.service';
|
|
||||||
import { Injectable } from '@nestjs/common';
|
|
||||||
import type { AiClaudeConnectorResponse } from '@trek/shared';
|
|
||||||
|
|
||||||
import QRCode from 'qrcode';
|
|
||||||
|
|
||||||
/** claude.ai's "add custom connector" dialog, prefilled through its query string. */
|
|
||||||
const CLAUDE_ADD_CONNECTOR_URL = 'https://claude.ai/customize/connectors';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* "Connect with Claude": the link that opens claude.ai's custom-connector
|
|
||||||
* dialog with this instance's MCP endpoint filled in, and the same link as a QR
|
|
||||||
* code for the phone. claude.ai then runs the ordinary OAuth flow against
|
|
||||||
* /oauth/authorize; nothing here touches the user's data or calls Anthropic.
|
|
||||||
*/
|
|
||||||
@Injectable()
|
|
||||||
export class ClaudeConnectorService {
|
|
||||||
constructor(private readonly oauth: OauthService) {}
|
|
||||||
|
|
||||||
async describe(): Promise<AiClaudeConnectorResponse> {
|
|
||||||
const mcpUrl = `${this.oauth.mcpSafeUrl().replace(/(?<!\/)\/+$/, '')}/mcp`;
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
modal: 'add-custom-connector',
|
|
||||||
connectorName: 'TREK',
|
|
||||||
connectorUrl: mcpUrl,
|
|
||||||
});
|
|
||||||
const deepLink = `${CLAUDE_ADD_CONNECTOR_URL}?${params.toString()}`;
|
|
||||||
const qrSvg = await QRCode.toString(deepLink, { type: 'svg', margin: 1 });
|
|
||||||
return { mcpUrl, deepLink, qrSvg };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -8,8 +8,6 @@ import { createMcpAddonGate } from '../addons/mcp-addon-gate';
|
|||||||
import { AuditModule } from '../audit/audit.module';
|
import { AuditModule } from '../audit/audit.module';
|
||||||
import { RateLimitModule } from '../common/rate-limit.module';
|
import { RateLimitModule } from '../common/rate-limit.module';
|
||||||
import { SchedulingModule } from '../scheduling/scheduling.module';
|
import { SchedulingModule } from '../scheduling/scheduling.module';
|
||||||
import { ClaudeConnectorController } from './claude-connector.controller';
|
|
||||||
import { ClaudeConnectorService } from './claude-connector.service';
|
|
||||||
import { OauthApiController } from './oauth-api.controller';
|
import { OauthApiController } from './oauth-api.controller';
|
||||||
import { CimdClientResolver } from './oauth-cimd';
|
import { CimdClientResolver } from './oauth-cimd';
|
||||||
import { OauthPublicController } from './oauth-public.controller';
|
import { OauthPublicController } from './oauth-public.controller';
|
||||||
@@ -49,8 +47,7 @@ import type { MiddlewareConsumer, NestModule } from '@nestjs/common';
|
|||||||
* swapped provider too.
|
* swapped provider too.
|
||||||
*
|
*
|
||||||
* Client ID Metadata Documents (oauth-cimd.ts) resolve through the injected
|
* Client ID Metadata Documents (oauth-cimd.ts) resolve through the injected
|
||||||
* CimdClientResolver; ClaudeConnectorController hands the settings page the
|
* CimdClientResolver.
|
||||||
* claude.ai link to this instance's MCP endpoint.
|
|
||||||
*
|
*
|
||||||
* Exports OauthService for AdminController (admin OAuth-session panel) and the
|
* Exports OauthService for AdminController (admin OAuth-session panel) and the
|
||||||
* MCP transport's token verification.
|
* MCP transport's token verification.
|
||||||
@@ -73,11 +70,10 @@ import type { MiddlewareConsumer, NestModule } from '@nestjs/common';
|
|||||||
SchedulingModule,
|
SchedulingModule,
|
||||||
MikroOrmModule.forFeature([OauthClients, OauthTokens, OauthConsents, Users]),
|
MikroOrmModule.forFeature([OauthClients, OauthTokens, OauthConsents, Users]),
|
||||||
],
|
],
|
||||||
controllers: [OauthPublicController, OauthApiController, ClaudeConnectorController],
|
controllers: [OauthPublicController, OauthApiController],
|
||||||
providers: [
|
providers: [
|
||||||
OauthService,
|
OauthService,
|
||||||
CimdClientResolver,
|
CimdClientResolver,
|
||||||
ClaudeConnectorService,
|
|
||||||
TrekClientsStore,
|
TrekClientsStore,
|
||||||
TrekOAuthProvider,
|
TrekOAuthProvider,
|
||||||
OauthTokenRetentionJob,
|
OauthTokenRetentionJob,
|
||||||
|
|||||||
@@ -69,3 +69,23 @@ export function shiftIsoDate(isoDate: string, days: number): string {
|
|||||||
d.setUTCDate(d.getUTCDate() + days);
|
d.setUTCDate(d.getUTCDate() + days);
|
||||||
return d.toISOString().slice(0, 10);
|
return d.toISOString().slice(0, 10);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const CACHE_TTL = 24 * 60 * 60 * 1000;
|
||||||
|
export const FETCH_TIMEOUT_MS = 10_000;
|
||||||
|
// A country's holidays for one year are a few kilobytes; two megabytes means the
|
||||||
|
// provider is misbehaving and we would rather report the usual error than buffer it.
|
||||||
|
export const MAX_HOLIDAY_BYTES = 2 * 1024 * 1024;
|
||||||
|
// Both providers key on a 4-digit year and an ISO 3166-1 alpha-2 code. Anything
|
||||||
|
// else has no business reaching the URL path.
|
||||||
|
export const YEAR_RE = /^\d{4}$/;
|
||||||
|
export const COUNTRY_RE = /^[A-Za-z]{2}$/;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Color palette for auto-assign
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const COLORS = [
|
||||||
|
'#6366f1', '#ec4899', '#14b8a6', '#8b5cf6', '#ef4444',
|
||||||
|
'#3b82f6', '#22c55e', '#06b6d4', '#f43f5e', '#a855f7',
|
||||||
|
'#10b981', '#0ea5e9', '#64748b', '#be185d', '#0d9488',
|
||||||
|
];
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ import { SchoolHolidayRegions } from '../../db/entities/SchoolHolidayRegions.ent
|
|||||||
import type { SchoolHolidayRegionsRepository } from '../../db/repositories/SchoolHolidayRegions.repository';
|
import type { SchoolHolidayRegionsRepository } from '../../db/repositories/SchoolHolidayRegions.repository';
|
||||||
import {
|
import {
|
||||||
daysAlwaysInMonth, isBlockedWeekend, normalizeFraction, normalizeKind, normalizeYearType, pad2, parseAsUtcMillis,
|
daysAlwaysInMonth, isBlockedWeekend, normalizeFraction, normalizeKind, normalizeYearType, pad2, parseAsUtcMillis,
|
||||||
shiftIsoDate, windowEndYear,
|
shiftIsoDate, windowEndYear, CACHE_TTL, COLORS, COUNTRY_RE, FETCH_TIMEOUT_MS, MAX_HOLIDAY_BYTES, YEAR_RE,
|
||||||
} from './vacay.helpers';
|
} from './vacay.helpers';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -125,26 +125,6 @@ export interface VacayShare {
|
|||||||
created_at?: string;
|
created_at?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CACHE_TTL = 24 * 60 * 60 * 1000;
|
|
||||||
const FETCH_TIMEOUT_MS = 10_000;
|
|
||||||
// A country's holidays for one year are a few kilobytes; two megabytes means the
|
|
||||||
// provider is misbehaving and we would rather report the usual error than buffer it.
|
|
||||||
const MAX_HOLIDAY_BYTES = 2 * 1024 * 1024;
|
|
||||||
// Both providers key on a 4-digit year and an ISO 3166-1 alpha-2 code. Anything
|
|
||||||
// else has no business reaching the URL path.
|
|
||||||
const YEAR_RE = /^\d{4}$/;
|
|
||||||
const COUNTRY_RE = /^[A-Za-z]{2}$/;
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Color palette for auto-assign
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const COLORS = [
|
|
||||||
'#6366f1', '#ec4899', '#14b8a6', '#8b5cf6', '#ef4444',
|
|
||||||
'#3b82f6', '#22c55e', '#06b6d4', '#f43f5e', '#a855f7',
|
|
||||||
'#10b981', '#0ea5e9', '#64748b', '#be185d', '#0d9488',
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Vacay domain service — owns the vacay SQL, now through the ten vacay
|
* Vacay domain service — owns the vacay SQL, now through the ten vacay
|
||||||
* repositories (+ `SchoolHolidayRegionsRepository`'s cross-domain read,
|
* repositories (+ `SchoolHolidayRegionsRepository`'s cross-domain read,
|
||||||
@@ -1150,16 +1130,29 @@ export class VacayService {
|
|||||||
// Removing a stray entry on a blocked day stays possible; keeping one
|
// Removing a stray entry on a blocked day stays possible; keeping one
|
||||||
// there (converted in place) does not.
|
// there (converted in place) does not.
|
||||||
if (weekendBlocked) return { error: 'weekend_blocked' };
|
if (weekendBlocked) return { error: 'weekend_blocked' };
|
||||||
await this.entries.updateFractionKind(existing.id, frac, knd);
|
await this.uow.transactional(async () => {
|
||||||
|
await this.entries.updateFractionKind(existing.id, frac, knd);
|
||||||
|
await this.ensureYearOf(planId, userId, date);
|
||||||
|
});
|
||||||
await this.notifyPlanUsers(planId, socketId);
|
await this.notifyPlanUsers(planId, socketId);
|
||||||
return { action: 'updated', fraction: frac, kind: knd };
|
return { action: 'updated', fraction: frac, kind: knd };
|
||||||
}
|
}
|
||||||
if (weekendBlocked) return { error: 'weekend_blocked' };
|
if (weekendBlocked) return { error: 'weekend_blocked' };
|
||||||
await this.entries.insertEntry(planId, userId, date, '', frac, knd);
|
await this.uow.transactional(async () => {
|
||||||
|
await this.entries.insertEntry(planId, userId, date, '', frac, knd);
|
||||||
|
await this.ensureYearOf(planId, userId, date);
|
||||||
|
});
|
||||||
await this.notifyPlanUsers(planId, socketId);
|
await this.notifyPlanUsers(planId, socketId);
|
||||||
return { action: 'added', fraction: frac, kind: knd };
|
return { action: 'added', fraction: frac, kind: knd };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A day written for a year the plan has not opened adds that year, or the calendar never shows it (#737 picks the period). */
|
||||||
|
private async ensureYearOf(planId: number, userId: number, date: string): Promise<void> {
|
||||||
|
const [y, m, d] = date.split('-').map(Number);
|
||||||
|
const year = await this.currentPeriodYear(userId, new Date(y, m - 1, d));
|
||||||
|
if (!(await this.years.exists(planId, year))) await this.addYear(planId, year, undefined);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A company holiday on or off, whole or half (#2439). The same click again clears
|
* A company holiday on or off, whole or half (#2439). The same click again clears
|
||||||
* it; the other size converts it in place, the way a vacation entry toggles. A
|
* it; the other size converts it in place, the way a vacation entry toggles. A
|
||||||
@@ -1179,6 +1172,8 @@ export class VacayService {
|
|||||||
await this.companyHolidays.insertHoliday(planId, date, note || '', frac); // VC120
|
await this.companyHolidays.insertHoliday(planId, date, note || '', frac); // VC120
|
||||||
}
|
}
|
||||||
await this.makeRoomForCompanyHoliday(planId, date, frac);
|
await this.makeRoomForCompanyHoliday(planId, date, frac);
|
||||||
|
const owner = await this.plans.findOwnerId(planId);
|
||||||
|
if (owner) await this.ensureYearOf(planId, owner.owner_id, date);
|
||||||
return { action: existing ? 'updated' : 'added', fraction: frac };
|
return { action: existing ? 'updated' : 'added', fraction: frac };
|
||||||
});
|
});
|
||||||
await this.notifyPlanUsers(planId, socketId);
|
await this.notifyPlanUsers(planId, socketId);
|
||||||
|
|||||||
@@ -160,34 +160,4 @@ describe('OAuth e2e (real guards + temp SQLite)', () => {
|
|||||||
expect(cookie.status).toBe(201);
|
expect(cookie.status).toBe(201);
|
||||||
expect(cookie.body).toEqual({ client_id: 'c1', client_secret: 's' });
|
expect(cookie.body).toEqual({ client_id: 'c1', client_secret: 's' });
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('GET /api/integrations/claude-connector', () => {
|
|
||||||
const MCP_URL = 'https://mcp.test/mcp';
|
|
||||||
|
|
||||||
it('404 { error } while the MCP addon is off, even without a session', async () => {
|
|
||||||
isAddonEnabled.mockReturnValue(false);
|
|
||||||
const res = await request(server).get('/api/integrations/claude-connector');
|
|
||||||
expect(res.status).toBe(404);
|
|
||||||
expect(res.body).toEqual({ error: 'MCP addon is not enabled' });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('401 without a session', async () => {
|
|
||||||
const res = await request(server).get('/api/integrations/claude-connector');
|
|
||||||
expect(res.status).toBe(401);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('200 with the MCP endpoint, the claude.ai link that adds it and that link as a QR code', async () => {
|
|
||||||
const res = await request(server).get('/api/integrations/claude-connector').set('Cookie', sessionCookie(1));
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
expect(res.body.mcpUrl).toBe(MCP_URL);
|
|
||||||
const link = new URL(res.body.deepLink);
|
|
||||||
expect(link.origin + link.pathname).toBe('https://claude.ai/customize/connectors');
|
|
||||||
expect(Object.fromEntries(link.searchParams)).toEqual({
|
|
||||||
modal: 'add-custom-connector',
|
|
||||||
connectorName: 'TREK',
|
|
||||||
connectorUrl: MCP_URL,
|
|
||||||
});
|
|
||||||
expect(res.body.qrSvg).toMatch(/^<svg[\s\S]*<\/svg>\s*$/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -602,14 +602,22 @@ const vacayCases: ParityCase[] = [
|
|||||||
needs: ['vacay'],
|
needs: ['vacay'],
|
||||||
args: () => ({ date: '2027-06-01' }),
|
args: () => ({ date: '2027-06-01' }),
|
||||||
rest: { method: 'post', path: () => `${VACAY}/entries/toggle`, body: () => ({ date: '2027-06-01' }) },
|
rest: { method: 'post', path: () => `${VACAY}/entries/toggle`, body: () => ({ date: '2027-06-01' }) },
|
||||||
expect: [{ event: 'vacay:update', user: 'actor' }],
|
// 2027 is not a year of the plan yet, so the day opens it too.
|
||||||
|
expect: [
|
||||||
|
{ event: 'vacay:settings', user: 'actor' },
|
||||||
|
{ event: 'vacay:update', user: 'actor' },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
tool: 'toggle_company_holiday',
|
tool: 'toggle_company_holiday',
|
||||||
needs: ['vacay'],
|
needs: ['vacay'],
|
||||||
args: () => ({ date: '2027-06-02' }),
|
args: () => ({ date: '2027-06-02' }),
|
||||||
rest: { method: 'post', path: () => `${VACAY}/entries/company-holiday`, body: () => ({ date: '2027-06-02' }) },
|
rest: { method: 'post', path: () => `${VACAY}/entries/company-holiday`, body: () => ({ date: '2027-06-02' }) },
|
||||||
expect: [{ event: 'vacay:update', user: 'actor' }],
|
// 2027 is not a year of the plan yet, so the day opens it too.
|
||||||
|
expect: [
|
||||||
|
{ event: 'vacay:settings', user: 'actor' },
|
||||||
|
{ event: 'vacay:update', user: 'actor' },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
tool: 'update_vacay_stats',
|
tool: 'update_vacay_stats',
|
||||||
|
|||||||
@@ -1,67 +0,0 @@
|
|||||||
import { ADDON_IDS } from '../../../src/addons';
|
|
||||||
import { AddonGuard } from '../../../src/nest/addons/addon.guard';
|
|
||||||
import { REQUIRE_ADDON } from '../../../src/nest/addons/require-addon.decorator';
|
|
||||||
import { JwtAuthGuard } from '../../../src/nest/auth-core/jwt-auth.guard';
|
|
||||||
import { RESPONSE_CONTRACT_KEY } from '../../../src/nest/common/response-contract';
|
|
||||||
import { ClaudeConnectorController } from '../../../src/nest/oauth/claude-connector.controller';
|
|
||||||
import { ClaudeConnectorService } from '../../../src/nest/oauth/claude-connector.service';
|
|
||||||
import { OauthService } from '../../../src/nest/oauth/oauth.service';
|
|
||||||
import { GUARDS_METADATA } from '@nestjs/common/constants';
|
|
||||||
import { Test } from '@nestjs/testing';
|
|
||||||
import { aiClaudeConnectorResponseSchema } from '@trek/shared';
|
|
||||||
|
|
||||||
import { describe, expect, it, vi } from 'vitest';
|
|
||||||
|
|
||||||
async function service(mcpSafeUrl: string): Promise<ClaudeConnectorService> {
|
|
||||||
const moduleRef = await Test.createTestingModule({
|
|
||||||
providers: [ClaudeConnectorService, { provide: OauthService, useValue: { mcpSafeUrl: () => mcpSafeUrl } }],
|
|
||||||
}).compile();
|
|
||||||
return moduleRef.get(ClaudeConnectorService);
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('ClaudeConnectorService', () => {
|
|
||||||
it('CLAUDE-001: links claude.ai to the MCP endpoint of this instance and encodes it once', async () => {
|
|
||||||
const res = await (await service('https://trek.example.test')).describe();
|
|
||||||
expect(res.mcpUrl).toBe('https://trek.example.test/mcp');
|
|
||||||
expect(res.deepLink).toBe(
|
|
||||||
'https://claude.ai/customize/connectors?modal=add-custom-connector&connectorName=TREK' +
|
|
||||||
'&connectorUrl=https%3A%2F%2Ftrek.example.test%2Fmcp',
|
|
||||||
);
|
|
||||||
expect(aiClaudeConnectorResponseSchema.parse(res)).toEqual(res);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('CLAUDE-002: trailing slashes on the configured URL do not double the path', async () => {
|
|
||||||
expect((await (await service('https://trek.example.test/sub///')).describe()).mcpUrl).toBe(
|
|
||||||
'https://trek.example.test/sub/mcp',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('CLAUDE-003: the QR code comes as an SVG document', async () => {
|
|
||||||
const { qrSvg } = await (await service('https://trek.example.test')).describe();
|
|
||||||
expect(qrSvg.startsWith('<svg')).toBe(true);
|
|
||||||
expect(qrSvg.trimEnd().endsWith('</svg>')).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('ClaudeConnectorController', () => {
|
|
||||||
it('CLAUDE-004: answers what the service describes', async () => {
|
|
||||||
const answer = { mcpUrl: 'https://t.example/mcp', deepLink: 'https://claude.ai/x', qrSvg: '<svg/>' };
|
|
||||||
const describeConnector = vi.fn().mockResolvedValue(answer);
|
|
||||||
const controller = new ClaudeConnectorController({
|
|
||||||
describe: describeConnector,
|
|
||||||
} as unknown as ClaudeConnectorService);
|
|
||||||
expect(await controller.get()).toBe(answer);
|
|
||||||
expect(describeConnector).toHaveBeenCalledOnce();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('CLAUDE-005: follows the MCP addon, checks it before the session, and declares its contract', () => {
|
|
||||||
expect(Reflect.getMetadata(GUARDS_METADATA, ClaudeConnectorController)).toEqual([AddonGuard, JwtAuthGuard]);
|
|
||||||
expect(Reflect.getMetadata(REQUIRE_ADDON, ClaudeConnectorController)).toEqual({
|
|
||||||
addonId: ADDON_IDS.MCP,
|
|
||||||
label: 'MCP',
|
|
||||||
});
|
|
||||||
expect(Reflect.getMetadata(RESPONSE_CONTRACT_KEY, ClaudeConnectorController.prototype.get)).toBe(
|
|
||||||
aiClaudeConnectorResponseSchema,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1614,21 +1614,18 @@ describe('OauthModule', () => {
|
|||||||
const { OauthApiController } = await import('../../../src/nest/oauth/oauth-api.controller');
|
const { OauthApiController } = await import('../../../src/nest/oauth/oauth-api.controller');
|
||||||
const { OauthService: Svc } = await import('../../../src/nest/oauth/oauth.service');
|
const { OauthService: Svc } = await import('../../../src/nest/oauth/oauth.service');
|
||||||
|
|
||||||
const { ClaudeConnectorController } = await import('../../../src/nest/oauth/claude-connector.controller');
|
|
||||||
const { ClaudeConnectorService } = await import('../../../src/nest/oauth/claude-connector.service');
|
|
||||||
const { CimdClientResolver } = await import('../../../src/nest/oauth/oauth-cimd');
|
const { CimdClientResolver } = await import('../../../src/nest/oauth/oauth-cimd');
|
||||||
const { TrekClientsStore, TrekOAuthProvider } = await import('../../../src/nest/oauth/oauth-sdk.provider');
|
const { TrekClientsStore, TrekOAuthProvider } = await import('../../../src/nest/oauth/oauth-sdk.provider');
|
||||||
const { OauthTokenRetentionJob } = await import('../../../src/nest/oauth/oauth-token-retention.job');
|
const { OauthTokenRetentionJob } = await import('../../../src/nest/oauth/oauth-token-retention.job');
|
||||||
|
|
||||||
const controllers = Reflect.getMetadata('controllers', OauthModule);
|
const controllers = Reflect.getMetadata('controllers', OauthModule);
|
||||||
const providers = Reflect.getMetadata('providers', OauthModule);
|
const providers = Reflect.getMetadata('providers', OauthModule);
|
||||||
expect(controllers).toEqual([OauthPublicController, OauthApiController, ClaudeConnectorController]);
|
expect(controllers).toEqual([OauthPublicController, OauthApiController]);
|
||||||
// RateLimitService is deliberately absent: it comes from the global
|
// RateLimitService is deliberately absent: it comes from the global
|
||||||
// RateLimitModule so all consumers share one set of counters.
|
// RateLimitModule so all consumers share one set of counters.
|
||||||
expect(providers).toEqual([
|
expect(providers).toEqual([
|
||||||
Svc,
|
Svc,
|
||||||
CimdClientResolver,
|
CimdClientResolver,
|
||||||
ClaudeConnectorService,
|
|
||||||
TrekClientsStore,
|
TrekClientsStore,
|
||||||
TrekOAuthProvider,
|
TrekOAuthProvider,
|
||||||
OauthTokenRetentionJob,
|
OauthTokenRetentionJob,
|
||||||
|
|||||||
@@ -1875,3 +1875,38 @@ describe('quirk fixes', () => {
|
|||||||
expect(await storedFields(VacayYears, { plan: plan.id, year: year + 1 }, ['id'])).toBeUndefined();
|
expect(await storedFields(VacayYears, { plan: plan.id, year: year + 1 }, ['id'])).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('a day written for a year the plan has not opened', () => {
|
||||||
|
it('VACAY-SVC-079: adds that year with the member allowance, so the day shows in the calendar', async () => {
|
||||||
|
const { user, plan } = await setupUserWithPlan();
|
||||||
|
await allowWeekends(plan.id);
|
||||||
|
expect(await svc.listYears(plan.id)).not.toContain(2031);
|
||||||
|
|
||||||
|
const result = await svc.toggleEntry(user.id, plan.id, '2031-01-06', 1, 'vacation');
|
||||||
|
|
||||||
|
expect(result.action).toBe('added');
|
||||||
|
expect(await svc.listYears(plan.id)).toContain(2031);
|
||||||
|
expect(await storedRow(VacayUserYears, { user: user.id, plan: plan.id, year: 2031 })).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('VACAY-SVC-080: taking the day away again keeps the year it opened', async () => {
|
||||||
|
const { user, plan } = await setupUserWithPlan();
|
||||||
|
await allowWeekends(plan.id);
|
||||||
|
await svc.toggleEntry(user.id, plan.id, '2032-03-02', 1, 'vacation');
|
||||||
|
await svc.deleteYear(plan.id, 2032, undefined);
|
||||||
|
const before = await svc.listYears(plan.id);
|
||||||
|
|
||||||
|
await svc.toggleEntry(user.id, plan.id, '2032-03-02', 1, 'vacation');
|
||||||
|
await svc.toggleEntry(user.id, plan.id, '2032-03-02', 1, 'vacation');
|
||||||
|
|
||||||
|
expect(await svc.listYears(plan.id)).toEqual([...before, 2032].sort((a, b) => a - b));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('VACAY-SVC-081: a company holiday in a year not opened yet adds that year too', async () => {
|
||||||
|
const { plan } = await setupUserWithPlan();
|
||||||
|
|
||||||
|
await svc.toggleCompanyHoliday(plan.id, '2033-12-25', 'Christmas', undefined);
|
||||||
|
|
||||||
|
expect(await svc.listYears(plan.id)).toContain(2033);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -224,13 +224,3 @@ export const aiAdminSettingsSchema = z
|
|||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
export type AiAdminSettings = z.infer<typeof aiAdminSettingsSchema>;
|
export type AiAdminSettings = z.infer<typeof aiAdminSettingsSchema>;
|
||||||
|
|
||||||
// ── Claude connector: /api/integrations/claude-connector ───────────────────
|
|
||||||
|
|
||||||
/** GET /api/integrations/claude-connector: adding TREK's MCP server in Claude, as a link and a QR code. */
|
|
||||||
export const aiClaudeConnectorResponseSchema = z.object({
|
|
||||||
mcpUrl: z.url(),
|
|
||||||
deepLink: z.url(),
|
|
||||||
qrSvg: z.string(),
|
|
||||||
});
|
|
||||||
export type AiClaudeConnectorResponse = z.infer<typeof aiClaudeConnectorResponseSchema>;
|
|
||||||
|
|||||||
@@ -68,7 +68,6 @@ const assistant: TranslationStrings = {
|
|||||||
'assistant.connect.quota.two': 'استُخدمت اليوم {used} من رسالتين',
|
'assistant.connect.quota.two': 'استُخدمت اليوم {used} من رسالتين',
|
||||||
'assistant.connect.quota.few': 'استُخدمت اليوم {used} من {count} رسائل',
|
'assistant.connect.quota.few': 'استُخدمت اليوم {used} من {count} رسائل',
|
||||||
'assistant.connect.quota.many': 'استُخدمت اليوم {used} من {count} رسالة',
|
'assistant.connect.quota.many': 'استُخدمت اليوم {used} من {count} رسالة',
|
||||||
'assistant.connect.claudeLink': 'تستخدم Claude؟ اربطه من داخل Claude',
|
|
||||||
'assistant.privacy.what': 'ما الذي تتم مشاركته؟',
|
'assistant.privacy.what': 'ما الذي تتم مشاركته؟',
|
||||||
'assistant.privacy.banner': 'تُرسل رسائلك وبيانات الرحلة التي يقرؤها المساعد إلى {provider}.',
|
'assistant.privacy.banner': 'تُرسل رسائلك وبيانات الرحلة التي يقرؤها المساعد إلى {provider}.',
|
||||||
'assistant.privacy.ok': 'فهمت',
|
'assistant.privacy.ok': 'فهمت',
|
||||||
@@ -270,13 +269,6 @@ const assistant: TranslationStrings = {
|
|||||||
'assistant.admin.retentionDays.few': '{count} أيام',
|
'assistant.admin.retentionDays.few': '{count} أيام',
|
||||||
'assistant.admin.retentionDays.many': '{count} يومًا',
|
'assistant.admin.retentionDays.many': '{count} يومًا',
|
||||||
'assistant.admin.retentionForever': 'للأبد',
|
'assistant.admin.retentionForever': 'للأبد',
|
||||||
'assistant.claude.title': 'الربط مع Claude',
|
|
||||||
'assistant.claude.description': 'استخدم TREK مباشرة داخل Claude: أضفه كموصل، وسجّل الدخول، وانتهى الأمر.',
|
|
||||||
'assistant.claude.open': 'فتح في Claude',
|
|
||||||
'assistant.claude.scan': 'أو امسحه بهاتفك',
|
|
||||||
'assistant.claude.copyUrl': 'نسخ العنوان',
|
|
||||||
'assistant.claude.qrAlt': 'رمز QR لإضافة TREK في Claude',
|
|
||||||
'assistant.claude.notPublic': 'لا يستطيع Claude الوصول إلى TREK إلا إذا كان متاحًا عبر https على الإنترنت.',
|
|
||||||
'assistant.mobile.tile': 'اسأل TREK',
|
'assistant.mobile.tile': 'اسأل TREK',
|
||||||
'assistant.mobile.tileHint': 'خطط مع المساعد',
|
'assistant.mobile.tileHint': 'خطط مع المساعد',
|
||||||
'assistant.mobile.backToChat': 'العودة إلى المحادثة',
|
'assistant.mobile.backToChat': 'العودة إلى المحادثة',
|
||||||
|
|||||||
@@ -66,7 +66,6 @@ const assistant: TranslationStrings = {
|
|||||||
'assistant.connect.statusError': 'Əlçatan deyil',
|
'assistant.connect.statusError': 'Əlçatan deyil',
|
||||||
'assistant.connect.quota': 'Bu gün {count} mesajdan {used} istifadə olunub',
|
'assistant.connect.quota': 'Bu gün {count} mesajdan {used} istifadə olunub',
|
||||||
'assistant.connect.quota.one': 'Bu gün {count} mesajdan {used} istifadə olunub',
|
'assistant.connect.quota.one': 'Bu gün {count} mesajdan {used} istifadə olunub',
|
||||||
'assistant.connect.claudeLink': 'Claude istifadə edirsiniz? Claude-da qoşulun',
|
|
||||||
'assistant.privacy.what': 'Nə paylaşılır?',
|
'assistant.privacy.what': 'Nə paylaşılır?',
|
||||||
'assistant.privacy.banner': 'Mesajlarınız və köməkçinin oxuduğu səyahət məlumatları {provider} xidmətinə göndərilir.',
|
'assistant.privacy.banner': 'Mesajlarınız və köməkçinin oxuduğu səyahət məlumatları {provider} xidmətinə göndərilir.',
|
||||||
'assistant.privacy.ok': 'Aydındır',
|
'assistant.privacy.ok': 'Aydındır',
|
||||||
@@ -253,14 +252,6 @@ const assistant: TranslationStrings = {
|
|||||||
'assistant.admin.retentionDays': '{count} gün',
|
'assistant.admin.retentionDays': '{count} gün',
|
||||||
'assistant.admin.retentionDays.one': '{count} gün',
|
'assistant.admin.retentionDays.one': '{count} gün',
|
||||||
'assistant.admin.retentionForever': 'Həmişəlik',
|
'assistant.admin.retentionForever': 'Həmişəlik',
|
||||||
'assistant.claude.title': 'Claude ilə qoşul',
|
|
||||||
'assistant.claude.description':
|
|
||||||
'TREK-dən birbaşa Claude daxilində istifadə edin: onu konnektor kimi əlavə edin, daxil olun, hazırdır.',
|
|
||||||
'assistant.claude.open': 'Claude-da aç',
|
|
||||||
'assistant.claude.scan': 'Və ya telefonunuzla skan edin',
|
|
||||||
'assistant.claude.copyUrl': 'Ünvanı kopyala',
|
|
||||||
'assistant.claude.qrAlt': 'TREK-i Claude-a əlavə etmək üçün QR kod',
|
|
||||||
'assistant.claude.notPublic': 'Claude TREK-ə yalnız o, internetdə https ilə əlçatan olduqda çata bilər.',
|
|
||||||
'assistant.mobile.tile': 'TREK-dən soruş',
|
'assistant.mobile.tile': 'TREK-dən soruş',
|
||||||
'assistant.mobile.tileHint': 'Köməkçi ilə planlaşdırın',
|
'assistant.mobile.tileHint': 'Köməkçi ilə planlaşdırın',
|
||||||
'assistant.mobile.backToChat': 'Söhbətə qayıt',
|
'assistant.mobile.backToChat': 'Söhbətə qayıt',
|
||||||
|
|||||||
@@ -66,7 +66,6 @@ const assistant: TranslationStrings = {
|
|||||||
'assistant.connect.statusError': 'Inacessível',
|
'assistant.connect.statusError': 'Inacessível',
|
||||||
'assistant.connect.quota': '{used} de {count} mensagens usadas hoje',
|
'assistant.connect.quota': '{used} de {count} mensagens usadas hoje',
|
||||||
'assistant.connect.quota.one': '{used} de {count} mensagem usada hoje',
|
'assistant.connect.quota.one': '{used} de {count} mensagem usada hoje',
|
||||||
'assistant.connect.claudeLink': 'Usa o Claude? Conecte no Claude',
|
|
||||||
'assistant.privacy.what': 'O que é compartilhado?',
|
'assistant.privacy.what': 'O que é compartilhado?',
|
||||||
'assistant.privacy.banner': 'Suas mensagens e os dados da viagem que o assistente lê vão para {provider}.',
|
'assistant.privacy.banner': 'Suas mensagens e os dados da viagem que o assistente lê vão para {provider}.',
|
||||||
'assistant.privacy.ok': 'Entendi',
|
'assistant.privacy.ok': 'Entendi',
|
||||||
@@ -253,13 +252,6 @@ const assistant: TranslationStrings = {
|
|||||||
'assistant.admin.retentionDays': '{count} dias',
|
'assistant.admin.retentionDays': '{count} dias',
|
||||||
'assistant.admin.retentionDays.one': '{count} dia',
|
'assistant.admin.retentionDays.one': '{count} dia',
|
||||||
'assistant.admin.retentionForever': 'Para sempre',
|
'assistant.admin.retentionForever': 'Para sempre',
|
||||||
'assistant.claude.title': 'Conectar com o Claude',
|
|
||||||
'assistant.claude.description': 'Use o TREK direto no Claude: adicione como conector, entre e pronto.',
|
|
||||||
'assistant.claude.open': 'Abrir no Claude',
|
|
||||||
'assistant.claude.scan': 'Ou escaneie com o celular',
|
|
||||||
'assistant.claude.copyUrl': 'Copiar endereço',
|
|
||||||
'assistant.claude.qrAlt': 'QR code para adicionar o TREK no Claude',
|
|
||||||
'assistant.claude.notPublic': 'O Claude só consegue acessar o TREK se ele estiver disponível via https na internet.',
|
|
||||||
'assistant.mobile.tile': 'Pergunte ao TREK',
|
'assistant.mobile.tile': 'Pergunte ao TREK',
|
||||||
'assistant.mobile.tileHint': 'Planeje com o assistente',
|
'assistant.mobile.tileHint': 'Planeje com o assistente',
|
||||||
'assistant.mobile.backToChat': 'Voltar ao chat',
|
'assistant.mobile.backToChat': 'Voltar ao chat',
|
||||||
|
|||||||
@@ -66,7 +66,6 @@ const assistant: TranslationStrings = {
|
|||||||
'assistant.connect.statusError': 'No accessible',
|
'assistant.connect.statusError': 'No accessible',
|
||||||
'assistant.connect.quota': '{used} de {count} missatges utilitzats avui',
|
'assistant.connect.quota': '{used} de {count} missatges utilitzats avui',
|
||||||
'assistant.connect.quota.one': '{used} de {count} missatge utilitzat avui',
|
'assistant.connect.quota.one': '{used} de {count} missatge utilitzat avui',
|
||||||
'assistant.connect.claudeLink': 'Fas servir Claude? Connecta’t a Claude',
|
|
||||||
'assistant.privacy.what': 'Què es comparteix?',
|
'assistant.privacy.what': 'Què es comparteix?',
|
||||||
'assistant.privacy.banner':
|
'assistant.privacy.banner':
|
||||||
'Els teus missatges i les dades del viatge que llegeix l’assistent s’envien a {provider}.',
|
'Els teus missatges i les dades del viatge que llegeix l’assistent s’envien a {provider}.',
|
||||||
@@ -255,14 +254,6 @@ const assistant: TranslationStrings = {
|
|||||||
'assistant.admin.retentionDays': '{count} dies',
|
'assistant.admin.retentionDays': '{count} dies',
|
||||||
'assistant.admin.retentionDays.one': '{count} dia',
|
'assistant.admin.retentionDays.one': '{count} dia',
|
||||||
'assistant.admin.retentionForever': 'Per sempre',
|
'assistant.admin.retentionForever': 'Per sempre',
|
||||||
'assistant.claude.title': 'Connecta amb Claude',
|
|
||||||
'assistant.claude.description':
|
|
||||||
'Fes servir TREK directament a Claude: afegeix-lo com a connector, inicia sessió i ja està.',
|
|
||||||
'assistant.claude.open': 'Obre a Claude',
|
|
||||||
'assistant.claude.scan': 'O escaneja’l amb el mòbil',
|
|
||||||
'assistant.claude.copyUrl': 'Copia l’adreça',
|
|
||||||
'assistant.claude.qrAlt': 'Codi QR per afegir TREK a Claude',
|
|
||||||
'assistant.claude.notPublic': 'Claude només pot accedir a TREK si està disponible per https a internet.',
|
|
||||||
'assistant.mobile.tile': 'Pregunta a TREK',
|
'assistant.mobile.tile': 'Pregunta a TREK',
|
||||||
'assistant.mobile.tileHint': 'Planifica amb l’assistent',
|
'assistant.mobile.tileHint': 'Planifica amb l’assistent',
|
||||||
'assistant.mobile.backToChat': 'Torna al xat',
|
'assistant.mobile.backToChat': 'Torna al xat',
|
||||||
|
|||||||
@@ -66,7 +66,6 @@ const assistant: TranslationStrings = {
|
|||||||
'assistant.connect.quota': 'Dnes využito {used} z {count} zpráv',
|
'assistant.connect.quota': 'Dnes využito {used} z {count} zpráv',
|
||||||
'assistant.connect.quota.one': 'Dnes využito {used} z {count} zprávy',
|
'assistant.connect.quota.one': 'Dnes využito {used} z {count} zprávy',
|
||||||
'assistant.connect.quota.few': 'Dnes využito {used} z {count} zpráv',
|
'assistant.connect.quota.few': 'Dnes využito {used} z {count} zpráv',
|
||||||
'assistant.connect.claudeLink': 'Používáš Claude? Připoj se v Claude',
|
|
||||||
'assistant.privacy.what': 'Co se sdílí?',
|
'assistant.privacy.what': 'Co se sdílí?',
|
||||||
'assistant.privacy.banner': 'Tvoje zprávy a data cesty, která asistent čte, jdou do {provider}.',
|
'assistant.privacy.banner': 'Tvoje zprávy a data cesty, která asistent čte, jdou do {provider}.',
|
||||||
'assistant.privacy.ok': 'Rozumím',
|
'assistant.privacy.ok': 'Rozumím',
|
||||||
@@ -257,13 +256,6 @@ const assistant: TranslationStrings = {
|
|||||||
'assistant.admin.retentionDays.one': '{count} den',
|
'assistant.admin.retentionDays.one': '{count} den',
|
||||||
'assistant.admin.retentionDays.few': '{count} dny',
|
'assistant.admin.retentionDays.few': '{count} dny',
|
||||||
'assistant.admin.retentionForever': 'Navždy',
|
'assistant.admin.retentionForever': 'Navždy',
|
||||||
'assistant.claude.title': 'Připojit přes Claude',
|
|
||||||
'assistant.claude.description': 'Používej TREK přímo v Claude: přidej ho jako konektor, přihlas se a hotovo.',
|
|
||||||
'assistant.claude.open': 'Otevřít v Claude',
|
|
||||||
'assistant.claude.scan': 'Nebo ho naskenuj telefonem',
|
|
||||||
'assistant.claude.copyUrl': 'Kopírovat adresu',
|
|
||||||
'assistant.claude.qrAlt': 'QR kód pro přidání TREK do Claude',
|
|
||||||
'assistant.claude.notPublic': 'Claude se k TREK dostane, jen když je dostupný přes https na internetu.',
|
|
||||||
'assistant.mobile.tile': 'Zeptej se TREK',
|
'assistant.mobile.tile': 'Zeptej se TREK',
|
||||||
'assistant.mobile.tileHint': 'Plánuj s asistentem',
|
'assistant.mobile.tileHint': 'Plánuj s asistentem',
|
||||||
'assistant.mobile.backToChat': 'Zpět do chatu',
|
'assistant.mobile.backToChat': 'Zpět do chatu',
|
||||||
|
|||||||
@@ -67,7 +67,6 @@ const assistant: TranslationStrings = {
|
|||||||
'assistant.connect.statusError': 'Nicht erreichbar',
|
'assistant.connect.statusError': 'Nicht erreichbar',
|
||||||
'assistant.connect.quota': 'Heute {used} von {count} Nachrichten genutzt',
|
'assistant.connect.quota': 'Heute {used} von {count} Nachrichten genutzt',
|
||||||
'assistant.connect.quota.one': 'Heute {used} von {count} Nachricht genutzt',
|
'assistant.connect.quota.one': 'Heute {used} von {count} Nachricht genutzt',
|
||||||
'assistant.connect.claudeLink': 'Du nutzt Claude? In Claude verbinden',
|
|
||||||
'assistant.privacy.what': 'Was wird geteilt?',
|
'assistant.privacy.what': 'Was wird geteilt?',
|
||||||
'assistant.privacy.banner': 'Deine Nachrichten und die Trip-Daten, die der Assistent liest, gehen an {provider}.',
|
'assistant.privacy.banner': 'Deine Nachrichten und die Trip-Daten, die der Assistent liest, gehen an {provider}.',
|
||||||
'assistant.privacy.ok': 'Verstanden',
|
'assistant.privacy.ok': 'Verstanden',
|
||||||
@@ -253,13 +252,6 @@ const assistant: TranslationStrings = {
|
|||||||
'assistant.admin.retentionDays': '{count} Tage',
|
'assistant.admin.retentionDays': '{count} Tage',
|
||||||
'assistant.admin.retentionDays.one': '{count} Tag',
|
'assistant.admin.retentionDays.one': '{count} Tag',
|
||||||
'assistant.admin.retentionForever': 'Für immer',
|
'assistant.admin.retentionForever': 'Für immer',
|
||||||
'assistant.claude.title': 'Mit Claude verbinden',
|
|
||||||
'assistant.claude.description': 'Nutze TREK direkt in Claude: als Connector hinzufügen, anmelden, fertig.',
|
|
||||||
'assistant.claude.open': 'In Claude öffnen',
|
|
||||||
'assistant.claude.scan': 'Oder mit dem Handy scannen',
|
|
||||||
'assistant.claude.copyUrl': 'Adresse kopieren',
|
|
||||||
'assistant.claude.qrAlt': 'QR-Code, um TREK in Claude hinzuzufügen',
|
|
||||||
'assistant.claude.notPublic': 'Claude erreicht TREK nur, wenn es per https aus dem Internet erreichbar ist.',
|
|
||||||
'assistant.mobile.tile': 'Frag TREK',
|
'assistant.mobile.tile': 'Frag TREK',
|
||||||
'assistant.mobile.tileHint': 'Plane mit dem Assistenten',
|
'assistant.mobile.tileHint': 'Plane mit dem Assistenten',
|
||||||
'assistant.mobile.backToChat': 'Zurück zum Chat',
|
'assistant.mobile.backToChat': 'Zurück zum Chat',
|
||||||
|
|||||||
@@ -65,7 +65,6 @@ const assistant: TranslationStrings = {
|
|||||||
'assistant.connect.statusError': 'Not reachable',
|
'assistant.connect.statusError': 'Not reachable',
|
||||||
'assistant.connect.quota': '{used} of {count} messages used today',
|
'assistant.connect.quota': '{used} of {count} messages used today',
|
||||||
'assistant.connect.quota.one': '{used} of {count} message used today',
|
'assistant.connect.quota.one': '{used} of {count} message used today',
|
||||||
'assistant.connect.claudeLink': 'Using Claude? Connect in Claude',
|
|
||||||
'assistant.privacy.what': 'What gets shared?',
|
'assistant.privacy.what': 'What gets shared?',
|
||||||
'assistant.privacy.banner': 'Your messages and the trip data the assistant reads go to {provider}.',
|
'assistant.privacy.banner': 'Your messages and the trip data the assistant reads go to {provider}.',
|
||||||
'assistant.privacy.ok': 'Got it',
|
'assistant.privacy.ok': 'Got it',
|
||||||
@@ -249,13 +248,6 @@ const assistant: TranslationStrings = {
|
|||||||
'assistant.admin.retentionDays': '{count} days',
|
'assistant.admin.retentionDays': '{count} days',
|
||||||
'assistant.admin.retentionDays.one': '{count} day',
|
'assistant.admin.retentionDays.one': '{count} day',
|
||||||
'assistant.admin.retentionForever': 'Forever',
|
'assistant.admin.retentionForever': 'Forever',
|
||||||
'assistant.claude.title': 'Connect with Claude',
|
|
||||||
'assistant.claude.description': 'Use TREK right inside Claude: add it as a connector, sign in, done.',
|
|
||||||
'assistant.claude.open': 'Open in Claude',
|
|
||||||
'assistant.claude.scan': 'Or scan it with your phone',
|
|
||||||
'assistant.claude.copyUrl': 'Copy address',
|
|
||||||
'assistant.claude.qrAlt': 'QR code to add TREK in Claude',
|
|
||||||
'assistant.claude.notPublic': 'Claude can only reach TREK when it is available over https on the internet.',
|
|
||||||
'assistant.mobile.tile': 'Ask TREK',
|
'assistant.mobile.tile': 'Ask TREK',
|
||||||
'assistant.mobile.tileHint': 'Plan with the assistant',
|
'assistant.mobile.tileHint': 'Plan with the assistant',
|
||||||
'assistant.mobile.backToChat': 'Back to chat',
|
'assistant.mobile.backToChat': 'Back to chat',
|
||||||
|
|||||||
@@ -65,7 +65,6 @@ const assistant: TranslationStrings = {
|
|||||||
'assistant.connect.statusError': 'No disponible',
|
'assistant.connect.statusError': 'No disponible',
|
||||||
'assistant.connect.quota': '{used} de {count} mensajes usados hoy',
|
'assistant.connect.quota': '{used} de {count} mensajes usados hoy',
|
||||||
'assistant.connect.quota.one': '{used} de {count} mensaje usado hoy',
|
'assistant.connect.quota.one': '{used} de {count} mensaje usado hoy',
|
||||||
'assistant.connect.claudeLink': '¿Usas Claude? Conéctate en Claude',
|
|
||||||
'assistant.privacy.what': '¿Qué se comparte?',
|
'assistant.privacy.what': '¿Qué se comparte?',
|
||||||
'assistant.privacy.banner': 'Tus mensajes y los datos del viaje que lee el asistente se envían a {provider}.',
|
'assistant.privacy.banner': 'Tus mensajes y los datos del viaje que lee el asistente se envían a {provider}.',
|
||||||
'assistant.privacy.ok': 'Entendido',
|
'assistant.privacy.ok': 'Entendido',
|
||||||
@@ -252,13 +251,6 @@ const assistant: TranslationStrings = {
|
|||||||
'assistant.admin.retentionDays': '{count} días',
|
'assistant.admin.retentionDays': '{count} días',
|
||||||
'assistant.admin.retentionDays.one': '{count} día',
|
'assistant.admin.retentionDays.one': '{count} día',
|
||||||
'assistant.admin.retentionForever': 'Para siempre',
|
'assistant.admin.retentionForever': 'Para siempre',
|
||||||
'assistant.claude.title': 'Conectar con Claude',
|
|
||||||
'assistant.claude.description': 'Usa TREK directamente en Claude: añádelo como conector, inicia sesión y listo.',
|
|
||||||
'assistant.claude.open': 'Abrir en Claude',
|
|
||||||
'assistant.claude.scan': 'O escanéalo con tu móvil',
|
|
||||||
'assistant.claude.copyUrl': 'Copiar dirección',
|
|
||||||
'assistant.claude.qrAlt': 'Código QR para añadir TREK en Claude',
|
|
||||||
'assistant.claude.notPublic': 'Claude solo puede acceder a TREK si está disponible por https en internet.',
|
|
||||||
'assistant.mobile.tile': 'Pregunta a TREK',
|
'assistant.mobile.tile': 'Pregunta a TREK',
|
||||||
'assistant.mobile.tileHint': 'Planifica con el asistente',
|
'assistant.mobile.tileHint': 'Planifica con el asistente',
|
||||||
'assistant.mobile.backToChat': 'Volver al chat',
|
'assistant.mobile.backToChat': 'Volver al chat',
|
||||||
|
|||||||
@@ -66,7 +66,6 @@ const assistant: TranslationStrings = {
|
|||||||
'assistant.connect.statusError': 'Pole kättesaadav',
|
'assistant.connect.statusError': 'Pole kättesaadav',
|
||||||
'assistant.connect.quota': 'Täna kasutatud {used} sõnumit {count}-st',
|
'assistant.connect.quota': 'Täna kasutatud {used} sõnumit {count}-st',
|
||||||
'assistant.connect.quota.one': 'Täna kasutatud {used} sõnumit {count}-st',
|
'assistant.connect.quota.one': 'Täna kasutatud {used} sõnumit {count}-st',
|
||||||
'assistant.connect.claudeLink': 'Kasutad Claude’i? Ühenda Claude’is',
|
|
||||||
'assistant.privacy.what': 'Mida jagatakse?',
|
'assistant.privacy.what': 'Mida jagatakse?',
|
||||||
'assistant.privacy.banner': 'Sinu sõnumid ja reisiandmed, mida assistent loeb, saadetakse teenusele {provider}.',
|
'assistant.privacy.banner': 'Sinu sõnumid ja reisiandmed, mida assistent loeb, saadetakse teenusele {provider}.',
|
||||||
'assistant.privacy.ok': 'Selge',
|
'assistant.privacy.ok': 'Selge',
|
||||||
@@ -253,13 +252,6 @@ const assistant: TranslationStrings = {
|
|||||||
'assistant.admin.retentionDays': '{count} päeva',
|
'assistant.admin.retentionDays': '{count} päeva',
|
||||||
'assistant.admin.retentionDays.one': '{count} päev',
|
'assistant.admin.retentionDays.one': '{count} päev',
|
||||||
'assistant.admin.retentionForever': 'Igavesti',
|
'assistant.admin.retentionForever': 'Igavesti',
|
||||||
'assistant.claude.title': 'Ühenda Claude’iga',
|
|
||||||
'assistant.claude.description': 'Kasuta TREKi otse Claude’is: lisa see konnektorina, logi sisse ja valmis.',
|
|
||||||
'assistant.claude.open': 'Ava Claude’is',
|
|
||||||
'assistant.claude.scan': 'Või skanni see telefoniga',
|
|
||||||
'assistant.claude.copyUrl': 'Kopeeri aadress',
|
|
||||||
'assistant.claude.qrAlt': 'QR-kood TREKi lisamiseks Claude’i',
|
|
||||||
'assistant.claude.notPublic': 'Claude jõuab TREKini ainult siis, kui see on internetis https-i kaudu kättesaadav.',
|
|
||||||
'assistant.mobile.tile': 'Küsi TREKilt',
|
'assistant.mobile.tile': 'Küsi TREKilt',
|
||||||
'assistant.mobile.tileHint': 'Planeeri assistendiga',
|
'assistant.mobile.tileHint': 'Planeeri assistendiga',
|
||||||
'assistant.mobile.backToChat': 'Tagasi vestlusse',
|
'assistant.mobile.backToChat': 'Tagasi vestlusse',
|
||||||
|
|||||||
@@ -66,7 +66,6 @@ const assistant: TranslationStrings = {
|
|||||||
'assistant.connect.statusError': 'Injoignable',
|
'assistant.connect.statusError': 'Injoignable',
|
||||||
'assistant.connect.quota': '{used} messages sur {count} utilisés aujourd’hui',
|
'assistant.connect.quota': '{used} messages sur {count} utilisés aujourd’hui',
|
||||||
'assistant.connect.quota.one': '{used} message sur {count} utilisé aujourd’hui',
|
'assistant.connect.quota.one': '{used} message sur {count} utilisé aujourd’hui',
|
||||||
'assistant.connect.claudeLink': 'Vous utilisez Claude ? Connectez-vous dans Claude',
|
|
||||||
'assistant.privacy.what': 'Qu’est-ce qui est partagé ?',
|
'assistant.privacy.what': 'Qu’est-ce qui est partagé ?',
|
||||||
'assistant.privacy.banner': 'Vos messages et les données du voyage que lit l’assistant sont envoyés à {provider}.',
|
'assistant.privacy.banner': 'Vos messages et les données du voyage que lit l’assistant sont envoyés à {provider}.',
|
||||||
'assistant.privacy.ok': 'Compris',
|
'assistant.privacy.ok': 'Compris',
|
||||||
@@ -256,14 +255,6 @@ const assistant: TranslationStrings = {
|
|||||||
'assistant.admin.retentionDays': '{count} jours',
|
'assistant.admin.retentionDays': '{count} jours',
|
||||||
'assistant.admin.retentionDays.one': '{count} jour',
|
'assistant.admin.retentionDays.one': '{count} jour',
|
||||||
'assistant.admin.retentionForever': 'Pour toujours',
|
'assistant.admin.retentionForever': 'Pour toujours',
|
||||||
'assistant.claude.title': 'Se connecter avec Claude',
|
|
||||||
'assistant.claude.description':
|
|
||||||
'Utilisez TREK directement dans Claude : ajoutez-le comme connecteur, connectez-vous, c’est fait.',
|
|
||||||
'assistant.claude.open': 'Ouvrir dans Claude',
|
|
||||||
'assistant.claude.scan': 'Ou scannez-le avec votre téléphone',
|
|
||||||
'assistant.claude.copyUrl': 'Copier l’adresse',
|
|
||||||
'assistant.claude.qrAlt': 'QR code pour ajouter TREK dans Claude',
|
|
||||||
'assistant.claude.notPublic': 'Claude ne peut joindre TREK que s’il est accessible en https sur Internet.',
|
|
||||||
'assistant.mobile.tile': 'Demander à TREK',
|
'assistant.mobile.tile': 'Demander à TREK',
|
||||||
'assistant.mobile.tileHint': 'Planifier avec l’assistant',
|
'assistant.mobile.tileHint': 'Planifier avec l’assistant',
|
||||||
'assistant.mobile.backToChat': 'Retour au chat',
|
'assistant.mobile.backToChat': 'Retour au chat',
|
||||||
|
|||||||
@@ -67,7 +67,6 @@ const assistant: TranslationStrings = {
|
|||||||
'assistant.connect.statusError': 'Μη προσβάσιμο',
|
'assistant.connect.statusError': 'Μη προσβάσιμο',
|
||||||
'assistant.connect.quota': 'Σήμερα χρησιμοποιήθηκαν {used} από {count} μηνύματα',
|
'assistant.connect.quota': 'Σήμερα χρησιμοποιήθηκαν {used} από {count} μηνύματα',
|
||||||
'assistant.connect.quota.one': 'Σήμερα χρησιμοποιήθηκαν {used} από {count} μήνυμα',
|
'assistant.connect.quota.one': 'Σήμερα χρησιμοποιήθηκαν {used} από {count} μήνυμα',
|
||||||
'assistant.connect.claudeLink': 'Χρησιμοποιείτε Claude; Συνδεθείτε στο Claude',
|
|
||||||
'assistant.privacy.what': 'Τι κοινοποιείται;',
|
'assistant.privacy.what': 'Τι κοινοποιείται;',
|
||||||
'assistant.privacy.banner':
|
'assistant.privacy.banner':
|
||||||
'Τα μηνύματά σας και τα δεδομένα του ταξιδιού που διαβάζει ο βοηθός πηγαίνουν στο {provider}.',
|
'Τα μηνύματά σας και τα δεδομένα του ταξιδιού που διαβάζει ο βοηθός πηγαίνουν στο {provider}.',
|
||||||
@@ -257,14 +256,6 @@ const assistant: TranslationStrings = {
|
|||||||
'assistant.admin.retentionDays': '{count} ημέρες',
|
'assistant.admin.retentionDays': '{count} ημέρες',
|
||||||
'assistant.admin.retentionDays.one': '{count} ημέρα',
|
'assistant.admin.retentionDays.one': '{count} ημέρα',
|
||||||
'assistant.admin.retentionForever': 'Για πάντα',
|
'assistant.admin.retentionForever': 'Για πάντα',
|
||||||
'assistant.claude.title': 'Σύνδεση με Claude',
|
|
||||||
'assistant.claude.description':
|
|
||||||
'Χρησιμοποιήστε το TREK απευθείας μέσα στο Claude: προσθέστε το ως σύνδεσμο, συνδεθείτε, έτοιμο.',
|
|
||||||
'assistant.claude.open': 'Άνοιγμα στο Claude',
|
|
||||||
'assistant.claude.scan': 'Ή σαρώστε το με το κινητό σας',
|
|
||||||
'assistant.claude.copyUrl': 'Αντιγραφή διεύθυνσης',
|
|
||||||
'assistant.claude.qrAlt': 'Κωδικός QR για προσθήκη του TREK στο Claude',
|
|
||||||
'assistant.claude.notPublic': 'Το Claude φτάνει στο TREK μόνο όταν είναι διαθέσιμο μέσω https στο διαδίκτυο.',
|
|
||||||
'assistant.mobile.tile': 'Ρωτήστε το TREK',
|
'assistant.mobile.tile': 'Ρωτήστε το TREK',
|
||||||
'assistant.mobile.tileHint': 'Σχεδιάστε με τον βοηθό',
|
'assistant.mobile.tileHint': 'Σχεδιάστε με τον βοηθό',
|
||||||
'assistant.mobile.backToChat': 'Πίσω στη συνομιλία',
|
'assistant.mobile.backToChat': 'Πίσω στη συνομιλία',
|
||||||
|
|||||||
@@ -66,7 +66,6 @@ const assistant: TranslationStrings = {
|
|||||||
'assistant.connect.statusError': 'Nem érhető el',
|
'assistant.connect.statusError': 'Nem érhető el',
|
||||||
'assistant.connect.quota': 'Ma {used} / {count} üzenet felhasználva',
|
'assistant.connect.quota': 'Ma {used} / {count} üzenet felhasználva',
|
||||||
'assistant.connect.quota.one': 'Ma {used} / {count} üzenet felhasználva',
|
'assistant.connect.quota.one': 'Ma {used} / {count} üzenet felhasználva',
|
||||||
'assistant.connect.claudeLink': 'Claude-ot használsz? Csatlakozz a Claude-ban',
|
|
||||||
'assistant.privacy.what': 'Mi kerül megosztásra?',
|
'assistant.privacy.what': 'Mi kerül megosztásra?',
|
||||||
'assistant.privacy.banner':
|
'assistant.privacy.banner':
|
||||||
'Az üzeneteid és az utazás adatai, amelyeket az asszisztens elolvas, ide kerülnek: {provider}.',
|
'Az üzeneteid és az utazás adatai, amelyeket az asszisztens elolvas, ide kerülnek: {provider}.',
|
||||||
@@ -256,15 +255,6 @@ const assistant: TranslationStrings = {
|
|||||||
'assistant.admin.retentionDays': '{count} nap',
|
'assistant.admin.retentionDays': '{count} nap',
|
||||||
'assistant.admin.retentionDays.one': '{count} nap',
|
'assistant.admin.retentionDays.one': '{count} nap',
|
||||||
'assistant.admin.retentionForever': 'Örökre',
|
'assistant.admin.retentionForever': 'Örökre',
|
||||||
'assistant.claude.title': 'Kapcsolódás a Claude-dal',
|
|
||||||
'assistant.claude.description':
|
|
||||||
'Használd a TREK-et közvetlenül a Claude-ban: add hozzá összekötőként, jelentkezz be, kész.',
|
|
||||||
'assistant.claude.open': 'Megnyitás a Claude-ban',
|
|
||||||
'assistant.claude.scan': 'Vagy olvasd be a telefonoddal',
|
|
||||||
'assistant.claude.copyUrl': 'Cím másolása',
|
|
||||||
'assistant.claude.qrAlt': 'QR-kód a TREK hozzáadásához a Claude-ban',
|
|
||||||
'assistant.claude.notPublic':
|
|
||||||
'A Claude csak akkor éri el a TREK-et, ha az https-en keresztül elérhető az interneten.',
|
|
||||||
'assistant.mobile.tile': 'Kérdezd a TREK-et',
|
'assistant.mobile.tile': 'Kérdezd a TREK-et',
|
||||||
'assistant.mobile.tileHint': 'Tervezz az asszisztenssel',
|
'assistant.mobile.tileHint': 'Tervezz az asszisztenssel',
|
||||||
'assistant.mobile.backToChat': 'Vissza a csevegéshez',
|
'assistant.mobile.backToChat': 'Vissza a csevegéshez',
|
||||||
|
|||||||
@@ -65,7 +65,6 @@ const assistant: TranslationStrings = {
|
|||||||
'assistant.connect.statusSignedOut': 'Sudah keluar',
|
'assistant.connect.statusSignedOut': 'Sudah keluar',
|
||||||
'assistant.connect.statusError': 'Tidak dapat dijangkau',
|
'assistant.connect.statusError': 'Tidak dapat dijangkau',
|
||||||
'assistant.connect.quota': '{used} dari {count} pesan terpakai hari ini',
|
'assistant.connect.quota': '{used} dari {count} pesan terpakai hari ini',
|
||||||
'assistant.connect.claudeLink': 'Pakai Claude? Hubungkan di Claude',
|
|
||||||
'assistant.privacy.what': 'Apa saja yang dibagikan?',
|
'assistant.privacy.what': 'Apa saja yang dibagikan?',
|
||||||
'assistant.privacy.banner': 'Pesanmu dan data perjalanan yang dibaca asisten dikirim ke {provider}.',
|
'assistant.privacy.banner': 'Pesanmu dan data perjalanan yang dibaca asisten dikirim ke {provider}.',
|
||||||
'assistant.privacy.ok': 'Mengerti',
|
'assistant.privacy.ok': 'Mengerti',
|
||||||
@@ -247,13 +246,6 @@ const assistant: TranslationStrings = {
|
|||||||
'Asisten boleh mencari di web (ulasan, jam buka, acara) lewat pencarian penyedia AI yang ia gunakan. Berfungsi dengan ChatGPT, Anthropic, dan OpenRouter; pencarian hanya dikirim ke penyedia itu.',
|
'Asisten boleh mencari di web (ulasan, jam buka, acara) lewat pencarian penyedia AI yang ia gunakan. Berfungsi dengan ChatGPT, Anthropic, dan OpenRouter; pencarian hanya dikirim ke penyedia itu.',
|
||||||
'assistant.admin.retentionDays': '{count} hari',
|
'assistant.admin.retentionDays': '{count} hari',
|
||||||
'assistant.admin.retentionForever': 'Selamanya',
|
'assistant.admin.retentionForever': 'Selamanya',
|
||||||
'assistant.claude.title': 'Hubungkan dengan Claude',
|
|
||||||
'assistant.claude.description': 'Pakai TREK langsung di dalam Claude: tambahkan sebagai konektor, masuk, selesai.',
|
|
||||||
'assistant.claude.open': 'Buka di Claude',
|
|
||||||
'assistant.claude.scan': 'Atau pindai dengan ponselmu',
|
|
||||||
'assistant.claude.copyUrl': 'Salin alamat',
|
|
||||||
'assistant.claude.qrAlt': 'Kode QR untuk menambahkan TREK di Claude',
|
|
||||||
'assistant.claude.notPublic': 'Claude hanya bisa menjangkau TREK jika TREK tersedia lewat https di internet.',
|
|
||||||
'assistant.mobile.tile': 'Tanya TREK',
|
'assistant.mobile.tile': 'Tanya TREK',
|
||||||
'assistant.mobile.tileHint': 'Rencanakan bersama asisten',
|
'assistant.mobile.tileHint': 'Rencanakan bersama asisten',
|
||||||
'assistant.mobile.backToChat': 'Kembali ke obrolan',
|
'assistant.mobile.backToChat': 'Kembali ke obrolan',
|
||||||
|
|||||||
@@ -66,7 +66,6 @@ const assistant: TranslationStrings = {
|
|||||||
'assistant.connect.statusError': 'Non raggiungibile',
|
'assistant.connect.statusError': 'Non raggiungibile',
|
||||||
'assistant.connect.quota': '{used} di {count} messaggi usati oggi',
|
'assistant.connect.quota': '{used} di {count} messaggi usati oggi',
|
||||||
'assistant.connect.quota.one': '{used} di {count} messaggio usato oggi',
|
'assistant.connect.quota.one': '{used} di {count} messaggio usato oggi',
|
||||||
'assistant.connect.claudeLink': 'Usi Claude? Collegati in Claude',
|
|
||||||
'assistant.privacy.what': 'Cosa viene condiviso?',
|
'assistant.privacy.what': 'Cosa viene condiviso?',
|
||||||
'assistant.privacy.banner':
|
'assistant.privacy.banner':
|
||||||
'I tuoi messaggi e i dati del viaggio letti dall’assistente vengono inviati a {provider}.',
|
'I tuoi messaggi e i dati del viaggio letti dall’assistente vengono inviati a {provider}.',
|
||||||
@@ -256,13 +255,6 @@ const assistant: TranslationStrings = {
|
|||||||
'assistant.admin.retentionDays': '{count} giorni',
|
'assistant.admin.retentionDays': '{count} giorni',
|
||||||
'assistant.admin.retentionDays.one': '{count} giorno',
|
'assistant.admin.retentionDays.one': '{count} giorno',
|
||||||
'assistant.admin.retentionForever': 'Per sempre',
|
'assistant.admin.retentionForever': 'Per sempre',
|
||||||
'assistant.claude.title': 'Collega con Claude',
|
|
||||||
'assistant.claude.description': 'Usa TREK direttamente in Claude: aggiungilo come connettore, accedi, fatto.',
|
|
||||||
'assistant.claude.open': 'Apri in Claude',
|
|
||||||
'assistant.claude.scan': 'Oppure scansionalo con il telefono',
|
|
||||||
'assistant.claude.copyUrl': 'Copia indirizzo',
|
|
||||||
'assistant.claude.qrAlt': 'Codice QR per aggiungere TREK in Claude',
|
|
||||||
'assistant.claude.notPublic': 'Claude può raggiungere TREK solo se è disponibile via https su internet.',
|
|
||||||
'assistant.mobile.tile': 'Chiedi a TREK',
|
'assistant.mobile.tile': 'Chiedi a TREK',
|
||||||
'assistant.mobile.tileHint': 'Pianifica con l’assistente',
|
'assistant.mobile.tileHint': 'Pianifica con l’assistente',
|
||||||
'assistant.mobile.backToChat': 'Torna alla chat',
|
'assistant.mobile.backToChat': 'Torna alla chat',
|
||||||
|
|||||||
@@ -65,7 +65,6 @@ const assistant: TranslationStrings = {
|
|||||||
'assistant.connect.statusSignedOut': 'サインアウト済み',
|
'assistant.connect.statusSignedOut': 'サインアウト済み',
|
||||||
'assistant.connect.statusError': '接続できません',
|
'assistant.connect.statusError': '接続できません',
|
||||||
'assistant.connect.quota': '本日 {count} 件中 {used} 件のメッセージを使用',
|
'assistant.connect.quota': '本日 {count} 件中 {used} 件のメッセージを使用',
|
||||||
'assistant.connect.claudeLink': 'Claude をお使いですか?Claude で接続',
|
|
||||||
'assistant.privacy.what': '何が共有されますか?',
|
'assistant.privacy.what': '何が共有されますか?',
|
||||||
'assistant.privacy.banner': 'メッセージと、アシスタントが読み取る旅行データは {provider} に送信されます。',
|
'assistant.privacy.banner': 'メッセージと、アシスタントが読み取る旅行データは {provider} に送信されます。',
|
||||||
'assistant.privacy.ok': 'わかりました',
|
'assistant.privacy.ok': 'わかりました',
|
||||||
@@ -245,14 +244,6 @@ const assistant: TranslationStrings = {
|
|||||||
'アシスタントは、接続しているAIプロバイダーの検索でウェブを調べられます(口コミ、営業時間、イベントなど)。ChatGPT、Anthropic、OpenRouterで使え、検索はそのプロバイダーにだけ送られます。',
|
'アシスタントは、接続しているAIプロバイダーの検索でウェブを調べられます(口コミ、営業時間、イベントなど)。ChatGPT、Anthropic、OpenRouterで使え、検索はそのプロバイダーにだけ送られます。',
|
||||||
'assistant.admin.retentionDays': '{count} 日',
|
'assistant.admin.retentionDays': '{count} 日',
|
||||||
'assistant.admin.retentionForever': '無期限',
|
'assistant.admin.retentionForever': '無期限',
|
||||||
'assistant.claude.title': 'Claude と接続',
|
|
||||||
'assistant.claude.description': 'Claude の中で TREK を直接使えます。コネクタとして追加し、サインインすれば完了です。',
|
|
||||||
'assistant.claude.open': 'Claude で開く',
|
|
||||||
'assistant.claude.scan': 'またはスマートフォンでスキャン',
|
|
||||||
'assistant.claude.copyUrl': 'アドレスをコピー',
|
|
||||||
'assistant.claude.qrAlt': 'Claude に TREK を追加するための QR コード',
|
|
||||||
'assistant.claude.notPublic':
|
|
||||||
'Claude が TREK にアクセスできるのは、TREK がインターネット上で https で公開されている場合だけです。',
|
|
||||||
'assistant.mobile.tile': 'TREK に質問',
|
'assistant.mobile.tile': 'TREK に質問',
|
||||||
'assistant.mobile.tileHint': 'アシスタントと一緒に計画',
|
'assistant.mobile.tileHint': 'アシスタントと一緒に計画',
|
||||||
'assistant.mobile.backToChat': 'チャットに戻る',
|
'assistant.mobile.backToChat': 'チャットに戻る',
|
||||||
|
|||||||
@@ -65,7 +65,6 @@ const assistant: TranslationStrings = {
|
|||||||
'assistant.connect.statusSignedOut': '로그아웃됨',
|
'assistant.connect.statusSignedOut': '로그아웃됨',
|
||||||
'assistant.connect.statusError': '연결할 수 없음',
|
'assistant.connect.statusError': '연결할 수 없음',
|
||||||
'assistant.connect.quota': '오늘 {count}개 중 {used}개 메시지 사용',
|
'assistant.connect.quota': '오늘 {count}개 중 {used}개 메시지 사용',
|
||||||
'assistant.connect.claudeLink': 'Claude를 사용하시나요? Claude에서 연결하세요',
|
|
||||||
'assistant.privacy.what': '무엇이 공유되나요?',
|
'assistant.privacy.what': '무엇이 공유되나요?',
|
||||||
'assistant.privacy.banner': '메시지와 어시스턴트가 읽는 여행 데이터는 {provider}(으)로 전송됩니다.',
|
'assistant.privacy.banner': '메시지와 어시스턴트가 읽는 여행 데이터는 {provider}(으)로 전송됩니다.',
|
||||||
'assistant.privacy.ok': '확인',
|
'assistant.privacy.ok': '확인',
|
||||||
@@ -243,13 +242,6 @@ const assistant: TranslationStrings = {
|
|||||||
'어시스턴트가 연결된 AI 제공자의 검색으로 웹을 찾아볼 수 있습니다(리뷰, 영업시간, 행사). ChatGPT, Anthropic, OpenRouter에서 작동하며 검색은 해당 제공자에게만 전달됩니다.',
|
'어시스턴트가 연결된 AI 제공자의 검색으로 웹을 찾아볼 수 있습니다(리뷰, 영업시간, 행사). ChatGPT, Anthropic, OpenRouter에서 작동하며 검색은 해당 제공자에게만 전달됩니다.',
|
||||||
'assistant.admin.retentionDays': '{count}일',
|
'assistant.admin.retentionDays': '{count}일',
|
||||||
'assistant.admin.retentionForever': '영구',
|
'assistant.admin.retentionForever': '영구',
|
||||||
'assistant.claude.title': 'Claude와 연결',
|
|
||||||
'assistant.claude.description': 'Claude 안에서 바로 TREK을 사용하세요. 커넥터로 추가하고 로그인하면 끝입니다.',
|
|
||||||
'assistant.claude.open': 'Claude에서 열기',
|
|
||||||
'assistant.claude.scan': '또는 휴대폰으로 스캔',
|
|
||||||
'assistant.claude.copyUrl': '주소 복사',
|
|
||||||
'assistant.claude.qrAlt': 'Claude에 TREK을 추가하는 QR 코드',
|
|
||||||
'assistant.claude.notPublic': 'TREK이 인터넷에서 https로 접속 가능할 때만 Claude가 TREK에 연결할 수 있습니다.',
|
|
||||||
'assistant.mobile.tile': 'TREK에 질문',
|
'assistant.mobile.tile': 'TREK에 질문',
|
||||||
'assistant.mobile.tileHint': '어시스턴트와 함께 계획',
|
'assistant.mobile.tileHint': '어시스턴트와 함께 계획',
|
||||||
'assistant.mobile.backToChat': '채팅으로 돌아가기',
|
'assistant.mobile.backToChat': '채팅으로 돌아가기',
|
||||||
|
|||||||
@@ -66,7 +66,6 @@ const assistant: TranslationStrings = {
|
|||||||
'assistant.connect.statusError': 'Niet bereikbaar',
|
'assistant.connect.statusError': 'Niet bereikbaar',
|
||||||
'assistant.connect.quota': '{used} van {count} berichten vandaag gebruikt',
|
'assistant.connect.quota': '{used} van {count} berichten vandaag gebruikt',
|
||||||
'assistant.connect.quota.one': '{used} van {count} bericht vandaag gebruikt',
|
'assistant.connect.quota.one': '{used} van {count} bericht vandaag gebruikt',
|
||||||
'assistant.connect.claudeLink': 'Gebruik je Claude? Koppel in Claude',
|
|
||||||
'assistant.privacy.what': 'Wat wordt er gedeeld?',
|
'assistant.privacy.what': 'Wat wordt er gedeeld?',
|
||||||
'assistant.privacy.banner': 'Je berichten en de reisgegevens die de assistent leest, gaan naar {provider}.',
|
'assistant.privacy.banner': 'Je berichten en de reisgegevens die de assistent leest, gaan naar {provider}.',
|
||||||
'assistant.privacy.ok': 'Begrepen',
|
'assistant.privacy.ok': 'Begrepen',
|
||||||
@@ -254,13 +253,6 @@ const assistant: TranslationStrings = {
|
|||||||
'assistant.admin.retentionDays': '{count} dagen',
|
'assistant.admin.retentionDays': '{count} dagen',
|
||||||
'assistant.admin.retentionDays.one': '{count} dag',
|
'assistant.admin.retentionDays.one': '{count} dag',
|
||||||
'assistant.admin.retentionForever': 'Voor altijd',
|
'assistant.admin.retentionForever': 'Voor altijd',
|
||||||
'assistant.claude.title': 'Koppelen met Claude',
|
|
||||||
'assistant.claude.description': 'Gebruik TREK direct in Claude: voeg het toe als connector, log in, klaar.',
|
|
||||||
'assistant.claude.open': 'Openen in Claude',
|
|
||||||
'assistant.claude.scan': 'Of scan het met je telefoon',
|
|
||||||
'assistant.claude.copyUrl': 'Adres kopiëren',
|
|
||||||
'assistant.claude.qrAlt': 'QR-code om TREK toe te voegen in Claude',
|
|
||||||
'assistant.claude.notPublic': 'Claude kan TREK alleen bereiken als het via https op internet beschikbaar is.',
|
|
||||||
'assistant.mobile.tile': 'Vraag het TREK',
|
'assistant.mobile.tile': 'Vraag het TREK',
|
||||||
'assistant.mobile.tileHint': 'Plan met de assistent',
|
'assistant.mobile.tileHint': 'Plan met de assistent',
|
||||||
'assistant.mobile.backToChat': 'Terug naar chat',
|
'assistant.mobile.backToChat': 'Terug naar chat',
|
||||||
|
|||||||
@@ -68,7 +68,6 @@ const assistant: TranslationStrings = {
|
|||||||
'assistant.connect.quota.one': 'Dziś wykorzystano {used} z {count} wiadomości',
|
'assistant.connect.quota.one': 'Dziś wykorzystano {used} z {count} wiadomości',
|
||||||
'assistant.connect.quota.few': 'Dziś wykorzystano {used} z {count} wiadomości',
|
'assistant.connect.quota.few': 'Dziś wykorzystano {used} z {count} wiadomości',
|
||||||
'assistant.connect.quota.many': 'Dziś wykorzystano {used} z {count} wiadomości',
|
'assistant.connect.quota.many': 'Dziś wykorzystano {used} z {count} wiadomości',
|
||||||
'assistant.connect.claudeLink': 'Używasz Claude? Połącz w Claude',
|
|
||||||
'assistant.privacy.what': 'Co jest udostępniane?',
|
'assistant.privacy.what': 'Co jest udostępniane?',
|
||||||
'assistant.privacy.banner': 'Twoje wiadomości i dane podróży, które czyta asystent, trafiają do {provider}.',
|
'assistant.privacy.banner': 'Twoje wiadomości i dane podróży, które czyta asystent, trafiają do {provider}.',
|
||||||
'assistant.privacy.ok': 'Rozumiem',
|
'assistant.privacy.ok': 'Rozumiem',
|
||||||
@@ -264,14 +263,6 @@ const assistant: TranslationStrings = {
|
|||||||
'assistant.admin.retentionDays.few': '{count} dni',
|
'assistant.admin.retentionDays.few': '{count} dni',
|
||||||
'assistant.admin.retentionDays.many': '{count} dni',
|
'assistant.admin.retentionDays.many': '{count} dni',
|
||||||
'assistant.admin.retentionForever': 'Zawsze',
|
'assistant.admin.retentionForever': 'Zawsze',
|
||||||
'assistant.claude.title': 'Połącz z Claude',
|
|
||||||
'assistant.claude.description': 'Używaj TREK bezpośrednio w Claude: dodaj jako konektor, zaloguj się, gotowe.',
|
|
||||||
'assistant.claude.open': 'Otwórz w Claude',
|
|
||||||
'assistant.claude.scan': 'Albo zeskanuj telefonem',
|
|
||||||
'assistant.claude.copyUrl': 'Kopiuj adres',
|
|
||||||
'assistant.claude.qrAlt': 'Kod QR do dodania TREK w Claude',
|
|
||||||
'assistant.claude.notPublic':
|
|
||||||
'Claude może połączyć się z TREK tylko wtedy, gdy jest on dostępny przez https w internecie.',
|
|
||||||
'assistant.mobile.tile': 'Zapytaj TREK',
|
'assistant.mobile.tile': 'Zapytaj TREK',
|
||||||
'assistant.mobile.tileHint': 'Planuj z asystentem',
|
'assistant.mobile.tileHint': 'Planuj z asystentem',
|
||||||
'assistant.mobile.backToChat': 'Wróć do czatu',
|
'assistant.mobile.backToChat': 'Wróć do czatu',
|
||||||
|
|||||||
@@ -68,7 +68,6 @@ const assistant: TranslationStrings = {
|
|||||||
'assistant.connect.quota.one': 'Сегодня использовано {used} из {count} сообщения',
|
'assistant.connect.quota.one': 'Сегодня использовано {used} из {count} сообщения',
|
||||||
'assistant.connect.quota.few': 'Сегодня использовано {used} из {count} сообщений',
|
'assistant.connect.quota.few': 'Сегодня использовано {used} из {count} сообщений',
|
||||||
'assistant.connect.quota.many': 'Сегодня использовано {used} из {count} сообщений',
|
'assistant.connect.quota.many': 'Сегодня использовано {used} из {count} сообщений',
|
||||||
'assistant.connect.claudeLink': 'Пользуетесь Claude? Подключитесь в Claude',
|
|
||||||
'assistant.privacy.what': 'Что передаётся?',
|
'assistant.privacy.what': 'Что передаётся?',
|
||||||
'assistant.privacy.banner': 'Ваши сообщения и данные поездки, которые читает ассистент, передаются в {provider}.',
|
'assistant.privacy.banner': 'Ваши сообщения и данные поездки, которые читает ассистент, передаются в {provider}.',
|
||||||
'assistant.privacy.ok': 'Понятно',
|
'assistant.privacy.ok': 'Понятно',
|
||||||
@@ -264,13 +263,6 @@ const assistant: TranslationStrings = {
|
|||||||
'assistant.admin.retentionDays.few': '{count} дня',
|
'assistant.admin.retentionDays.few': '{count} дня',
|
||||||
'assistant.admin.retentionDays.many': '{count} дней',
|
'assistant.admin.retentionDays.many': '{count} дней',
|
||||||
'assistant.admin.retentionForever': 'Всегда',
|
'assistant.admin.retentionForever': 'Всегда',
|
||||||
'assistant.claude.title': 'Подключиться через Claude',
|
|
||||||
'assistant.claude.description': 'Пользуйтесь TREK прямо в Claude: добавьте его как коннектор, войдите, готово.',
|
|
||||||
'assistant.claude.open': 'Открыть в Claude',
|
|
||||||
'assistant.claude.scan': 'Или отсканируйте телефоном',
|
|
||||||
'assistant.claude.copyUrl': 'Копировать адрес',
|
|
||||||
'assistant.claude.qrAlt': 'QR-код для добавления TREK в Claude',
|
|
||||||
'assistant.claude.notPublic': 'Claude может обратиться к TREK, только если он доступен по https из интернета.',
|
|
||||||
'assistant.mobile.tile': 'Спросить TREK',
|
'assistant.mobile.tile': 'Спросить TREK',
|
||||||
'assistant.mobile.tileHint': 'Планируйте с ассистентом',
|
'assistant.mobile.tileHint': 'Планируйте с ассистентом',
|
||||||
'assistant.mobile.backToChat': 'Назад к чату',
|
'assistant.mobile.backToChat': 'Назад к чату',
|
||||||
|
|||||||
@@ -66,7 +66,6 @@ const assistant: TranslationStrings = {
|
|||||||
'assistant.connect.quota': 'Dnes použité správy: {used} z {count}',
|
'assistant.connect.quota': 'Dnes použité správy: {used} z {count}',
|
||||||
'assistant.connect.quota.one': 'Dnes použité správy: {used} z {count}',
|
'assistant.connect.quota.one': 'Dnes použité správy: {used} z {count}',
|
||||||
'assistant.connect.quota.few': 'Dnes použité správy: {used} z {count}',
|
'assistant.connect.quota.few': 'Dnes použité správy: {used} z {count}',
|
||||||
'assistant.connect.claudeLink': 'Používaš Claude? Pripoj sa v Claude',
|
|
||||||
'assistant.privacy.what': 'Čo sa zdieľa?',
|
'assistant.privacy.what': 'Čo sa zdieľa?',
|
||||||
'assistant.privacy.banner': 'Tvoje správy a údaje o ceste, ktoré asistent číta, idú do {provider}.',
|
'assistant.privacy.banner': 'Tvoje správy a údaje o ceste, ktoré asistent číta, idú do {provider}.',
|
||||||
'assistant.privacy.ok': 'Rozumiem',
|
'assistant.privacy.ok': 'Rozumiem',
|
||||||
@@ -257,13 +256,6 @@ const assistant: TranslationStrings = {
|
|||||||
'assistant.admin.retentionDays.one': '{count} deň',
|
'assistant.admin.retentionDays.one': '{count} deň',
|
||||||
'assistant.admin.retentionDays.few': '{count} dni',
|
'assistant.admin.retentionDays.few': '{count} dni',
|
||||||
'assistant.admin.retentionForever': 'Navždy',
|
'assistant.admin.retentionForever': 'Navždy',
|
||||||
'assistant.claude.title': 'Pripojiť cez Claude',
|
|
||||||
'assistant.claude.description': 'Používaj TREK priamo v Claude: pridaj ho ako konektor, prihlás sa a hotovo.',
|
|
||||||
'assistant.claude.open': 'Otvoriť v Claude',
|
|
||||||
'assistant.claude.scan': 'Alebo to naskenuj telefónom',
|
|
||||||
'assistant.claude.copyUrl': 'Kopírovať adresu',
|
|
||||||
'assistant.claude.qrAlt': 'QR kód na pridanie TREK do Claude',
|
|
||||||
'assistant.claude.notPublic': 'Claude sa k TREK dostane, len ak je dostupný cez https na internete.',
|
|
||||||
'assistant.mobile.tile': 'Opýtaj sa TREK',
|
'assistant.mobile.tile': 'Opýtaj sa TREK',
|
||||||
'assistant.mobile.tileHint': 'Plánuj s asistentom',
|
'assistant.mobile.tileHint': 'Plánuj s asistentom',
|
||||||
'assistant.mobile.backToChat': 'Späť do chatu',
|
'assistant.mobile.backToChat': 'Späť do chatu',
|
||||||
|
|||||||
@@ -67,7 +67,6 @@ const assistant: TranslationStrings = {
|
|||||||
'assistant.connect.statusError': 'Går inte att nå',
|
'assistant.connect.statusError': 'Går inte att nå',
|
||||||
'assistant.connect.quota': '{used} av {count} meddelanden använda i dag',
|
'assistant.connect.quota': '{used} av {count} meddelanden använda i dag',
|
||||||
'assistant.connect.quota.one': '{used} av {count} meddelande använt i dag',
|
'assistant.connect.quota.one': '{used} av {count} meddelande använt i dag',
|
||||||
'assistant.connect.claudeLink': 'Använder du Claude? Anslut i Claude',
|
|
||||||
'assistant.privacy.what': 'Vad delas?',
|
'assistant.privacy.what': 'Vad delas?',
|
||||||
'assistant.privacy.banner': 'Dina meddelanden och de resedata som assistenten läser skickas till {provider}.',
|
'assistant.privacy.banner': 'Dina meddelanden och de resedata som assistenten läser skickas till {provider}.',
|
||||||
'assistant.privacy.ok': 'Uppfattat',
|
'assistant.privacy.ok': 'Uppfattat',
|
||||||
@@ -254,13 +253,6 @@ const assistant: TranslationStrings = {
|
|||||||
'assistant.admin.retentionDays': '{count} dagar',
|
'assistant.admin.retentionDays': '{count} dagar',
|
||||||
'assistant.admin.retentionDays.one': '{count} dag',
|
'assistant.admin.retentionDays.one': '{count} dag',
|
||||||
'assistant.admin.retentionForever': 'För alltid',
|
'assistant.admin.retentionForever': 'För alltid',
|
||||||
'assistant.claude.title': 'Anslut med Claude',
|
|
||||||
'assistant.claude.description': 'Använd TREK direkt i Claude: lägg till det som koppling, logga in, klart.',
|
|
||||||
'assistant.claude.open': 'Öppna i Claude',
|
|
||||||
'assistant.claude.scan': 'Eller skanna med din telefon',
|
|
||||||
'assistant.claude.copyUrl': 'Kopiera adressen',
|
|
||||||
'assistant.claude.qrAlt': 'QR-kod för att lägga till TREK i Claude',
|
|
||||||
'assistant.claude.notPublic': 'Claude kan bara nå TREK när det är tillgängligt via https på internet.',
|
|
||||||
'assistant.mobile.tile': 'Fråga TREK',
|
'assistant.mobile.tile': 'Fråga TREK',
|
||||||
'assistant.mobile.tileHint': 'Planera med assistenten',
|
'assistant.mobile.tileHint': 'Planera med assistenten',
|
||||||
'assistant.mobile.backToChat': 'Tillbaka till chatten',
|
'assistant.mobile.backToChat': 'Tillbaka till chatten',
|
||||||
|
|||||||
@@ -64,7 +64,6 @@ const assistant: TranslationStrings = {
|
|||||||
'assistant.connect.statusSignedOut': 'ออกจากระบบแล้ว',
|
'assistant.connect.statusSignedOut': 'ออกจากระบบแล้ว',
|
||||||
'assistant.connect.statusError': 'เข้าถึงไม่ได้',
|
'assistant.connect.statusError': 'เข้าถึงไม่ได้',
|
||||||
'assistant.connect.quota': 'วันนี้ใช้ไปแล้ว {used} จาก {count} ข้อความ',
|
'assistant.connect.quota': 'วันนี้ใช้ไปแล้ว {used} จาก {count} ข้อความ',
|
||||||
'assistant.connect.claudeLink': 'ใช้ Claude อยู่ไหม เชื่อมต่อใน Claude ได้เลย',
|
|
||||||
'assistant.privacy.what': 'มีอะไรถูกแชร์บ้าง',
|
'assistant.privacy.what': 'มีอะไรถูกแชร์บ้าง',
|
||||||
'assistant.privacy.banner': 'ข้อความของคุณและข้อมูลทริปที่ผู้ช่วยอ่านจะถูกส่งไปยัง {provider}',
|
'assistant.privacy.banner': 'ข้อความของคุณและข้อมูลทริปที่ผู้ช่วยอ่านจะถูกส่งไปยัง {provider}',
|
||||||
'assistant.privacy.ok': 'เข้าใจแล้ว',
|
'assistant.privacy.ok': 'เข้าใจแล้ว',
|
||||||
@@ -243,13 +242,6 @@ const assistant: TranslationStrings = {
|
|||||||
'ผู้ช่วยค้นข้อมูลบนเว็บได้ (รีวิว เวลาเปิดปิด งานอีเวนต์) ผ่านการค้นหาของผู้ให้บริการ AI ที่ใช้อยู่ ใช้ได้กับ ChatGPT, Anthropic และ OpenRouter และการค้นหาจะส่งไปที่ผู้ให้บริการนั้นเท่านั้น',
|
'ผู้ช่วยค้นข้อมูลบนเว็บได้ (รีวิว เวลาเปิดปิด งานอีเวนต์) ผ่านการค้นหาของผู้ให้บริการ AI ที่ใช้อยู่ ใช้ได้กับ ChatGPT, Anthropic และ OpenRouter และการค้นหาจะส่งไปที่ผู้ให้บริการนั้นเท่านั้น',
|
||||||
'assistant.admin.retentionDays': '{count} วัน',
|
'assistant.admin.retentionDays': '{count} วัน',
|
||||||
'assistant.admin.retentionForever': 'ตลอดไป',
|
'assistant.admin.retentionForever': 'ตลอดไป',
|
||||||
'assistant.claude.title': 'เชื่อมต่อกับ Claude',
|
|
||||||
'assistant.claude.description': 'ใช้ TREK ใน Claude ได้โดยตรง: เพิ่มเป็นตัวเชื่อมต่อ ลงชื่อเข้าใช้ เสร็จเลย',
|
|
||||||
'assistant.claude.open': 'เปิดใน Claude',
|
|
||||||
'assistant.claude.scan': 'หรือสแกนด้วยโทรศัพท์',
|
|
||||||
'assistant.claude.copyUrl': 'คัดลอกที่อยู่',
|
|
||||||
'assistant.claude.qrAlt': 'คิวอาร์โค้ดสำหรับเพิ่ม TREK ใน Claude',
|
|
||||||
'assistant.claude.notPublic': 'Claude จะเข้าถึง TREK ได้ก็ต่อเมื่อ TREK เปิดให้เข้าถึงผ่าน https บนอินเทอร์เน็ต',
|
|
||||||
'assistant.mobile.tile': 'ถาม TREK',
|
'assistant.mobile.tile': 'ถาม TREK',
|
||||||
'assistant.mobile.tileHint': 'วางแผนกับผู้ช่วย',
|
'assistant.mobile.tileHint': 'วางแผนกับผู้ช่วย',
|
||||||
'assistant.mobile.backToChat': 'กลับไปที่แชต',
|
'assistant.mobile.backToChat': 'กลับไปที่แชต',
|
||||||
|
|||||||
@@ -65,7 +65,6 @@ const assistant: TranslationStrings = {
|
|||||||
'assistant.connect.statusError': 'Ulaşılamıyor',
|
'assistant.connect.statusError': 'Ulaşılamıyor',
|
||||||
'assistant.connect.quota': 'Bugün {count} mesajın {used} tanesi kullanıldı',
|
'assistant.connect.quota': 'Bugün {count} mesajın {used} tanesi kullanıldı',
|
||||||
'assistant.connect.quota.one': 'Bugün {count} mesajın {used} tanesi kullanıldı',
|
'assistant.connect.quota.one': 'Bugün {count} mesajın {used} tanesi kullanıldı',
|
||||||
'assistant.connect.claudeLink': 'Claude mı kullanıyorsun? Claude içinden bağla',
|
|
||||||
'assistant.privacy.what': 'Neler paylaşılıyor?',
|
'assistant.privacy.what': 'Neler paylaşılıyor?',
|
||||||
'assistant.privacy.banner': 'Mesajların ve asistanın okuduğu gezi verileri {provider} hizmetine gider.',
|
'assistant.privacy.banner': 'Mesajların ve asistanın okuduğu gezi verileri {provider} hizmetine gider.',
|
||||||
'assistant.privacy.ok': 'Anladım',
|
'assistant.privacy.ok': 'Anladım',
|
||||||
@@ -252,13 +251,6 @@ const assistant: TranslationStrings = {
|
|||||||
'assistant.admin.retentionDays': '{count} gün',
|
'assistant.admin.retentionDays': '{count} gün',
|
||||||
'assistant.admin.retentionDays.one': '{count} gün',
|
'assistant.admin.retentionDays.one': '{count} gün',
|
||||||
'assistant.admin.retentionForever': 'Süresiz',
|
'assistant.admin.retentionForever': 'Süresiz',
|
||||||
'assistant.claude.title': 'Claude ile bağlan',
|
|
||||||
'assistant.claude.description': 'TREK’i doğrudan Claude içinde kullan: bağlayıcı olarak ekle, giriş yap, hepsi bu.',
|
|
||||||
'assistant.claude.open': 'Claude’da aç',
|
|
||||||
'assistant.claude.scan': 'Ya da telefonunla tara',
|
|
||||||
'assistant.claude.copyUrl': 'Adresi kopyala',
|
|
||||||
'assistant.claude.qrAlt': 'TREK’i Claude’a eklemek için QR kodu',
|
|
||||||
'assistant.claude.notPublic': 'Claude, TREK’e yalnızca internette https üzerinden erişilebiliyorsa ulaşabilir.',
|
|
||||||
'assistant.mobile.tile': 'TREK’e sor',
|
'assistant.mobile.tile': 'TREK’e sor',
|
||||||
'assistant.mobile.tileHint': 'Asistanla planla',
|
'assistant.mobile.tileHint': 'Asistanla planla',
|
||||||
'assistant.mobile.backToChat': 'Sohbete dön',
|
'assistant.mobile.backToChat': 'Sohbete dön',
|
||||||
|
|||||||
@@ -68,7 +68,6 @@ const assistant: TranslationStrings = {
|
|||||||
'assistant.connect.quota.one': 'Сьогодні використано {used} з {count} повідомлення',
|
'assistant.connect.quota.one': 'Сьогодні використано {used} з {count} повідомлення',
|
||||||
'assistant.connect.quota.few': 'Сьогодні використано {used} з {count} повідомлень',
|
'assistant.connect.quota.few': 'Сьогодні використано {used} з {count} повідомлень',
|
||||||
'assistant.connect.quota.many': 'Сьогодні використано {used} з {count} повідомлень',
|
'assistant.connect.quota.many': 'Сьогодні використано {used} з {count} повідомлень',
|
||||||
'assistant.connect.claudeLink': 'Користуєтеся Claude? Підключіть у Claude',
|
|
||||||
'assistant.privacy.what': 'Що передається?',
|
'assistant.privacy.what': 'Що передається?',
|
||||||
'assistant.privacy.banner': 'Ваші повідомлення й дані подорожі, які читає асистент, надсилаються до {provider}.',
|
'assistant.privacy.banner': 'Ваші повідомлення й дані подорожі, які читає асистент, надсилаються до {provider}.',
|
||||||
'assistant.privacy.ok': 'Зрозуміло',
|
'assistant.privacy.ok': 'Зрозуміло',
|
||||||
@@ -264,13 +263,6 @@ const assistant: TranslationStrings = {
|
|||||||
'assistant.admin.retentionDays.few': '{count} дні',
|
'assistant.admin.retentionDays.few': '{count} дні',
|
||||||
'assistant.admin.retentionDays.many': '{count} днів',
|
'assistant.admin.retentionDays.many': '{count} днів',
|
||||||
'assistant.admin.retentionForever': 'Назавжди',
|
'assistant.admin.retentionForever': 'Назавжди',
|
||||||
'assistant.claude.title': 'Підключити через Claude',
|
|
||||||
'assistant.claude.description': 'Користуйтеся TREK прямо в Claude: додайте його як конектор, увійдіть, і готово.',
|
|
||||||
'assistant.claude.open': 'Відкрити в Claude',
|
|
||||||
'assistant.claude.scan': 'Або відскануйте телефоном',
|
|
||||||
'assistant.claude.copyUrl': 'Скопіювати адресу',
|
|
||||||
'assistant.claude.qrAlt': 'QR-код, щоб додати TREK у Claude',
|
|
||||||
'assistant.claude.notPublic': 'Claude може дістатися до TREK, лише коли він доступний в інтернеті через https.',
|
|
||||||
'assistant.mobile.tile': 'Запитати TREK',
|
'assistant.mobile.tile': 'Запитати TREK',
|
||||||
'assistant.mobile.tileHint': 'Плануйте з асистентом',
|
'assistant.mobile.tileHint': 'Плануйте з асистентом',
|
||||||
'assistant.mobile.backToChat': 'Назад до чату',
|
'assistant.mobile.backToChat': 'Назад до чату',
|
||||||
|
|||||||
@@ -65,7 +65,6 @@ const assistant: TranslationStrings = {
|
|||||||
'assistant.connect.statusSignedOut': 'Đã đăng xuất',
|
'assistant.connect.statusSignedOut': 'Đã đăng xuất',
|
||||||
'assistant.connect.statusError': 'Không thể kết nối',
|
'assistant.connect.statusError': 'Không thể kết nối',
|
||||||
'assistant.connect.quota': 'Hôm nay đã dùng {used}/{count} tin nhắn',
|
'assistant.connect.quota': 'Hôm nay đã dùng {used}/{count} tin nhắn',
|
||||||
'assistant.connect.claudeLink': 'Bạn dùng Claude? Kết nối ngay trong Claude',
|
|
||||||
'assistant.privacy.what': 'Những gì được chia sẻ?',
|
'assistant.privacy.what': 'Những gì được chia sẻ?',
|
||||||
'assistant.privacy.banner': 'Tin nhắn của bạn và dữ liệu chuyến đi mà trợ lý đọc sẽ được gửi đến {provider}.',
|
'assistant.privacy.banner': 'Tin nhắn của bạn và dữ liệu chuyến đi mà trợ lý đọc sẽ được gửi đến {provider}.',
|
||||||
'assistant.privacy.ok': 'Đã hiểu',
|
'assistant.privacy.ok': 'Đã hiểu',
|
||||||
@@ -245,13 +244,6 @@ const assistant: TranslationStrings = {
|
|||||||
'Trợ lý có thể tra cứu trên web (đánh giá, giờ mở cửa, sự kiện) qua công cụ tìm kiếm của nhà cung cấp AI mà nó dùng. Hoạt động với ChatGPT, Anthropic và OpenRouter; các lượt tìm chỉ gửi đến nhà cung cấp đó.',
|
'Trợ lý có thể tra cứu trên web (đánh giá, giờ mở cửa, sự kiện) qua công cụ tìm kiếm của nhà cung cấp AI mà nó dùng. Hoạt động với ChatGPT, Anthropic và OpenRouter; các lượt tìm chỉ gửi đến nhà cung cấp đó.',
|
||||||
'assistant.admin.retentionDays': '{count} ngày',
|
'assistant.admin.retentionDays': '{count} ngày',
|
||||||
'assistant.admin.retentionForever': 'Mãi mãi',
|
'assistant.admin.retentionForever': 'Mãi mãi',
|
||||||
'assistant.claude.title': 'Kết nối với Claude',
|
|
||||||
'assistant.claude.description': 'Dùng TREK ngay trong Claude: thêm làm trình kết nối, đăng nhập, xong.',
|
|
||||||
'assistant.claude.open': 'Mở trong Claude',
|
|
||||||
'assistant.claude.scan': 'Hoặc quét bằng điện thoại',
|
|
||||||
'assistant.claude.copyUrl': 'Sao chép địa chỉ',
|
|
||||||
'assistant.claude.qrAlt': 'Mã QR để thêm TREK vào Claude',
|
|
||||||
'assistant.claude.notPublic': 'Claude chỉ truy cập được TREK khi TREK có thể truy cập qua https trên internet.',
|
|
||||||
'assistant.mobile.tile': 'Hỏi TREK',
|
'assistant.mobile.tile': 'Hỏi TREK',
|
||||||
'assistant.mobile.tileHint': 'Lên kế hoạch cùng trợ lý',
|
'assistant.mobile.tileHint': 'Lên kế hoạch cùng trợ lý',
|
||||||
'assistant.mobile.backToChat': 'Quay lại trò chuyện',
|
'assistant.mobile.backToChat': 'Quay lại trò chuyện',
|
||||||
|
|||||||
@@ -63,7 +63,6 @@ const assistant: TranslationStrings = {
|
|||||||
'assistant.connect.statusSignedOut': '已登出',
|
'assistant.connect.statusSignedOut': '已登出',
|
||||||
'assistant.connect.statusError': '無法連線',
|
'assistant.connect.statusError': '無法連線',
|
||||||
'assistant.connect.quota': '今天已使用 {used}/{count} 則訊息',
|
'assistant.connect.quota': '今天已使用 {used}/{count} 則訊息',
|
||||||
'assistant.connect.claudeLink': '在用 Claude?在 Claude 中連結',
|
|
||||||
'assistant.privacy.what': '會分享哪些內容?',
|
'assistant.privacy.what': '會分享哪些內容?',
|
||||||
'assistant.privacy.banner': '你的訊息以及助理讀取的旅程資料會傳送給 {provider}。',
|
'assistant.privacy.banner': '你的訊息以及助理讀取的旅程資料會傳送給 {provider}。',
|
||||||
'assistant.privacy.ok': '我知道了',
|
'assistant.privacy.ok': '我知道了',
|
||||||
@@ -240,13 +239,6 @@ const assistant: TranslationStrings = {
|
|||||||
'助理可以透過所連接 AI 供應商的搜尋功能在網路上查詢資訊(評價、營業時間、活動)。適用於 ChatGPT、Anthropic 和 OpenRouter;搜尋只會傳送給該供應商。',
|
'助理可以透過所連接 AI 供應商的搜尋功能在網路上查詢資訊(評價、營業時間、活動)。適用於 ChatGPT、Anthropic 和 OpenRouter;搜尋只會傳送給該供應商。',
|
||||||
'assistant.admin.retentionDays': '{count} 天',
|
'assistant.admin.retentionDays': '{count} 天',
|
||||||
'assistant.admin.retentionForever': '永久',
|
'assistant.admin.retentionForever': '永久',
|
||||||
'assistant.claude.title': '與 Claude 連結',
|
|
||||||
'assistant.claude.description': '直接在 Claude 中使用 TREK:新增為連接器,登入,完成。',
|
|
||||||
'assistant.claude.open': '在 Claude 中開啟',
|
|
||||||
'assistant.claude.scan': '或用手機掃描',
|
|
||||||
'assistant.claude.copyUrl': '複製網址',
|
|
||||||
'assistant.claude.qrAlt': '在 Claude 中新增 TREK 的 QR 碼',
|
|
||||||
'assistant.claude.notPublic': '只有當 TREK 能透過 https 在網際網路上存取時,Claude 才能連到它。',
|
|
||||||
'assistant.mobile.tile': '問問 TREK',
|
'assistant.mobile.tile': '問問 TREK',
|
||||||
'assistant.mobile.tileHint': '和助理一起規劃',
|
'assistant.mobile.tileHint': '和助理一起規劃',
|
||||||
'assistant.mobile.backToChat': '返回對話',
|
'assistant.mobile.backToChat': '返回對話',
|
||||||
|
|||||||
@@ -63,7 +63,6 @@ const assistant: TranslationStrings = {
|
|||||||
'assistant.connect.statusSignedOut': '已退出登录',
|
'assistant.connect.statusSignedOut': '已退出登录',
|
||||||
'assistant.connect.statusError': '无法访问',
|
'assistant.connect.statusError': '无法访问',
|
||||||
'assistant.connect.quota': '今天已使用 {used}/{count} 条消息',
|
'assistant.connect.quota': '今天已使用 {used}/{count} 条消息',
|
||||||
'assistant.connect.claudeLink': '在用 Claude?在 Claude 中连接',
|
|
||||||
'assistant.privacy.what': '会共享哪些内容?',
|
'assistant.privacy.what': '会共享哪些内容?',
|
||||||
'assistant.privacy.banner': '你的消息以及助手读取的旅行数据会发送给 {provider}。',
|
'assistant.privacy.banner': '你的消息以及助手读取的旅行数据会发送给 {provider}。',
|
||||||
'assistant.privacy.ok': '知道了',
|
'assistant.privacy.ok': '知道了',
|
||||||
@@ -240,13 +239,6 @@ const assistant: TranslationStrings = {
|
|||||||
'助手可以通过其所连接的 AI 提供商的搜索功能在网上查询信息(评价、营业时间、活动)。适用于 ChatGPT、Anthropic 和 OpenRouter;搜索只会发送给该提供商。',
|
'助手可以通过其所连接的 AI 提供商的搜索功能在网上查询信息(评价、营业时间、活动)。适用于 ChatGPT、Anthropic 和 OpenRouter;搜索只会发送给该提供商。',
|
||||||
'assistant.admin.retentionDays': '{count} 天',
|
'assistant.admin.retentionDays': '{count} 天',
|
||||||
'assistant.admin.retentionForever': '永久',
|
'assistant.admin.retentionForever': '永久',
|
||||||
'assistant.claude.title': '与 Claude 连接',
|
|
||||||
'assistant.claude.description': '直接在 Claude 中使用 TREK:添加为连接器,登录,完成。',
|
|
||||||
'assistant.claude.open': '在 Claude 中打开',
|
|
||||||
'assistant.claude.scan': '或用手机扫描',
|
|
||||||
'assistant.claude.copyUrl': '复制地址',
|
|
||||||
'assistant.claude.qrAlt': '在 Claude 中添加 TREK 的二维码',
|
|
||||||
'assistant.claude.notPublic': '只有当 TREK 能通过 https 在互联网上访问时,Claude 才能连接到它。',
|
|
||||||
'assistant.mobile.tile': '问问 TREK',
|
'assistant.mobile.tile': '问问 TREK',
|
||||||
'assistant.mobile.tileHint': '和助手一起规划',
|
'assistant.mobile.tileHint': '和助手一起规划',
|
||||||
'assistant.mobile.backToChat': '返回对话',
|
'assistant.mobile.backToChat': '返回对话',
|
||||||
|
|||||||
Reference in New Issue
Block a user