Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4cc62f4c4e | |||
| cb34c46226 | |||
| e777118272 | |||
| 3f2da7a867 | |||
| 9c3649f011 | |||
| 73fdbdc0c3 | |||
| 0879b5aecc | |||
| 9729b0b575 | |||
| 35acdb8ba1 | |||
| a19fdf6161 | |||
| 813dd7a7eb | |||
| d51b9de2b0 | |||
| 6254510e0f | |||
| c525b2f84c | |||
| b3d30a834e | |||
| 589d3354eb | |||
| 8ce3b24a01 | |||
| 06a1dcd730 | |||
| 4b633d8cd2 | |||
| 728a275fef | |||
| f6befaa6b2 | |||
| fd6d3fddd5 | |||
| 280e55bbc9 | |||
| e84060cdf7 | |||
| fbc85b05a1 | |||
| 01333c6ff3 | |||
| aecef03090 | |||
| 8dacd0a50c | |||
| 3c73da4427 | |||
| cff2e55db7 |
@@ -460,6 +460,20 @@ const exportDialog = (page: Page) => page.getByRole('dialog', { name: 'Export' }
|
||||
const thumbs = (page: Page) => page.locator('.st-thumb-row')
|
||||
/** The hit targets over the spread, one per element. */
|
||||
const sheetElements = (page: Page) => page.locator('.st-sheet > div[style*="left:"]')
|
||||
/**
|
||||
* The hit target of the first text on the spread on screen. The targets above the page are empty
|
||||
* boxes, so the one over the drawn words is found by where the words are and marked for the locator.
|
||||
*/
|
||||
async function markTextTarget(page: Page): Promise<void> {
|
||||
const words = page.locator('.st-sheet').getByText(/\p{L}{3,}/u).first()
|
||||
await expect(words).toBeVisible()
|
||||
await words.evaluate(el => {
|
||||
const r = el.getBoundingClientRect()
|
||||
const hit = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2)
|
||||
hit?.setAttribute('data-help-text', '')
|
||||
})
|
||||
}
|
||||
const textTarget = (page: Page) => page.locator('.st-sheet [data-help-text]')
|
||||
|
||||
async function studioSection(page: Page, name: string): Promise<void> {
|
||||
await railButton(page, name).click()
|
||||
@@ -1458,16 +1472,162 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
],
|
||||
},
|
||||
|
||||
'studio-my-layouts': {
|
||||
guide: guide('studio-my-layouts'),
|
||||
start: openStudio,
|
||||
steps: [
|
||||
{
|
||||
prepare: async p => {
|
||||
await studioSection(p, 'Pages')
|
||||
await thumbs(p).nth(2).locator('.st-thumb').click()
|
||||
await settle(p)
|
||||
},
|
||||
target: p => railButton(p, 'Layouts'),
|
||||
act: async p => { await studioSection(p, 'Layouts') },
|
||||
},
|
||||
{
|
||||
// Pointed at rather than pressed, so the seed book keeps no layout of its own for the guides after this one.
|
||||
prepare: async p => {
|
||||
await sidePanel(p).getByRole('button', { name: 'Save this page as a layout' }).scrollIntoViewIfNeeded()
|
||||
await settle(p)
|
||||
},
|
||||
target: p => sidePanel(p).locator('.st-saved-layouts'),
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
'studio-theme': {
|
||||
guide: guide('studio-theme'),
|
||||
start: openStudio,
|
||||
steps: [
|
||||
{
|
||||
target: p => railButton(p, 'Theme'),
|
||||
act: async p => { await studioSection(p, 'Theme') },
|
||||
},
|
||||
{
|
||||
target: p => sidePanel(p).locator('.st-theme-grid'),
|
||||
act: async p => {
|
||||
await sidePanel(p).locator('.st-theme-card').nth(1).click()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
await sidePanel(p).getByRole('button', { name: 'Apply to the book' }).scrollIntoViewIfNeeded()
|
||||
await settle(p)
|
||||
},
|
||||
target: p => sidePanel(p).locator('.st-theme-apply'),
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
'studio-stickers': {
|
||||
guide: guide('studio-stickers'),
|
||||
start: openStudio,
|
||||
steps: [
|
||||
{
|
||||
prepare: async p => { await studioSection(p, 'Elements') },
|
||||
target: p => sidePanel(p).getByRole('tab', { name: 'Stickers' }),
|
||||
act: async p => {
|
||||
await sidePanel(p).getByRole('tab', { name: 'Stickers' }).click()
|
||||
await expect(sidePanel(p).locator('.st-shape-grid').first()).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => sidePanel(p).locator('.st-shape-grid').first(),
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
'studio-travel': {
|
||||
guide: guide('studio-travel'),
|
||||
start: openStudio,
|
||||
steps: [
|
||||
{
|
||||
target: p => railButton(p, 'Travel'),
|
||||
act: async p => { await studioSection(p, 'Travel') },
|
||||
act: async p => {
|
||||
await studioSection(p, 'Travel')
|
||||
await expect(sidePanel(p).locator('.st-travel-card').first()).toBeVisible({ timeout: 15_000 })
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: sidePanel,
|
||||
target: p => sidePanel(p).locator('.st-travel-grid').first(),
|
||||
},
|
||||
{
|
||||
target: p => sidePanel(p).locator('.st-travel-card').first(),
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
'studio-text-effects': {
|
||||
guide: guide('studio-text-effects'),
|
||||
start: openStudio,
|
||||
steps: [
|
||||
{
|
||||
prepare: async p => {
|
||||
await studioSection(p, 'Pages')
|
||||
await thumbs(p).nth(2).locator('.st-thumb').click()
|
||||
await settle(p)
|
||||
await markTextTarget(p)
|
||||
},
|
||||
target: p => textTarget(p),
|
||||
act: async p => {
|
||||
await textTarget(p).click()
|
||||
await expect(inspector(p).getByRole('button', { name: 'Effects' })).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
const head = inspector(p).getByRole('button', { name: 'Effects' })
|
||||
if ((await head.getAttribute('aria-expanded')) !== 'true') await head.click()
|
||||
await head.scrollIntoViewIfNeeded()
|
||||
await settle(p)
|
||||
},
|
||||
target: p => inspector(p).locator('.st-section').filter({ has: p.getByRole('button', { name: 'Effects' }) }),
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
'studio-background': {
|
||||
guide: guide('studio-background'),
|
||||
start: openStudio,
|
||||
steps: [
|
||||
{
|
||||
prepare: async p => {
|
||||
await studioSection(p, 'Pages')
|
||||
await thumbs(p).nth(2).locator('.st-thumb').click()
|
||||
await settle(p)
|
||||
},
|
||||
target: p => inspector(p).locator('.st-section').filter({ hasText: 'Page background' }).first(),
|
||||
},
|
||||
{
|
||||
target: p => inspector(p).locator('.st-paint-tabs').first(),
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
'studio-layers': {
|
||||
guide: guide('studio-layers'),
|
||||
start: openStudio,
|
||||
steps: [
|
||||
{
|
||||
prepare: async p => {
|
||||
await studioSection(p, 'Pages')
|
||||
await thumbs(p).nth(2).locator('.st-thumb').click()
|
||||
await settle(p)
|
||||
},
|
||||
target: p => railButton(p, 'Layers'),
|
||||
act: async p => {
|
||||
await studioSection(p, 'Layers')
|
||||
await expect(sidePanel(p).locator('.st-layer').first()).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => sidePanel(p).locator('.st-layer').first(),
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 55 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 92 KiB After Width: | Height: | Size: 93 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 29 KiB |
@@ -3,12 +3,12 @@
|
||||
"@typescript-eslint/no-non-null-assertion": 384,
|
||||
"@typescript-eslint/no-misused-promises": 849,
|
||||
"no-console": 88,
|
||||
"complexity": 222,
|
||||
"complexity": 221,
|
||||
"@typescript-eslint/no-explicit-any": 146,
|
||||
"trek/no-swallowed-catch": 130,
|
||||
"max-params": 32,
|
||||
"trek/store-without-selector": 67,
|
||||
"react-hooks/refs": 556,
|
||||
"react-hooks/refs": 555,
|
||||
"react-hooks/set-state-in-effect": 194,
|
||||
"@typescript-eslint/no-floating-promises": 165,
|
||||
"react-hooks/exhaustive-deps": 116,
|
||||
|
||||
@@ -72,7 +72,6 @@
|
||||
"components/Map/poiCategories.ts": 7,
|
||||
"components/Map/ratingBadge.ts": 2,
|
||||
"components/Map/reservationsMapbox.ts": 1,
|
||||
"components/Notifications/InAppNotificationItem.tsx": 1,
|
||||
"components/PDF/TripPDF.tsx": 53,
|
||||
"components/Packing/PackingListPanelBagModal.tsx": 2,
|
||||
"components/Packing/PackingListPanelCategoryGroup.tsx": 5,
|
||||
@@ -295,7 +294,6 @@
|
||||
"pages/FilesPage.tsx": 11,
|
||||
"pages/ForgotPasswordPage.tsx": 18,
|
||||
"pages/HelpPage.tsx": 21,
|
||||
"pages/InAppNotificationsPage.tsx": 2,
|
||||
"pages/JoinTripPage.tsx": 4,
|
||||
"pages/JourneyDetailPage.tsx": 53,
|
||||
"pages/JourneyPage.tsx": 64,
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
type AiApiKeyConnectRequest,
|
||||
type AiAuthStartResponse,
|
||||
type AiChatgptFinishRequest,
|
||||
type AiClaudeConnectorResponse,
|
||||
type AiConnectionScope,
|
||||
type AiConnectionView,
|
||||
type AiInstanceConnectionRequest,
|
||||
@@ -233,7 +232,3 @@ export const aiAdminApi = {
|
||||
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),
|
||||
};
|
||||
|
||||
export const claudeConnectorApi = {
|
||||
get: (): Promise<AiClaudeConnectorResponse> => apiClient.get('/integrations/claude-connector').then((r) => r.data),
|
||||
};
|
||||
|
||||
@@ -102,6 +102,13 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
if (block.kind === 'h5') {
|
||||
return (
|
||||
<h5 key={idx} className="mb-1 mt-3 font-semibold text-content first:mt-0" style={fs(12.5, 'body')}>
|
||||
{block.text}
|
||||
</h5>
|
||||
);
|
||||
}
|
||||
if (block.kind === 'h4') {
|
||||
return (
|
||||
<h4 key={idx} className="mb-1 mt-3 font-geist font-bold uppercase tracking-[.08em] text-content-faint first:mt-0" style={fs(10)}>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ActionBarPrimitive, AuiIf, MessagePrimitive } from '@assistant-ui/react';
|
||||
import type { AssistantMessage as StoredMessage } from '@trek/shared';
|
||||
import { Check, Copy, FileText, LoaderCircle, Share2 } from 'lucide-react';
|
||||
import { useMemo } from 'react';
|
||||
import { Fragment, useMemo } from 'react';
|
||||
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { chatOf, useAssistantStore } from '../../store/assistantStore';
|
||||
@@ -16,7 +16,7 @@ import AssistantShareDialog from './AssistantShareDialog';
|
||||
import AssistantSources from './AssistantSources';
|
||||
import AssistantToolSteps from './AssistantToolSteps';
|
||||
import AssistantWeatherStrip from './AssistantWeatherStrip';
|
||||
import { messageBlocks } from './assistantParts';
|
||||
import { type MessageBlock, messageBlocks, tailStart } from './assistantParts';
|
||||
import { useAssistantView } from './assistantViewContext';
|
||||
import AssistantCardBlock from './cards/AssistantCardBlock';
|
||||
import { useAssistantShare } from './useAssistantShare';
|
||||
@@ -108,6 +108,28 @@ function Question({ message }: { message: StoredMessage }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** One block of an answer, in the shape its kind takes. */
|
||||
function AnswerBlock({
|
||||
block,
|
||||
message,
|
||||
writing,
|
||||
streaming,
|
||||
}: {
|
||||
block: MessageBlock;
|
||||
message: StoredMessage;
|
||||
writing: boolean;
|
||||
streaming: boolean;
|
||||
}) {
|
||||
if (block.kind === 'text') return <AssistantMarkdown text={block.text} writing={streaming} />;
|
||||
if (block.kind === 'steps') return <AssistantToolSteps steps={block.steps} />;
|
||||
if (block.kind === 'places') return <AssistantPlaceStrip found={block.found} message={message} />;
|
||||
if (block.kind === 'weather') return <AssistantWeatherStrip days={block.days} />;
|
||||
if (block.kind === 'sources') return <AssistantSources sources={block.sources} />;
|
||||
if (block.kind === 'remembered') return <AssistantRemembered note={block.note} live={writing} />;
|
||||
if (block.kind === 'cards') return <AssistantCardBlock block={block} message={message} />;
|
||||
return <AssistantChangeCard items={block.items} />;
|
||||
}
|
||||
|
||||
function Answer({ message }: { message: StoredMessage }) {
|
||||
const { t } = useTranslation();
|
||||
const { tripId, variant } = useAssistantView();
|
||||
@@ -119,25 +141,16 @@ function Answer({ message }: { message: StoredMessage }) {
|
||||
const share = useAssistantShare(tripId, message, writing);
|
||||
const last = blocks[blocks.length - 1];
|
||||
const hasText = blocks.some((b) => b.kind === 'text');
|
||||
const tail = tailStart(blocks);
|
||||
|
||||
return (
|
||||
<MessagePrimitive.Root className="trek-assistant-enter flex flex-col gap-2.5 py-2">
|
||||
{blocks.map((block) => {
|
||||
if (block.kind === 'text') {
|
||||
return <AssistantMarkdown key={block.key} text={block.text} writing={writing && block === last} />;
|
||||
}
|
||||
if (block.kind === 'steps') return <AssistantToolSteps key={block.key} steps={block.steps} />;
|
||||
if (block.kind === 'places') {
|
||||
return <AssistantPlaceStrip key={block.key} found={block.found} message={message} />;
|
||||
}
|
||||
if (block.kind === 'weather') return <AssistantWeatherStrip key={block.key} days={block.days} />;
|
||||
if (block.kind === 'sources') return <AssistantSources key={block.key} sources={block.sources} />;
|
||||
if (block.kind === 'remembered') {
|
||||
return <AssistantRemembered key={block.key} note={block.note} live={writing} />;
|
||||
}
|
||||
if (block.kind === 'cards') return <AssistantCardBlock key={block.key} block={block} message={message} />;
|
||||
return <AssistantChangeCard key={block.key} items={block.items} />;
|
||||
})}
|
||||
{blocks.map((block, i) => (
|
||||
<Fragment key={block.key}>
|
||||
{i === tail && i > 0 && <span data-assistant-tail aria-hidden="true" className="-mb-2.5 block h-0" />}
|
||||
<AnswerBlock block={block} message={message} writing={writing} streaming={writing && block === last} />
|
||||
</Fragment>
|
||||
))}
|
||||
{/* Nothing below says the answer goes on: no words being written, no step running. */}
|
||||
{writing && last?.kind !== 'text' && last?.kind !== 'steps' && (
|
||||
<p role="status" className="m-0 text-caption">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { AuiIf, ThreadPrimitive } from '@assistant-ui/react';
|
||||
import { ArrowDown, FileUp, MapPin } from 'lucide-react';
|
||||
import { useMemo } from 'react';
|
||||
import { useMemo, useRef } from 'react';
|
||||
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { chatOf, useAssistantStore } from '../../store/assistantStore';
|
||||
@@ -15,13 +15,15 @@ import { useAssistantAttach } from './useAssistantAttach';
|
||||
import { useFollowSelectedDay } from './useAssistantFocus';
|
||||
import { joinDropZones, useAssistantPlaceDrop } from './useAssistantPlaceDrop';
|
||||
import { useAssistantSuggestions } from './useAssistantSuggestions';
|
||||
import { useRevealAnswerTail } from './useRevealAnswerTail';
|
||||
|
||||
/**
|
||||
* The chat itself: the messages, and under them the line that says what went
|
||||
* wrong, the privacy note and the composer, held at the bottom while the
|
||||
* messages scroll. A new question moves up to the top of the view, so its
|
||||
* answer reads from its first line instead of chasing the newest one. A
|
||||
* booking file or a place of the trip can be dropped anywhere on it.
|
||||
* answer reads from its first line instead of chasing the newest one, and once
|
||||
* the answer is written its end comes into view. A booking file or a place of
|
||||
* the trip can be dropped anywhere on it.
|
||||
*/
|
||||
export default function AssistantThread({ tripTitle }: { tripTitle: string }) {
|
||||
const { t } = useTranslation();
|
||||
@@ -31,6 +33,8 @@ export default function AssistantThread({ tripTitle }: { tripTitle: string }) {
|
||||
const byId = useMemo(() => new Map(messages.map((m) => [m.id, m])), [messages]);
|
||||
const { suggestions, canEdit } = useAssistantSuggestions(tripId);
|
||||
useFollowSelectedDay(tripId);
|
||||
const viewport = useRef<HTMLDivElement>(null);
|
||||
useRevealAnswerTail(viewport, isRunning);
|
||||
|
||||
const phone = variant === 'fullscreen';
|
||||
const attach = useAssistantAttach(tripId);
|
||||
@@ -52,7 +56,11 @@ export default function AssistantThread({ tripTitle }: { tripTitle: string }) {
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<ThreadPrimitive.Viewport turnAnchor="top" className="flex min-h-0 flex-1 flex-col overflow-y-auto px-4">
|
||||
<ThreadPrimitive.Viewport
|
||||
ref={viewport}
|
||||
turnAnchor="top"
|
||||
className="flex min-h-0 flex-1 flex-col overflow-y-auto px-4"
|
||||
>
|
||||
<AuiIf condition={(s) => s.thread.isEmpty}>
|
||||
<AssistantWelcome suggestions={suggestions} canEdit={canEdit} withSuggestions={!phone} />
|
||||
{mentionable && (
|
||||
|
||||
@@ -61,7 +61,7 @@ export interface ChangeItem {
|
||||
/** The answer cards of one group, side by side. */
|
||||
export type CardsBlock = { kind: 'cards'; key: string; group: string; entries: CardEntry[] };
|
||||
|
||||
type MessageBlock =
|
||||
export type MessageBlock =
|
||||
| { kind: 'text'; key: string; text: string }
|
||||
| { kind: 'steps'; key: string; steps: ToolStep[] }
|
||||
| { kind: 'change'; key: string; items: ChangeItem[] }
|
||||
@@ -464,3 +464,14 @@ export const CHANGE_LABELS: Record<AssistantChangeLabelKey, { labelKey: string }
|
||||
packing: { labelKey: 'assistant.change.label.packing' },
|
||||
breaks: { labelKey: 'assistant.change.label.breaks' },
|
||||
};
|
||||
|
||||
/**
|
||||
* Where an answer's closing words and changes begin: the block after its last
|
||||
* step, strip or card. That end is what the thread brings into view once the
|
||||
* answer is written.
|
||||
*/
|
||||
export function tailStart(blocks: MessageBlock[]): number {
|
||||
let i = blocks.length;
|
||||
while (i > 0 && (blocks[i - 1].kind === 'text' || blocks[i - 1].kind === 'change')) i -= 1;
|
||||
return i;
|
||||
}
|
||||
|
||||
@@ -37,6 +37,11 @@ const STYLE_LOOK: Readonly<Record<BookDesignStyle, { labelKey: string }>> = {
|
||||
nordic: { labelKey: 'studio.theme.name.nordic' },
|
||||
alpine: { labelKey: 'studio.theme.name.alpine' },
|
||||
'india-stamp': { labelKey: 'studio.theme.name.indiaStamp' },
|
||||
riso: { labelKey: 'studio.theme.name.riso' },
|
||||
magazine: { labelKey: 'studio.theme.name.magazine' },
|
||||
'retro-travel': { labelKey: 'studio.theme.name.retroTravel' },
|
||||
midnight: { labelKey: 'studio.theme.name.midnight' },
|
||||
'field-notes': { labelKey: 'studio.theme.name.fieldNotes' },
|
||||
};
|
||||
|
||||
type Translate = (key: string, params?: Record<string, string | number>) => string;
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* FE-AI-REVEAL-001 to FE-AI-REVEAL-007
|
||||
*
|
||||
* Once an answer is written, its closing words and changes come into view,
|
||||
* unless the user scrolled the thread themselves while it was written; and
|
||||
* where that end begins in an answer's blocks.
|
||||
*/
|
||||
import { useRef } from 'react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { fireEvent, render } from '../../../tests/helpers/render';
|
||||
import { type MessageBlock, tailStart } from './assistantParts';
|
||||
import { tailScrollTop, useRevealAnswerTail } from './useRevealAnswerTail';
|
||||
|
||||
/** A viewport of 400px holding 1200px, its tail mark 900px down, scrolled to `scrollTop`. */
|
||||
function lay(viewport: HTMLElement, { tailAt = 900, scrollTop = 0, height = 1200 } = {}) {
|
||||
Object.defineProperty(viewport, 'clientHeight', { configurable: true, value: 400 });
|
||||
Object.defineProperty(viewport, 'scrollHeight', { configurable: true, value: height });
|
||||
viewport.scrollTop = scrollTop;
|
||||
viewport.getBoundingClientRect = () => ({ top: 100 }) as DOMRect;
|
||||
const tail = viewport.querySelector('[data-assistant-tail]') as HTMLElement | null;
|
||||
if (tail) tail.getBoundingClientRect = () => ({ top: 100 + tailAt - scrollTop }) as DOMRect;
|
||||
}
|
||||
|
||||
function Thread({ running }: { running: boolean }) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useRevealAnswerTail(ref, running);
|
||||
return (
|
||||
<div ref={ref} data-testid="viewport" tabIndex={0}>
|
||||
<span data-assistant-tail />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const block = (kind: MessageBlock['kind']) => ({ kind, key: kind }) as MessageBlock;
|
||||
|
||||
describe('useRevealAnswerTail', () => {
|
||||
let scrollTo: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
scrollTo = vi.fn();
|
||||
HTMLElement.prototype.scrollTo = scrollTo as unknown as HTMLElement['scrollTo'];
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => {
|
||||
cb(0);
|
||||
return 1;
|
||||
});
|
||||
});
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it('FE-AI-REVEAL-001: brings the end of the answer into view once it is written', () => {
|
||||
const view = render(<Thread running />);
|
||||
lay(view.getByTestId('viewport'), { height: 2000 });
|
||||
view.rerender(<Thread running={false} />);
|
||||
expect(scrollTo).toHaveBeenCalledWith(expect.objectContaining({ top: 888 }));
|
||||
});
|
||||
|
||||
it('FE-AI-REVEAL-002: leaves the thread where the user scrolled it while the answer was written', () => {
|
||||
const view = render(<Thread running />);
|
||||
const viewport = view.getByTestId('viewport');
|
||||
lay(viewport);
|
||||
fireEvent.wheel(viewport, { deltaY: 120 });
|
||||
view.rerender(<Thread running={false} />);
|
||||
expect(scrollTo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-AI-REVEAL-003: does nothing on the first render or while nothing runs', () => {
|
||||
const view = render(<Thread running={false} />);
|
||||
lay(view.getByTestId('viewport'));
|
||||
view.rerender(<Thread running={false} />);
|
||||
expect(scrollTo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-AI-REVEAL-004: a new answer reveals its end again after an earlier one was scrolled by hand', () => {
|
||||
const view = render(<Thread running />);
|
||||
const viewport = view.getByTestId('viewport');
|
||||
lay(viewport);
|
||||
fireEvent.wheel(viewport, { deltaY: 120 });
|
||||
view.rerender(<Thread running={false} />);
|
||||
view.rerender(<Thread running />);
|
||||
view.rerender(<Thread running={false} />);
|
||||
expect(scrollTo).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tailScrollTop', () => {
|
||||
it('FE-AI-REVEAL-005: stops at the bottom when the end is shorter than the view, and never scrolls up', () => {
|
||||
const viewport = document.createElement('div');
|
||||
viewport.innerHTML = '<span data-assistant-tail></span>';
|
||||
lay(viewport, { tailAt: 1100 });
|
||||
expect(tailScrollTop(viewport)).toBe(800);
|
||||
lay(viewport, { tailAt: 300, scrollTop: 500 });
|
||||
expect(tailScrollTop(viewport)).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-AI-REVEAL-006: without a mark there is nothing to reveal', () => {
|
||||
const viewport = document.createElement('div');
|
||||
lay(viewport);
|
||||
expect(tailScrollTop(viewport)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('tailStart', () => {
|
||||
it('FE-AI-REVEAL-007: the end begins after the last step, strip or card', () => {
|
||||
expect(tailStart([block('steps'), block('places'), block('text'), block('change')])).toBe(2);
|
||||
expect(tailStart([block('text'), block('places')])).toBe(2);
|
||||
expect(tailStart([block('text'), block('change')])).toBe(0);
|
||||
expect(tailStart([])).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { type RefObject, useEffect, useRef } from 'react';
|
||||
|
||||
import { prefersReducedMotion } from '../../utils/reducedMotion';
|
||||
|
||||
/** Room left above the tail, so its first line does not sit flush on the edge. */
|
||||
const TAIL_GAP = 12;
|
||||
/** What reading the thread by hand looks like while the answer is written. */
|
||||
const OWN_SCROLL = ['wheel', 'touchmove', 'keydown'] as const;
|
||||
|
||||
/**
|
||||
* Where to scroll so the end of the latest answer is in view: the start of its
|
||||
* closing text and proposed changes (the `data-assistant-tail` mark), or the
|
||||
* very bottom when that tail is shorter than the view. Null when it is in view
|
||||
* already, so the thread never moves up.
|
||||
*/
|
||||
export function tailScrollTop(viewport: HTMLElement): number | null {
|
||||
const marks = viewport.querySelectorAll('[data-assistant-tail]');
|
||||
const tail = marks[marks.length - 1];
|
||||
if (!tail) return null;
|
||||
const offset = tail.getBoundingClientRect().top - viewport.getBoundingClientRect().top + viewport.scrollTop;
|
||||
const target = Math.min(offset - TAIL_GAP, viewport.scrollHeight - viewport.clientHeight);
|
||||
return target > viewport.scrollTop + 1 ? target : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A new question moves to the top of the view, so its answer reads from the
|
||||
* first line. A long answer (steps, a strip of places found, cards) then ends
|
||||
* below the view, and its closing words and the changes to apply were never
|
||||
* seen. Once the answer is written, this brings that end into view, unless the
|
||||
* user scrolled the thread themselves while it was written.
|
||||
*/
|
||||
export function useRevealAnswerTail(viewport: RefObject<HTMLElement | null>, isRunning: boolean): void {
|
||||
const scrolledByHand = useRef(false);
|
||||
const wasRunning = useRef(isRunning);
|
||||
|
||||
useEffect(() => {
|
||||
const el = viewport.current;
|
||||
if (!el || !isRunning) return;
|
||||
scrolledByHand.current = false;
|
||||
const mark = () => {
|
||||
scrolledByHand.current = true;
|
||||
};
|
||||
for (const type of OWN_SCROLL) el.addEventListener(type, mark, { passive: true });
|
||||
return () => {
|
||||
for (const type of OWN_SCROLL) el.removeEventListener(type, mark);
|
||||
};
|
||||
}, [viewport, isRunning]);
|
||||
|
||||
useEffect(() => {
|
||||
const ended = wasRunning.current && !isRunning;
|
||||
wasRunning.current = isRunning;
|
||||
const el = viewport.current;
|
||||
if (!ended || !el || scrolledByHand.current) return;
|
||||
// A frame later, so the answer's last blocks have their height.
|
||||
const frame = requestAnimationFrame(() => {
|
||||
const top = tailScrollTop(el);
|
||||
if (top !== null) el.scrollTo({ top, behavior: prefersReducedMotion() ? 'auto' : 'smooth' });
|
||||
});
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [viewport, isRunning]);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
Book, Palette, Map, Bell, Plug, CloudOff, User, Lock, ShieldCheck, Fingerprint, KeyRound,
|
||||
Route, Users, UserRound, Crown, PanelLeftClose, Undo2, Ticket, Train, Plane, ListChecks, Wallet, FolderOpen, MessagesSquare,
|
||||
ArrowUpDown, Backpack, BatteryCharging, BedDouble, BellRing, Bike, Blocks, CableCar, Car, CloudDownload, CopyPlus, FileDown, FilePen, FileSpreadsheet, FolderInput, Footprints, History, Info, Layers, MailCheck, MapPinned, Moon, Mountain, MountainSnow, MoveRight, NotebookPen, PackagePlus, Pin, Receipt, Rotate3d, RouteOff, Shuffle, Spline, TriangleAlert, Waves, Wind,
|
||||
PaintBucket, Sticker, Type,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react'
|
||||
import type { HelpIcon } from '../../help/types'
|
||||
@@ -13,6 +14,9 @@ import type { HelpIcon } from '../../help/types'
|
||||
/** The registry names icons; the components draw them. Unknown names fall back to the compass. */
|
||||
const ICONS: Record<HelpIcon, LucideIcon> = {
|
||||
plus: Plus,
|
||||
sticker: Sticker,
|
||||
type: Type,
|
||||
paintBucket: PaintBucket,
|
||||
pencil: Pencil,
|
||||
image: Image,
|
||||
copy: Copy,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-JRN-SHARE-001 to FE-JRN-SHARE-014
|
||||
// FE-JRN-SHARE-001 to FE-JRN-SHARE-015
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import { http, HttpResponse, delay } from 'msw'
|
||||
@@ -245,4 +245,15 @@ describe('JourneyShareSection', () => {
|
||||
expect(plan).toHaveAttribute('aria-pressed', 'false')
|
||||
expect(screen.getByRole('button', { name: /Recorded routes/ })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('FE-JRN-SHARE-015: a filled accent button sets its words in the colour made for it, not in the accent itself', async () => {
|
||||
server.use(http.get('/api/journeys/7/share-link', () => HttpResponse.json({ link: existingLink() })))
|
||||
render(<JourneyShareSection journeyId={JOURNEY_ID} />)
|
||||
|
||||
const filled = [await screen.findByRole('button', { name: 'Timeline' }), screen.getByRole('button', { name: 'Copy' })]
|
||||
for (const button of filled) {
|
||||
expect(button.className).toContain('text-accent-text')
|
||||
expect(button.className).not.toContain('text-accent-on')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -69,7 +69,7 @@ export default function JourneyShareSection({ journeyId }: { journeyId: number }
|
||||
<span className="flex-1 text-caption text-content-muted truncate">{shareUrl}</span>
|
||||
<button type="button"
|
||||
onClick={copyLink}
|
||||
className="flex-shrink-0 px-3 py-1.5 rounded-full bg-accent text-accent-on text-caption font-semibold hover:bg-accent-hover transition-colors"
|
||||
className="flex-shrink-0 px-3 py-1.5 rounded-full bg-accent text-accent-text text-caption font-semibold hover:bg-accent-hover transition-colors"
|
||||
>
|
||||
{copied ? t('journey.share.copied') : t('journey.share.copy')}
|
||||
</button>
|
||||
@@ -94,7 +94,7 @@ export default function JourneyShareSection({ journeyId }: { journeyId: number }
|
||||
onClick={() => togglePerm(key)}
|
||||
className={`flex items-center gap-2.5 px-3 py-2 rounded-xl border text-body font-medium transition-all ${
|
||||
link[key]
|
||||
? 'border-accent bg-accent text-accent-on'
|
||||
? 'border-accent bg-accent text-accent-text'
|
||||
: 'border-edge text-content-muted hover:border-content-faint'
|
||||
}`}
|
||||
>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Bell, Trash2, CheckCheck } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useInAppNotificationStore } from '../../store/inAppNotificationStore.ts'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import InAppNotificationItem from '../Notifications/InAppNotificationItem.tsx'
|
||||
import NotificationDayList from '../Notifications/NotificationDayList'
|
||||
import ConfirmDialog from '../shared/ConfirmDialog'
|
||||
|
||||
export default function InAppNotificationBell(): React.ReactElement {
|
||||
@@ -73,9 +73,9 @@ export default function InAppNotificationBell(): React.ReactElement {
|
||||
position: 'fixed',
|
||||
top: 'var(--nav-h)',
|
||||
insetInlineEnd: 8,
|
||||
width: 380,
|
||||
width: 400,
|
||||
maxWidth: 'calc(100vw - 16px)',
|
||||
maxHeight: 'min(480px, calc(100vh - var(--nav-h) - 16px))',
|
||||
maxHeight: 'min(560px, calc(100vh - var(--nav-h) - 16px))',
|
||||
zIndex: 9999,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
@@ -119,7 +119,7 @@ export default function InAppNotificationBell(): React.ReactElement {
|
||||
</div>
|
||||
|
||||
{/* 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 ? (
|
||||
<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" />
|
||||
@@ -133,9 +133,7 @@ export default function InAppNotificationBell(): React.ReactElement {
|
||||
<p className="text-caption text-content-muted">{t('notifications.emptyDescription')}</p>
|
||||
</div>
|
||||
) : (
|
||||
notifications.slice(0, 10).map(n => (
|
||||
<InAppNotificationItem key={n.id} notification={n} onClose={() => setOpen(false)} />
|
||||
))
|
||||
<NotificationDayList notifications={notifications.slice(0, 10)} onClose={() => setOpen(false)} compact />
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,29 +1,53 @@
|
||||
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 type { InAppNotification } from '../../store/inAppNotificationStore'
|
||||
import { compactTime, useNotificationItemActions } from './useNotificationItemActions'
|
||||
|
||||
function relativeTime(dateStr: string, locale: string): string {
|
||||
return compactTime(dateStr, locale === 'ar' ? 'الآن' : 'just now')
|
||||
}
|
||||
import { KIND_LOOK, createdAt, kindOf } from './notificationModel'
|
||||
|
||||
interface NotificationItemProps {
|
||||
notification: InAppNotification
|
||||
onClose?: () => void
|
||||
}
|
||||
|
||||
/** The round tool beside a notification, quiet until 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'
|
||||
/** 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-secondary focus-visible:bg-surface-secondary focus-visible:text-content'
|
||||
|
||||
/** An answer or a way on, as a pill under the words. */
|
||||
const PILL = 'inline-flex items-center gap-1.5 h-7 px-3 rounded-full text-caption font-medium transition-colors'
|
||||
/** An answer or a way on, as a button under the words. */
|
||||
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
|
||||
* can be done with it. Unread ones carry a dot in the accent and a faint
|
||||
* ground; the tools to mark and delete come forward when the card is pointed
|
||||
* at or focused, so a list reads as messages rather than as rows of buttons.
|
||||
* One notification: a tile that says at a glance what it is about and who sent
|
||||
* it, the message itself, and what can be done with it. Unread ones are set in
|
||||
* bold with a dot in the accent; marking and deleting come forward when the row
|
||||
* is pointed at or focused, so a list reads as messages rather than as buttons.
|
||||
*/
|
||||
export default function InAppNotificationItem({ notification, onClose }: NotificationItemProps): React.ReactElement {
|
||||
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 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 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 (
|
||||
<div
|
||||
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' : ''}`}
|
||||
>
|
||||
{unread && <span aria-hidden className="absolute start-1 top-5 w-1.5 h-1.5 rounded-full bg-accent" />}
|
||||
<div className="group relative flex gap-3.5 px-4 py-3.5 transition-colors hover:bg-surface-hover focus-within:bg-surface-hover">
|
||||
<KindTile notification={notification} />
|
||||
|
||||
<div className="flex gap-3 items-start">
|
||||
{/* Sender avatar */}
|
||||
<div className="flex-shrink-0">
|
||||
{notification.sender_avatar ? (
|
||||
<img src={notification.sender_avatar} alt="" className="w-9 h-9 rounded-xl object-cover" />
|
||||
) : (
|
||||
<div className="w-9 h-9 rounded-xl flex items-center justify-center text-body font-semibold text-accent bg-accent-subtle">
|
||||
{notification.sender_username
|
||||
? notification.sender_username.charAt(0).toUpperCase()
|
||||
: <User className="w-4 h-4" />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 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" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<p className={`text-body leading-snug text-content ${unread ? 'font-semibold' : 'font-medium'}`}>{titleText}</p>
|
||||
<div className="relative flex-shrink-0 h-6 flex items-center gap-2">
|
||||
<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'))}
|
||||
</span>
|
||||
{unread && (
|
||||
<span aria-hidden className="w-2 h-2 rounded-full bg-accent transition-opacity group-hover:opacity-0 group-focus-within:opacity-0" />
|
||||
)}
|
||||
<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 ? (
|
||||
<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>
|
||||
</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>
|
||||
|
||||
<p className="text-caption mt-0.5 leading-relaxed text-content-muted">
|
||||
{hasUnknownBody ? notification.text_key : bodyText}
|
||||
</p>
|
||||
<p className="text-body mt-0.5 leading-relaxed text-content-muted">{bodyText}</p>
|
||||
|
||||
{/* Boolean actions */}
|
||||
{notification.type === 'boolean' && notification.positive_text_key && notification.negative_text_key && (
|
||||
<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 && (
|
||||
{asks && (
|
||||
<div className="flex flex-wrap gap-2 mt-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleNavigate}
|
||||
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`}
|
||||
onClick={() => handleRespond('positive')}
|
||||
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)}
|
||||
<ArrowRight className="w-3.5 h-3.5 rtl:-scale-x-100" />
|
||||
<Check className="w-3.5 h-3.5" />
|
||||
{t(yesKey)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
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>
|
||||
)
|
||||
|
||||
@@ -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 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[]) {
|
||||
seedStore(useAddonStore, {
|
||||
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/oauth/clients', () => HttpResponse.json({ clients: [] })),
|
||||
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 />);
|
||||
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 LlmConnectionSection from './LlmConnectionSection'
|
||||
import AiConnectionSection from './ai/AiConnectionSection'
|
||||
import ClaudeConnectorCard from './ai/ClaudeConnectorCard'
|
||||
import ApiKeysSection from './ApiKeysSection'
|
||||
import ScopeGroupPicker from '../OAuth/ScopeGroupPicker'
|
||||
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. */}
|
||||
<ApiKeysSection />
|
||||
{/* TREK inside Claude rides on the MCP server, so it follows that addon. */}
|
||||
{S.mcpEnabled && <ClaudeConnectorCard />}
|
||||
{S.mcpEnabled && <IntegrationsMcpSection S={S} />}
|
||||
<McpTokenModals S={S} />
|
||||
<OAuthClientModals S={S} />
|
||||
|
||||
@@ -3,8 +3,7 @@ import userEvent from '@testing-library/user-event';
|
||||
import type { AiStatusResponse } from '@trek/shared';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, render, screen, waitFor } from '../../../../tests/helpers/render';
|
||||
import { resetAllStores, seedStore } from '../../../../tests/helpers/store';
|
||||
import { useAddonStore } from '../../../store/addonStore';
|
||||
import { resetAllStores } from '../../../../tests/helpers/store';
|
||||
import { useAssistantStore } from '../../../store/assistantStore';
|
||||
|
||||
const repo = vi.hoisted(() => ({
|
||||
@@ -173,17 +172,11 @@ describe('AiConnectPanel', () => {
|
||||
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 { unmount } = render(<AiConnectPanel scope="user" onConnected={vi.fn()} />);
|
||||
expect(screen.queryByText('Using Claude? Connect in Claude')).not.toBeInTheDocument();
|
||||
render(<AiConnectPanel scope="user" onConnected={vi.fn()} />);
|
||||
await user.click(screen.getByRole('button', { name: /What gets shared\?/ }));
|
||||
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 () => {
|
||||
|
||||
@@ -2,14 +2,13 @@ import type { AiApiKeyKind, AiConnectionScope, AiParsingPrefill } from '@trek/sh
|
||||
import { ChevronRight, KeyRound, Loader2, Server, ShieldCheck } from 'lucide-react';
|
||||
import React, { useId, useState } from 'react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { useAddonStore } from '../../../store/addonStore';
|
||||
import { useAssistantStore } from '../../../store/assistantStore';
|
||||
import { AI_PROVIDERS, API_KEY_KINDS, providerLabel } from '../../Assistant/providerCatalog';
|
||||
import { fs } from '../../shared/DialogShell';
|
||||
import { EditorField, GRID_2, INPUT } from '../../shared/dialogParts';
|
||||
import { SETTINGS_BUTTON, SETTINGS_BUTTON_PRIMARY, SettingsHint, StatusPill } from '../settingsKit';
|
||||
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';
|
||||
|
||||
interface AiConnectPanelProps {
|
||||
@@ -399,7 +398,6 @@ export default function AiConnectPanel({ scope, onConnected, initialMode, prefil
|
||||
const chatgpt = useChatgptConnect({ active: mode === 'chatgpt', onConnected, scope });
|
||||
const keyConnect = useKeyConnect({ scope, 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.
|
||||
const receivers: Record<AiConnectMode, string> = {
|
||||
@@ -450,18 +448,6 @@ export default function AiConnectPanel({ scope, onConnected, initialMode, prefil
|
||||
{mode === 'custom' && <CustomForm custom={custom} />}
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
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. */
|
||||
export function refusalCode(err: unknown): string | null {
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -90,7 +90,7 @@ describe('the Templates panel', () => {
|
||||
it('narrows the set to one archetype from the style chips', () => {
|
||||
panel();
|
||||
const chips = screen.getByRole('radiogroup', { name: 'studio.templates.style' });
|
||||
expect(chips.querySelectorAll('[role="radio"]')).toHaveLength(8);
|
||||
expect(chips.querySelectorAll('[role="radio"]')).toHaveLength(13);
|
||||
fireEvent.click(screen.getByRole('radio', { name: 'studio.templates.archetype.postcard' }));
|
||||
expect(
|
||||
screen.getByRole('radio', { name: 'studio.templates.archetype.postcard' }).getAttribute('aria-checked')
|
||||
|
||||
@@ -22,7 +22,7 @@ import { ThemeSheet } from './ThemeSheet';
|
||||
import { useStudioTheme } from './useStudioTheme';
|
||||
|
||||
/**
|
||||
* The Theme section of the rail: twelve built-in looks, the palette, the
|
||||
* The Theme section of the rail: seventeen built-in looks, the palette, the
|
||||
* typefaces and the motifs of the one being put together, and applying it to
|
||||
* the book or to the page that is open.
|
||||
*
|
||||
|
||||
@@ -1,100 +1,56 @@
|
||||
import type {
|
||||
BookBadgeStyle,
|
||||
BookBadgeVariant,
|
||||
BookColorToken,
|
||||
BookCountryLook,
|
||||
BookElement,
|
||||
BookMapElement,
|
||||
BookMapScope,
|
||||
BookMetric,
|
||||
BookPageSetup,
|
||||
BookStatsLook,
|
||||
BookTheme,
|
||||
BookTravelMapCard,
|
||||
BookTravelMarkVariant,
|
||||
JourneyStats,
|
||||
} from '@trek/shared';
|
||||
import {
|
||||
bookBadgeValue,
|
||||
bookCountryName,
|
||||
bookTravelBadge,
|
||||
bookTravelCountries,
|
||||
bookTravelMap,
|
||||
bookTravelSize,
|
||||
bookTravelStats,
|
||||
themedElement,
|
||||
BOOK_TRAVEL_COUNTRY_LOOKS,
|
||||
BOOK_TRAVEL_FIGURE_SHELVES,
|
||||
BOOK_TRAVEL_MARK_STYLES,
|
||||
BOOK_TRAVEL_MARK_VARIANTS,
|
||||
BOOK_TRAVEL_SUMMARY_LOOKS,
|
||||
bookTravelLooks,
|
||||
bookElementId as uid,
|
||||
} from '@trek/shared';
|
||||
import { studioTravelContext } from '../studioTravelContext';
|
||||
|
||||
/**
|
||||
* What the Travel panel offers, built: every card is the element its click
|
||||
* places, made by the book engine's own factories (book-travel.ts) so a card
|
||||
* and what the assistant places are one thing, then set in one of the newer
|
||||
* looks and dressed in the book's theme.
|
||||
* places, made by the book's own travel looks (book-travel-looks.ts in
|
||||
* shared), the same factory the auto layout builds its framing pages with,
|
||||
* so a card and a designed page are one thing.
|
||||
*
|
||||
* Pure, so the panel stays a list of shelves: it asks for an element and
|
||||
* renders it through the page's own renderer, and the preview and the result
|
||||
* are the same object.
|
||||
* are the same object. What is the panel's own here is where a new element
|
||||
* lands: centred on the spread on screen.
|
||||
*/
|
||||
|
||||
/** The figures a summary offers: how far, how long, where, and how much was kept. */
|
||||
const SUMMARY_METRICS: BookMetric[] = ['distance', 'days', 'countries', 'photos'];
|
||||
|
||||
/** The looks of a summary, in the order the panel offers them. */
|
||||
export const SUMMARY_LOOKS: BookStatsLook[] = [
|
||||
'poster',
|
||||
'ticket',
|
||||
'tiles',
|
||||
'sign',
|
||||
'odometer',
|
||||
'note',
|
||||
'sketch',
|
||||
'band',
|
||||
];
|
||||
export const SUMMARY_LOOKS: readonly BookStatsLook[] = BOOK_TRAVEL_SUMMARY_LOOKS;
|
||||
|
||||
/** The marks the journey fills, in the order a page is read: when, which day, how far, where. */
|
||||
export const MARK_VARIANTS = ['date', 'day', 'distance', 'coords', 'flag'] as const;
|
||||
export type MarkVariant = (typeof MARK_VARIANTS)[number];
|
||||
export const MARK_VARIANTS = BOOK_TRAVEL_MARK_VARIANTS;
|
||||
export type MarkVariant = BookTravelMarkVariant;
|
||||
|
||||
/** The newer styles a mark comes in, and which of them each mark is offered in. */
|
||||
export const MARK_STYLES: { style: BookBadgeStyle; variants: readonly MarkVariant[] }[] = [
|
||||
{ style: 'stamp', variants: ['date', 'day', 'flag'] },
|
||||
{ style: 'tag', variants: ['day', 'distance', 'coords', 'flag'] },
|
||||
{ style: 'pill', variants: ['date', 'day', 'distance', 'coords', 'flag'] },
|
||||
{ style: 'ribbon', variants: ['date', 'day', 'distance', 'flag'] },
|
||||
{ style: 'sticker', variants: ['date', 'day', 'distance', 'flag'] },
|
||||
{ style: 'patch', variants: ['day', 'distance', 'flag'] },
|
||||
{ style: 'sketch', variants: ['date', 'day', 'distance', 'coords', 'flag'] },
|
||||
];
|
||||
export const MARK_STYLES: readonly { style: BookBadgeStyle; variants: readonly MarkVariant[] }[] =
|
||||
BOOK_TRAVEL_MARK_STYLES;
|
||||
|
||||
/** The single figures, one shelf per look, with the figures each look suits. */
|
||||
export const FIGURE_SHELVES: { look: BookStatsLook; metrics: readonly BookMetric[] }[] = [
|
||||
{ look: 'medal', metrics: ['distance', 'days', 'steps', 'photos', 'countries', 'places', 'furthest'] },
|
||||
{ look: 'odometer', metrics: ['distance', 'furthest', 'days', 'photos'] },
|
||||
{ look: 'sketch', metrics: ['distance', 'days', 'countries', 'photos'] },
|
||||
];
|
||||
export const FIGURE_SHELVES: readonly { look: BookStatsLook; metrics: readonly BookMetric[] }[] =
|
||||
BOOK_TRAVEL_FIGURE_SHELVES;
|
||||
|
||||
/** The looks a country is offered in. */
|
||||
export const COUNTRY_LOOKS: BookCountryLook[] = ['stamp', 'visa', 'postcard', 'flag', 'pin', 'label', 'sketch'];
|
||||
|
||||
/** The looks written by hand take the theme's script face rather than its heading. */
|
||||
const HANDWRITTEN = new Set<string>(['note', 'sketch']);
|
||||
|
||||
/** Which of the theme's accents each country look takes, so a shelf of them is not one colour. */
|
||||
const COUNTRY_ACCENT: Partial<Record<BookCountryLook, BookColorToken>> = {
|
||||
stamp: 'accent2',
|
||||
visa: 'accent3',
|
||||
pin: 'accent',
|
||||
label: 'accent2',
|
||||
};
|
||||
|
||||
/** Which of the theme's accents each mark takes, so a page of them is not one colour. */
|
||||
const MARK_ACCENT: Record<MarkVariant, BookColorToken> = {
|
||||
date: 'accent',
|
||||
day: 'accent2',
|
||||
distance: 'accent3',
|
||||
coords: 'accent',
|
||||
flag: 'accent2',
|
||||
};
|
||||
export const COUNTRY_LOOKS: readonly BookCountryLook[] = BOOK_TRAVEL_COUNTRY_LOOKS;
|
||||
|
||||
/** The map cards: the photographed route, its stops numbered, its two ends marked, and the dark ground. */
|
||||
export const MAP_CARDS: {
|
||||
@@ -130,130 +86,19 @@ export function travelBuilders({ page, stats, path, locale, t, theme, single }:
|
||||
return { x: (W - size.w) / 2, y: (page.pageHeight - size.h) / 2, w: size.w, h: size.h };
|
||||
};
|
||||
|
||||
/**
|
||||
* An element dressed in the book's theme: its words in the theme's ink and
|
||||
* heading face, its colour one of the theme's accents, all three linked so
|
||||
* it follows when the theme changes. A book without a theme keeps the
|
||||
* engine's own colours.
|
||||
*/
|
||||
const dress = (el: BookElement, accent: BookColorToken = 'accent', hand = false): BookElement =>
|
||||
theme
|
||||
? themedElement(
|
||||
{ ...el, token: 'ink', accentToken: accent, fontRole: hand ? 'script' : 'heading' } as BookElement,
|
||||
theme
|
||||
)
|
||||
: el;
|
||||
const looks = bookTravelLooks({ page, stats, travel, theme, newId: uid, place: at });
|
||||
|
||||
/** A summary in one of the newer looks; a band is a strip, so it is set a third as tall. */
|
||||
const summaryEl = (look: BookStatsLook): BookElement => {
|
||||
const w = page.pageWidth * (look === 'note' || look === 'sign' ? 0.56 : 0.72);
|
||||
const h =
|
||||
look === 'band' ? page.pageHeight * 0.13 : look === 'ticket' ? w * 0.42 : look === 'note' ? w * 0.72 : w * 0.5;
|
||||
const el = { ...bookTravelStats(stats, { id: uid('st'), frame: at({ w, h }), metrics: SUMMARY_METRICS }), look };
|
||||
return dress(el, look === 'sign' ? 'accent3' : 'accent', HANDWRITTEN.has(look));
|
||||
return {
|
||||
summaryEl: (look: BookStatsLook) => looks.summaryEl(look),
|
||||
figureEl: (metric: BookMetric, look: BookStatsLook = 'medal') => looks.figureEl(metric, look),
|
||||
mapEl: (
|
||||
card: BookTravelMapCard,
|
||||
scope: BookMapScope = 'journey',
|
||||
source: 'vector' | 'relief' | 'satellite' = 'vector'
|
||||
) => looks.mapEl(card, scope, source),
|
||||
countryMapEl: (code: string, kind: 'shape' | 'satellite' | 'box') => looks.countryMapEl(code, kind),
|
||||
markEl: (variant: MarkVariant, style: BookBadgeStyle) => looks.markEl(variant, style),
|
||||
countryEl: (code: string, look: BookCountryLook) => looks.countryEl(code, look),
|
||||
countryName: looks.countryName,
|
||||
};
|
||||
|
||||
/**
|
||||
* One figure on its own, the mark to drop beside a photograph: as a medal, on
|
||||
* the drums of an odometer, or ringed by hand.
|
||||
*/
|
||||
const figureEl = (metric: BookMetric, look: BookStatsLook = 'medal'): BookElement => {
|
||||
const side = Math.min(page.pageWidth, page.pageHeight) * 0.3;
|
||||
const size = look === 'odometer' ? { w: side * 1.6, h: side * 0.62 } : { w: side, h: side };
|
||||
const el = { ...bookTravelStats(stats, { id: uid('st'), frame: at(size), metrics: [metric] }), look };
|
||||
return dress(el, 'accent', HANDWRITTEN.has(look));
|
||||
};
|
||||
|
||||
/**
|
||||
* A map as the engine places it, on the card's ground and with the card's
|
||||
* stops. The scope is the journey or one trip; the ground is the outlines
|
||||
* or one of the two imagery sources.
|
||||
*/
|
||||
const mapEl = (
|
||||
card: { style: BookMapElement['style']; pins?: BookMapElement['pinStyle'] },
|
||||
scope: BookMapScope = 'journey',
|
||||
source: 'vector' | 'relief' | 'satellite' = 'vector'
|
||||
): BookElement => {
|
||||
const el = bookTravelMap(travel, stats, { id: uid('mp'), frame: at(bookTravelSize('map', page)), scope, source });
|
||||
const shaped = { ...el, style: card.style, ...(card.pins ? { pinStyle: card.pins } : {}) };
|
||||
// Over imagery the line is white on purpose (book-travel.ts); only the outline maps take the theme.
|
||||
return source === 'vector' ? dress(shaped) : shaped;
|
||||
};
|
||||
|
||||
/**
|
||||
* A map of one country: its stops alone, without the way there. A journey's
|
||||
* recorded way often starts with the flight from home, and fitted to all of
|
||||
* it the country is a speck at the edge of a continent; the page a travel
|
||||
* book wants is the country, cut to its own shape on the paper or over the
|
||||
* satellite picture, or the country in a box.
|
||||
*/
|
||||
const countryMapEl = (code: string, kind: 'shape' | 'satellite' | 'box'): BookElement => {
|
||||
const own = {
|
||||
...stats,
|
||||
points: stats.points.filter((p) => p.country === code),
|
||||
countries: stats.countries.filter((c) => c.code === code),
|
||||
};
|
||||
const ctx = studioTravelContext(own, [], locale, t);
|
||||
const source = kind === 'satellite' ? 'satellite' : 'vector';
|
||||
const el = bookTravelMap(ctx, own, {
|
||||
id: uid('mp'),
|
||||
frame: at(bookTravelSize('map', page)),
|
||||
scope: 'journey',
|
||||
source,
|
||||
});
|
||||
// Cut to its shape on the paper, the land is dark, so the silhouette reads and the route in the accent stands on it.
|
||||
const style = kind === 'shape' ? 'dark' : 'minimal';
|
||||
const shaped = {
|
||||
...el,
|
||||
style,
|
||||
clip: kind === 'box' ? 'rect' : 'country',
|
||||
pinStyle: 'numbered',
|
||||
fitToCountries: true,
|
||||
} as const;
|
||||
return source === 'vector' ? dress(shaped) : shaped;
|
||||
};
|
||||
|
||||
/** A mark in one of the newer styles, at the size its variant suits; a stamp is set taller for its two rules. */
|
||||
const markEl = (variant: MarkVariant, style: BookBadgeStyle): BookElement | null => {
|
||||
const value = bookBadgeValue(travel, variant, null);
|
||||
if (!value) return null;
|
||||
const frame = at(markSize(style, value.text, value.sub));
|
||||
const el = { ...bookTravelBadge({ id: uid('bd'), frame, variant: variant as BookBadgeVariant, value }), style };
|
||||
return dress(el, MARK_ACCENT[variant], style === 'sketch');
|
||||
};
|
||||
|
||||
/**
|
||||
* How large a mark is set: as wide as its words need. A stamp stacks them in
|
||||
* a box with room for its two rules; a tag or a pill sets them in a line
|
||||
* after its eyelet or its disc, so its length follows the words.
|
||||
*/
|
||||
const markSize = (style: BookBadgeStyle, text: string, sub: string) => {
|
||||
const twoLines = !!text && !!sub && text.length > 14;
|
||||
const h = page.pageHeight * (style === 'stamp' ? 0.12 : twoLines ? 0.1 : 0.066);
|
||||
const main = text || sub;
|
||||
if (style === 'stamp') return { w: Math.max(h * 1.3, h * 0.2 * Math.max(main.length * 1.4, sub.length)), h };
|
||||
// Two lines are set smaller than one, so they need less width a letter.
|
||||
if (twoLines)
|
||||
return { w: Math.min(page.pageWidth * 0.6, h * (1.5 + 0.13 * Math.max(text.length, sub.length * 0.7))), h };
|
||||
const chars = main.length * (text ? 1 : 0.85) + (text ? sub.length * 0.5 : 0);
|
||||
return { w: Math.min(page.pageWidth * 0.6, h * (1.6 + 0.3 * chars)), h };
|
||||
};
|
||||
|
||||
/** One country as a square stamp, or as a card in the proportions of a postcard. */
|
||||
const countryEl = (code: string, look: BookCountryLook): BookElement => {
|
||||
const side = Math.min(page.pageWidth, page.pageHeight) * 0.36;
|
||||
const ratio = look === 'stamp' || look === 'pin' || look === 'sketch' ? 1 : look === 'postcard' ? 1.36 : 1.4;
|
||||
const frame = at({ w: side * ratio, h: side });
|
||||
const el = {
|
||||
...bookTravelCountries(travel, { id: uid('co'), frame, codes: [code] }),
|
||||
look,
|
||||
showFlag: look === 'pin' || look === 'postcard',
|
||||
};
|
||||
return dress(el, COUNTRY_ACCENT[look] ?? 'accent', look === 'sketch');
|
||||
};
|
||||
|
||||
/** A country's name in the reader's language, from the same CLDR data Atlas uses. */
|
||||
const countryName = (code: string): string => bookCountryName(code, locale, stats);
|
||||
|
||||
return { summaryEl, figureEl, mapEl, countryMapEl, markEl, countryEl, countryName };
|
||||
}
|
||||
|
||||
@@ -65,16 +65,22 @@ export const { context: studioContext, guides: studioGuides } = defineScreen({
|
||||
id: 'journey-studio',
|
||||
parent: 'journey',
|
||||
icon: 'book',
|
||||
bullets: 6,
|
||||
bullets: 8,
|
||||
docs: [STUDIO],
|
||||
guides: [
|
||||
['studio-auto-layout', 'sparkles', 'guide', 3, 2, STUDIO, ['studio-pages', 'studio-layouts'], true],
|
||||
['studio-pages', 'book', 'guide', 3, 2, STUDIO, ['studio-layouts', 'studio-spread-file'], true],
|
||||
['studio-layouts', 'layout', 'quick', 2, 1, STUDIO, ['studio-auto-layout', 'studio-content'], true],
|
||||
['studio-layouts', 'layout', 'quick', 2, 1, STUDIO, ['studio-my-layouts', 'studio-auto-layout', 'studio-content'], true],
|
||||
['studio-my-layouts', 'bookmark', 'quick', 2, 1, STUDIO, ['studio-layouts', 'studio-spread-file'], true],
|
||||
['studio-content', 'image', 'guide', 3, 2, STUDIO, ['studio-properties', 'studio-elements'], true],
|
||||
['studio-elements', 'plus', 'quick', 2, 1, STUDIO, ['studio-travel', 'studio-properties']],
|
||||
['studio-travel', 'globe', 'quick', 2, 1, STUDIO, ['studio-elements']],
|
||||
['studio-properties', 'panelRight', 'guide', 3, 2, STUDIO, ['studio-content', 'studio-layouts']],
|
||||
['studio-theme', 'palette', 'guide', 3, 2, STUDIO, ['studio-background', 'studio-text-effects'], true],
|
||||
['studio-elements', 'plus', 'quick', 2, 1, STUDIO, ['studio-stickers', 'studio-travel', 'studio-properties']],
|
||||
['studio-stickers', 'sticker', 'quick', 2, 1, STUDIO, ['studio-elements', 'studio-layers'], true],
|
||||
['studio-travel', 'globe', 'guide', 3, 2, STUDIO, ['studio-elements', 'studio-theme'], true],
|
||||
['studio-text-effects', 'type', 'quick', 2, 1, STUDIO, ['studio-theme', 'studio-properties'], true],
|
||||
['studio-background', 'paintBucket', 'quick', 2, 1, STUDIO, ['studio-theme', 'studio-layouts'], true],
|
||||
['studio-layers', 'layers', 'quick', 2, 2, STUDIO, ['studio-properties', 'studio-stickers'], true],
|
||||
['studio-properties', 'panelRight', 'guide', 3, 2, STUDIO, ['studio-content', 'studio-layers']],
|
||||
['studio-format', 'filter', 'quick', 2, 2, STUDIO, ['studio-export']],
|
||||
['studio-export', 'upload', 'guide', 3, 2, STUDIO, ['studio-format', 'studio-spread-file']],
|
||||
['studio-spread-file', 'fileInput', 'quick', 2, 1, STUDIO, ['studio-export', 'studio-pages']],
|
||||
|
||||
@@ -36,6 +36,9 @@ export interface HelpGuideMedia {
|
||||
/** Icon names the help components know how to draw (see components/Help/helpIcons.ts). */
|
||||
export type HelpIcon =
|
||||
| 'plus'
|
||||
| 'sticker'
|
||||
| 'type'
|
||||
| 'paintBucket'
|
||||
| 'pencil'
|
||||
| 'image'
|
||||
| 'copy'
|
||||
|
||||
@@ -83,6 +83,13 @@ export default function MAdminGitHubPanel({ isPrerelease = false }: { isPrerelea
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
if (block.kind === 'h5') {
|
||||
return (
|
||||
<h5 key={idx} className="text-caption font-semibold mt-3 mb-1 text-m-ink">
|
||||
{block.text}
|
||||
</h5>
|
||||
);
|
||||
}
|
||||
if (block.kind === 'h4') {
|
||||
return (
|
||||
<h4 key={idx} className="text-xs font-semibold mt-3 mb-1 text-m-ink">
|
||||
|
||||
@@ -1,15 +1,10 @@
|
||||
import { Check, Copy, ExternalLink, MessagesSquare } from 'lucide-react'
|
||||
import { useTranslation } from '../../../i18n'
|
||||
import MPhotoProvidersSection from './MPhotoProvidersSection'
|
||||
import MAirTrailConnectionSection from './MAirTrailConnectionSection'
|
||||
import MDawarichConnectionSection from './MDawarichConnectionSection'
|
||||
import MLlmConnectionSection from './MLlmConnectionSection'
|
||||
import MAiConnectionSection from './MAiConnectionSection'
|
||||
import MSettingsMcp from './MSettingsMcp'
|
||||
import { MSetButton, MSetCard, MSetHint } from './MSettingsUi'
|
||||
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
|
||||
@@ -33,35 +28,7 @@ export default function MSettingsIntegrations() {
|
||||
a managed install. The per-user fallback exists for people who supply their
|
||||
own key, and there nobody does. */}
|
||||
{llmEnabled && ownParsing && !managed && <MLlmConnectionSection />}
|
||||
{mcpEnabled && <MClaudeConnectorCard />}
|
||||
{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 ConfirmDialog from '../components/shared/ConfirmDialog'
|
||||
import { Spinner } from '../components/shared/Spinner'
|
||||
import InAppNotificationItem from '../components/Notifications/InAppNotificationItem.tsx'
|
||||
import NotificationDayList from '../components/Notifications/NotificationDayList'
|
||||
import { useInAppNotifications } from './inAppNotifications/useInAppNotifications'
|
||||
|
||||
export default function InAppNotificationsPage(): React.ReactElement {
|
||||
@@ -88,27 +88,25 @@ function InAppNotificationsPageDesktop(): React.ReactElement {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Notification list */}
|
||||
<div className="rounded-2xl border overflow-hidden border-edge bg-surface-card py-1">
|
||||
{isLoading && displayed.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<Spinner className="w-6 h-6 border-2 border-slate-200 border-t-current" />
|
||||
</div>
|
||||
) : displayed.length === 0 ? (
|
||||
{/* The list, one card per day. */}
|
||||
{isLoading && displayed.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<Spinner className="w-6 h-6 border-2 border-edge border-t-current" />
|
||||
</div>
|
||||
) : displayed.length === 0 ? (
|
||||
<div className="rounded-2xl border border-edge bg-surface-card">
|
||||
<EmptyState scene="notifications" title={t('notifications.empty')} />
|
||||
) : (
|
||||
displayed.map(n => (
|
||||
<InAppNotificationItem key={n.id} notification={n} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<NotificationDayList notifications={displayed} />
|
||||
)}
|
||||
|
||||
{/* Infinite scroll trigger */}
|
||||
{hasMore && (
|
||||
<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" />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Infinite scroll trigger */}
|
||||
{hasMore && (
|
||||
<div ref={loaderRef} className="flex items-center justify-center py-4">
|
||||
{isLoading && <Spinner className="w-5 h-5 border-2 border-edge border-t-current" />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<ConfirmDialog
|
||||
isOpen={confirmingDeleteAll}
|
||||
|
||||
@@ -37,10 +37,13 @@ vi.mock('../../api/websocket', async importOriginal => ({
|
||||
|
||||
// The auto layout lives in @trek/shared now; the hook's call into it is watched through a partial mock.
|
||||
const shared = await vi.importActual<typeof import('@trek/shared')>('@trek/shared')
|
||||
const buildBook = vi.fn(shared.buildBook)
|
||||
const autoDesignedBook = vi.fn(shared.autoDesignedBook)
|
||||
vi.mock('@trek/shared', async importOriginal => {
|
||||
const actual = await importOriginal<typeof import('@trek/shared')>()
|
||||
return { ...actual, buildBook: (...args: Parameters<typeof actual.buildBook>) => buildBook(...args) }
|
||||
return {
|
||||
...actual,
|
||||
autoDesignedBook: (...args: Parameters<typeof actual.autoDesignedBook>) => autoDesignedBook(...args),
|
||||
}
|
||||
})
|
||||
|
||||
import { useJourneyStudio } from './useJourneyStudio'
|
||||
@@ -67,7 +70,7 @@ function seedJourney() {
|
||||
|
||||
beforeEach(() => {
|
||||
seedJourney()
|
||||
buildBook.mockClear()
|
||||
autoDesignedBook.mockClear()
|
||||
useStudioStore.setState({ doc: null, selection: [], activeSpread: 0, past: [], future: [], readOnly: false })
|
||||
vi.spyOn(journeyApi, 'getBook').mockResolvedValue({ book: null } as never)
|
||||
vi.spyOn(journeyApi, 'saveBook').mockResolvedValue({} as never)
|
||||
@@ -140,8 +143,8 @@ describe('the auto layout input', () => {
|
||||
|
||||
act(() => { result.current.relayoutBook() })
|
||||
|
||||
expect(buildBook).toHaveBeenCalled()
|
||||
const input = buildBook.mock.calls[buildBook.mock.calls.length - 1][0]
|
||||
expect(autoDesignedBook).toHaveBeenCalled()
|
||||
const input = autoDesignedBook.mock.calls[autoDesignedBook.mock.calls.length - 1][1]
|
||||
expect(input.path).toEqual([TRACK])
|
||||
})
|
||||
|
||||
@@ -176,9 +179,51 @@ describe('the auto layout input', () => {
|
||||
|
||||
act(() => { result.current.relayoutBook() })
|
||||
|
||||
const input = buildBook.mock.calls[buildBook.mock.calls.length - 1][0]
|
||||
const input = autoDesignedBook.mock.calls[autoDesignedBook.mock.calls.length - 1][1]
|
||||
expect(input.path).toEqual([])
|
||||
})
|
||||
|
||||
it("lays the whole book on the designed pages in the book's own page format, theme and title", async () => {
|
||||
const { result } = await mountStudio()
|
||||
const theme = shared.bookThemeOf('midnight')
|
||||
const page = { ...shared.bookPageSetupSchema.parse({}), pageWidth: 200 }
|
||||
act(() => {
|
||||
useStudioStore.getState().commit(d => ({ ...d, title: 'My own title', page, theme }))
|
||||
})
|
||||
|
||||
act(() => { result.current.relayoutBook() })
|
||||
|
||||
const [doc, input, opts] = autoDesignedBook.mock.calls[autoDesignedBook.mock.calls.length - 1]
|
||||
expect(input.page).toEqual(page)
|
||||
expect(opts?.theme).toEqual(theme)
|
||||
expect(doc?.title).toBe('My own title')
|
||||
const laid = useStudioStore.getState().doc
|
||||
expect(laid?.title).toBe('My own title')
|
||||
expect(laid?.theme).toEqual(theme)
|
||||
expect(laid?.page.pageWidth).toBe(200)
|
||||
expect(laid?.spreads.some(s => s.role === 'inner' && s.entryId === 1)).toBe(true)
|
||||
})
|
||||
|
||||
it('lays this spread on another design, its id kept, and knows the cover but not the back', async () => {
|
||||
const { result } = await mountStudio()
|
||||
act(() => { result.current.relayoutBook() })
|
||||
const before = useStudioStore.getState().doc
|
||||
if (!before) throw new Error('a laid book')
|
||||
const at = before.spreads.findIndex(s => s.role === 'inner' && s.entryId === 1)
|
||||
act(() => { useStudioStore.getState().setActiveSpread(at) })
|
||||
expect(result.current.canRelayoutSpread).toBe(true)
|
||||
const was = shared.matchDecoratedTemplate(before.spreads[at], before.page)?.id
|
||||
|
||||
act(() => { result.current.relayoutCurrentSpread() })
|
||||
|
||||
const after = useStudioStore.getState().doc?.spreads[at]
|
||||
expect(after?.id).toBe(before.spreads[at].id)
|
||||
expect(shared.matchDecoratedTemplate(after as never, before.page)?.id).not.toBe(was)
|
||||
act(() => { useStudioStore.getState().setActiveSpread(0) })
|
||||
expect(result.current.canRelayoutSpread).toBe(true)
|
||||
act(() => { useStudioStore.getState().setActiveSpread(before.spreads.length - 1) })
|
||||
expect(result.current.canRelayoutSpread).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useLocation, useNavigate, useParams } from 'react-router'
|
||||
import type { AutoEntry, AutoInput, AutoPhoto, BookElement, BookPageNumbers, JourneyStats } from '@trek/shared'
|
||||
import type { AutoDesignedInput, AutoEntry, AutoPhoto, BookElement, BookPageNumbers, JourneyStats } from '@trek/shared'
|
||||
import {
|
||||
autoEntriesWithStops, bookElementId as elementId, bookPageSetupSchema, bookPath, buildBook, distributeGallery,
|
||||
emptyBook, layoutAroundLocks, relayoutSpread,
|
||||
autoDesignedBook, autoEntriesWithStops, autoRelayoutKind, autoRelayoutSpread, bookElementId as elementId,
|
||||
bookPageSetupSchema, bookPath, emptyBook,
|
||||
} from '@trek/shared'
|
||||
import { journeyApi } from '../../api/client'
|
||||
import { useJourneyStore, type GalleryPhoto, type JourneyEntry, type JourneyPhoto } from '../../store/journeyStore'
|
||||
@@ -153,7 +153,7 @@ export function useJourneyStudio() {
|
||||
* possible; rebuilding it from the journey on every click would be the same
|
||||
* work plus a chance of the two disagreeing.
|
||||
*/
|
||||
const autoInput = useRef<AutoInput | null>(null)
|
||||
const autoInput = useRef<AutoDesignedInput | null>(null)
|
||||
|
||||
const journey = current && current.id === journeyId ? current : null
|
||||
|
||||
@@ -274,15 +274,15 @@ export function useJourneyStudio() {
|
||||
})),
|
||||
}))
|
||||
|
||||
// When each loose photo was taken: the auto layout gives it to the entry of its day first.
|
||||
const gallery: AutoPhoto[] = (journey.gallery || []).map((p: GalleryPhoto) => ({
|
||||
photoId: p.photo_id,
|
||||
width: p.width ?? null,
|
||||
height: p.height ?? null,
|
||||
caption: p.caption ?? null,
|
||||
takenAt: p.taken_at ?? null,
|
||||
}))
|
||||
|
||||
const withPhotos = distributeGallery(entries, gallery)
|
||||
|
||||
/*
|
||||
* Give every entry what the journey's figures know about its stop: the
|
||||
* coordinates and country its place resolved to and which day it is, so a
|
||||
@@ -290,7 +290,7 @@ export function useJourneyStudio() {
|
||||
* is shared (autoEntriesWithStops) so the server's design tool reads the
|
||||
* same stops.
|
||||
*/
|
||||
const placed = autoEntriesWithStops(withPhotos, stats)
|
||||
const placed = autoEntriesWithStops(entries, stats)
|
||||
|
||||
const preset = PAGE_PRESETS['square-210']
|
||||
|
||||
@@ -300,6 +300,8 @@ export function useJourneyStudio() {
|
||||
subtitle: journey.subtitle ?? null,
|
||||
coverPhotoId: gallery[0]?.photoId ?? null,
|
||||
entries: placed,
|
||||
// The loose photos stay loose here: the layout shares them out itself, by day, and loses none.
|
||||
gallery,
|
||||
page: {
|
||||
preset: preset.id,
|
||||
pageWidth: preset.pageWidthMm,
|
||||
@@ -375,7 +377,7 @@ export function useJourneyStudio() {
|
||||
*
|
||||
* The build effect runs as soon as the book and the figures are in, which is
|
||||
* usually before this answers, and what it froze is what the Auto layout
|
||||
* buttons hand to buildBook afterwards. Without the patch a relayout drew
|
||||
* buttons hand to the layout afterwards. Without the patch a relayout drew
|
||||
* the straight line between stops over a route somebody had walked.
|
||||
*/
|
||||
const applyPath = (p: [number, number][][]) => {
|
||||
@@ -471,8 +473,10 @@ export function useJourneyStudio() {
|
||||
const relayoutBook = useCallback(() => {
|
||||
const input = autoInput.current
|
||||
if (!input) return
|
||||
// A new seed every press: "the whole book" twice gives two books, each one undo away.
|
||||
const seed = Date.now() % 2 ** 31
|
||||
commit(d => ({
|
||||
...buildBook({ ...input, page: d.page }),
|
||||
...autoDesignedBook(d, { ...input, page: d.page }, { theme: d.theme, seed }),
|
||||
title: d.title,
|
||||
}))
|
||||
setActiveSpread(0)
|
||||
@@ -481,22 +485,20 @@ export function useJourneyStudio() {
|
||||
const relayoutCurrentSpread = useCallback(() => {
|
||||
const input = autoInput.current
|
||||
if (!input) return
|
||||
const seed = Date.now() % 2 ** 31
|
||||
commit(d => {
|
||||
const sp = d.spreads[activeSpread]
|
||||
if (!sp) return d
|
||||
// Laid out around what is locked, as the assistant's auto layout leaves it.
|
||||
const next = layoutAroundLocks(sp, free => relayoutSpread(free, { ...input, page: d.page }))
|
||||
// Laid out around what is locked, its id kept, on another template than the one on screen.
|
||||
const next = autoRelayoutSpread(d, activeSpread, { ...input, page: d.page }, { seed })
|
||||
if (!next) return d
|
||||
return { ...d, spreads: d.spreads.map((x, i) => (i === activeSpread ? next : x)) }
|
||||
})
|
||||
select([])
|
||||
}, [commit, activeSpread, select])
|
||||
|
||||
/** Whether the spread on screen came from an entry, and so can be redone. */
|
||||
/** Whether the auto layout knows the spread on screen (an entry's page, the cover, the summary, the countries). */
|
||||
const canRelayoutSpread = !!(
|
||||
autoInput.current
|
||||
&& doc?.spreads[activeSpread]?.entryId != null
|
||||
&& doc.spreads[activeSpread].role === 'inner'
|
||||
&& autoRelayoutKind(doc?.spreads[activeSpread]) !== null
|
||||
)
|
||||
|
||||
/** Page numbers and anything else that belongs to the book rather than a page. */
|
||||
|
||||
@@ -126,6 +126,5 @@ describe('aiConnectionsRepo', () => {
|
||||
await expect(aiConnectionsRepo.disconnect()).rejects.toBeInstanceOf(OnlineOnlyError);
|
||||
await expect(aiConnectionsRepo.chatgptStart()).rejects.toBeInstanceOf(OnlineOnlyError);
|
||||
await expect(aiConnectionsRepo.adminSettings()).rejects.toBeInstanceOf(OnlineOnlyError);
|
||||
await expect(aiConnectionsRepo.claudeConnector()).rejects.toBeInstanceOf(OnlineOnlyError);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,6 @@ import {
|
||||
aiAdminConnectionResponseSchema,
|
||||
aiAdminSettingsSchema,
|
||||
aiAuthStartResponseSchema,
|
||||
aiClaudeConnectorResponseSchema,
|
||||
aiConnectionViewSchema,
|
||||
aiModelListResponseSchema,
|
||||
aiStatusResponseSchema,
|
||||
@@ -14,7 +13,7 @@ import {
|
||||
type AiOpenrouterFinishRequest,
|
||||
type AiPrivacyAckRequest,
|
||||
} from '@trek/shared';
|
||||
import { aiAdminApi, aiApi, claudeConnectorApi } from '../api/assistant';
|
||||
import { aiAdminApi, aiApi } from '../api/assistant';
|
||||
import { requireOnline } from './onlineOnly';
|
||||
|
||||
/**
|
||||
@@ -114,11 +113,4 @@ export const aiConnectionsRepo = {
|
||||
requireOnline();
|
||||
return aiModelListResponseSchema.parse(await aiAdminApi.models()).models;
|
||||
},
|
||||
|
||||
// ── Claude connector ───────────────────────────────────────────────────────
|
||||
|
||||
async claudeConnector() {
|
||||
requireOnline();
|
||||
return aiClaudeConnectorResponseSchema.parse(await claudeConnectorApi.get());
|
||||
},
|
||||
};
|
||||
|
||||
@@ -22,11 +22,11 @@ describe('parseReleaseNotes', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('FE-UTIL-RELNOTES-003: lines are trimmed and markers need their space', () => {
|
||||
it('FE-UTIL-RELNOTES-003: lines are trimmed, markers need their space and #### heads a feature', () => {
|
||||
expect(parseReleaseNotes(' ## Spaced \n-no space\n#### deep\n\n\n')).toEqual([
|
||||
{ kind: 'h3', text: 'Spaced' },
|
||||
{ kind: 'p', text: '-no space' },
|
||||
{ kind: 'p', text: '#### deep' },
|
||||
{ kind: 'h5', text: 'deep' },
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/** One block of a release body: a heading, a paragraph, or a run of list items. */
|
||||
export type ReleaseNoteBlock = { kind: 'h3' | 'h4' | 'p'; text: string } | { kind: 'ul'; items: string[] };
|
||||
export type ReleaseNoteBlock = { kind: 'h3' | 'h4' | 'h5' | 'p'; text: string } | { kind: 'ul'; items: string[] };
|
||||
|
||||
/** Escapes the characters that would let release text break out of the HTML it is placed in. */
|
||||
export function escapeReleaseHtml(str: string): string {
|
||||
@@ -7,8 +7,8 @@ export function escapeReleaseHtml(str: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits a GitHub release body into the blocks both admin shells render: `### ` and `## `
|
||||
* headings, `- ` or `* ` list items gathered into one list until anything else breaks the
|
||||
* Splits a GitHub release body into the blocks both admin shells render: `## `, `### ` and
|
||||
* `#### ` headings (the version, a section such as Added, a feature inside it), `- ` or `* ` list items gathered into one list until anything else breaks the
|
||||
* run, and every other non-empty line as a paragraph. Blank lines only end a list. The
|
||||
* text stays raw; each shell formats it inline itself.
|
||||
*/
|
||||
@@ -30,7 +30,10 @@ export function parseReleaseNotes(body: string): ReleaseNoteBlock[] {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (trimmed.startsWith('### ')) {
|
||||
if (trimmed.startsWith('#### ')) {
|
||||
flushList();
|
||||
blocks.push({ kind: 'h5', text: trimmed.slice(5) });
|
||||
} else if (trimmed.startsWith('### ')) {
|
||||
flushList();
|
||||
blocks.push({ kind: 'h4', text: trimmed.slice(4) });
|
||||
} else if (trimmed.startsWith('## ')) {
|
||||
|
||||
@@ -179,12 +179,12 @@ describe('applying a theme', () => {
|
||||
});
|
||||
|
||||
describe('the Theme panel', () => {
|
||||
it('FE-STUDIO-THEME-007: shows twelve themes, applies the one picked and says so', () => {
|
||||
it('FE-STUDIO-THEME-007: shows seventeen themes, applies the one picked and says so', () => {
|
||||
render(<StudioThemePanel t={t} />);
|
||||
const cards = screen
|
||||
.getAllByRole('button', { pressed: false })
|
||||
.filter((b) => b.classList.contains('st-theme-card'));
|
||||
expect(cards).toHaveLength(11);
|
||||
expect(cards).toHaveLength(16);
|
||||
fireEvent.click(screen.getByTitle('studio.theme.descr.riviera'));
|
||||
fireEvent.click(screen.getByText('studio.theme.applyBook'));
|
||||
expect(doc().theme?.id).toBe('riviera');
|
||||
|
||||
@@ -17,5 +17,5 @@
|
||||
"src/nest/plugins/runtime/plugin-sdk.ts": 1093,
|
||||
"src/nest/reservations/reservations.service.ts": 1237,
|
||||
"src/nest/trips/trips.service.ts": 1006,
|
||||
"src/nest/vacay/vacay.service.ts": 1416
|
||||
"src/nest/vacay/vacay.service.ts": 1409
|
||||
}
|
||||
|
||||
@@ -46,6 +46,11 @@ Recipes, for a cover built or tweaked by hand, as JSON: each object one element
|
||||
- grid-badge: [{"kind":"photo","photoId":"cover","frame":[-2,-2,50.8,50.8]},{"kind":"photo","photoId":"2","frame":[51.2,-2,50.8,50.8]},{"kind":"photo","photoId":"3","frame":[-2,51.2,50.8,50.8]},{"kind":"photo","photoId":"4","frame":[51.2,51.2,50.8,50.8]},{"lockup":"circle-badge","box":[29,29,42,42]}], a cross of paper 2.4 % (5 mm) wide, the badge off every photo's focal point.
|
||||
- minimal-serif: [{"kind":"photo","photoId":"cover","frame":[8,8,84,53],"border":{"width":0.3,"color":"ink"}},{"kind":"text","binding":{"source":"journey.title"},"fontRole":"display","token":"ink","size":80,"leading":1,"frame":[8,64,84,17]},{"kind":"text","binding":{"source":"journey.subtitle"},"fontRole":"heading","token":"ink","size":11,"frame":[8,82,84,3]},{"kind":"shape","shape":"rect","token":"ink","frame":[28,88.4,20,0.15]},{"kind":"shape","shape":"sun","token":"accent","frame":[48.5,87,3,3]},{"kind":"shape","shape":"rect","token":"ink","frame":[52,88.4,20,0.15]},{"kind":"text","text":"2026","size":8,"frame":[8,92,84,3]}]
|
||||
- stamp-collage: [{"kind":"shape","shape":"rect","paint":{"type":"pattern","pattern":"checker","fg":"accent","bg":"paper","scale":1,"angle":0},"frame":[-2,-2,104,36]},{"kind":"photo","photoId":"cover","frame":[8,19,26,31],"rotation":-4,"edge":"perforated","edgeSize":3,"border":{"width":2.2,"color":"#ffffff"}},{"kind":"photo","photoId":"2","frame":[37,17,26,34],"rotation":3,"mask":"arch-tall","cutout":{"width":1.8,"color":"#ffffff"}},{"kind":"photo","photoId":"3","frame":[66,19,26,31],"rotation":-3,"edge":"perforated","edgeSize":3,"border":{"width":2.2,"color":"#ffffff"}},{"kind":"text","binding":{"source":"journey.title"},"fontRole":"display","token":"ink","size":90,"weight":800,"transform":"upper","align":"left","frame":[8,62,52,18]},{"kind":"text","text":"May 2026","size":9,"frame":[8,81,52,4]},{"lockup":"postmark","box":[62,60,30,30]}]
|
||||
- riso: [{"kind":"shape","shape":"ellipse","token":"accent2","opacity":0.85,"frame":[8,46,52,52]},{"kind":"shape","shape":"ellipse","paint":{"type":"pattern","pattern":"dots","fg":"accent","bg":null,"scale":0.4,"angle":45},"frame":[11,44,52,52]},{"kind":"shape","shape":"rect","token":"accent","frame":[37,50,52,42],"rotation":2},{"kind":"photo","photoId":"cover","frame":[39,48,52,42],"rotation":2},{"kind":"text","binding":{"source":"journey.title"},"fontRole":"display","token":"ink","size":90,"transform":"upper","leading":0.92,"align":"left","shadow":{"dx":1.6,"dy":1.6,"color":"accent","alpha":1},"frame":[8,8,84,30]},{"kind":"text","text":"2026","size":9,"highlight":{"color":"accent2"},"frame":[8,88,20,4]}], the block and the discs a few mm out of register.
|
||||
- editorial: [{"kind":"photo","photoId":"cover","frame":[-2,37,104,65]},{"kind":"text","binding":{"source":"journey.title"},"fontRole":"display","token":"ink","size":96,"weight":800,"transform":"upper","leading":0.9,"frame":[8,8,84,19]},{"kind":"shape","shape":"rect","token":"ink","frame":[8,27.5,84,0.2]},{"kind":"text","text":"May 2026","size":8,"align":"left","frame":[8,29,40,3]},{"kind":"shape","shape":"rect","token":"accent","frame":[8,74,44,18]},{"kind":"text","binding":{"source":"journey.subtitle"},"fontRole":"heading","token":"paper","size":16,"align":"left","frame":[12,78,36,10]}], the block off the photo's focal point.
|
||||
- vintage-poster: [{"kind":"shape","shape":"rect","fill":null,"strokeToken":"ink","strokeWidth":0.6,"frame":[1.7,1.7,96.6,96.6]},{"kind":"shape","shape":"rect","token":"accent3","frame":[3.3,3.3,93.4,58]},{"kind":"shape","shape":"sunburst-24","token":"accent","opacity":0.5,"frame":[24,6,52,52]},{"kind":"shape","shape":"ellipse","token":"accent","frame":[34,16,32,32]},{"kind":"shape","shape":"mountain","token":"accent2","frame":[3.3,32,64,29.3]},{"kind":"shape","shape":"mountain","token":"ink","opacity":0.8,"frame":[36,40,60.7,21.3]},{"kind":"photo","photoId":"cover","frame":[38,14,24,44],"mask":"arch-tall","cutout":{"width":2,"color":"#ffffff"}},{"kind":"text","binding":{"source":"journey.title"},"fontRole":"display","token":"ink","size":80,"transform":"upper","leading":0.92,"frame":[8,66,84,20]},{"kind":"text","text":"May 2026","size":9,"frame":[8,88,84,4]}]
|
||||
- night-film: [{"background":"ink"},{"kind":"text","text":"May 2026","font":"mono","token":"accent","size":8,"tracking":0.2,"align":"left","frame":[8,8,40,3]},{"kind":"photo","photoId":"cover","frame":[-2,14,104,40]},{"kind":"text","binding":{"source":"journey.title"},"fontRole":"display","token":"paper","size":72,"transform":"upper","tracking":0.14,"align":"left","frame":[8,58,84,16]},{"kind":"shape","shape":"rect","token":"accent","frame":[8,76,16,0.4]},{"kind":"shape","shape":"rect","token":"paper","opacity":0.12,"frame":[-2,80,104,18]},{"kind":"line","stroke":"#f2efe8","strokeWidth":1.6,"dash":"dashed","frame":[-2,81.5,104,1]},{"kind":"line","stroke":"#f2efe8","strokeWidth":1.6,"dash":"dashed","frame":[-2,95.5,104,1]},{"kind":"photo","photoId":"2","frame":[8,84,26,10]},{"kind":"photo","photoId":"3","frame":[37,84,26,10]},{"kind":"photo","photoId":"4","frame":[66,84,26,10]}], paper and ink swapped in a dark theme.
|
||||
- field-notes: [{"kind":"shape","shape":"rect","paint":{"type":"pattern","pattern":"grid","fg":"accent3","bg":"accent2","scale":0.6,"angle":0},"frame":[4,4,92,92],"rotation":-1.5},{"kind":"shape","shape":"leaf-long","token":"accent3","frame":[2,40,9,40],"rotation":-20},{"kind":"photo","photoId":"cover","frame":[10,10,48,40],"rotation":-3,"border":{"width":2.6,"color":"#ffffff"}},{"kind":"shape","shape":"rect","token":"accent2","opacity":0.72,"edge":"zigzag","edgeSize":1,"frame":[24,8,19,3.3],"rotation":4},{"kind":"photo","photoId":"2","frame":[62,17,28,27],"rotation":4,"border":{"width":2.6,"color":"#ffffff"}},{"kind":"text","binding":{"source":"journey.title"},"fontRole":"display","token":"ink","size":60,"align":"left","frame":[8,62,46,18]},{"kind":"text","binding":{"source":"journey.subtitle"},"fontRole":"script","token":"accent","size":26,"align":"left","frame":[8,80,46,8]},{"kind":"text","text":"May 2026","font":"mono","size":8,"align":"left","frame":[8,89,46,3]}]
|
||||
Lively spreads, in percent of the double page (the fold at 50); photos 1 to 4 are the page's, entry its entry id:
|
||||
- script overlay: [{"kind":"photo","photoId":"1","focalX":0.3,"frame":[-1,-2,102,104]},{"kind":"text","binding":{"source":"entry.location","entryId":"entry"},"fontRole":"script","size":120,"color":"#ffffff","outline":{"color":"#1a1a1a","width":2.9},"frame":[55,26,40,30],"rotation":-6},{"kind":"text","binding":{"source":"entry.title","entryId":"entry"},"token":"ink","size":9,"highlight":{"color":"paper"},"frame":[57,62,36,4]}], the photo's detail off the fold and the word.
|
||||
- collage: [{"kind":"photo","photoId":"1","frameStyle":"polaroid","frame":[6,10,26,36],"rotation":-5},{"kind":"photo","photoId":"2","frameStyle":"white","frame":[22,42,24,32],"rotation":4},{"kind":"photo","photoId":"3","frameStyle":"tape","frame":[56,12,30,40],"rotation":-3},{"kind":"photo","photoId":"4","frameStyle":"white","frame":[66,52,26,34],"rotation":6},{"kind":"sticker","stickerId":"motif","frame":[39,8,7,10],"rotation":8}], up to 3 prints a page turned 3 to 7 degrees either way, none across the fold or over another's focal point.
|
||||
|
||||
@@ -61,7 +61,7 @@ export class JourneyBookDesignMcp {
|
||||
|
||||
@Tool({
|
||||
name: 'design_book',
|
||||
description: `Design the whole photo book of a journey as a draft: chapters, pacing, the pages, the photos picked for each, type and colours, with the engine's own check run on every page and repaired where it can. directives says what the person wants (style, density, page count, scope, chapters, photos, cover, type, palette, words, travel pages, what to keep) and the art direction: the brief (energy, decoration, collages, theme), the cover's archetype and lockup, and pages.overrides giving single pages of the plan another beat or layout. It answers the draft's design_id, the plan page by page (what each page becomes and its findings), the look, the photos left out and why, and the findings left. ${DRAFT_FLOW} ${LIMITS} Only the book's owner and editors can design it.`,
|
||||
description: `Design the whole photo book of a journey as a draft: chapters, pacing, the pages, the photos picked for each, type and colours, with the engine's own check run on every page and repaired where it can. directives says what the person wants (style, density, page count, scope, chapters, photos, cover, type, palette, words, travel pages, what to keep) and the art direction: the brief (energy, decoration, collages, theme), the cover's archetype and lockup, and pages.overrides giving single pages of the plan another beat or layout. With layouts: 'templates' every page is laid on the Studio's designed templates in the archetype of the book's theme, as the Studio's Auto layout does, and each plan row names its template; style classic alone is the old built-in Auto layout. It answers the draft's design_id, the plan page by page (what each page becomes and its findings), the look, the photos left out and why, and the findings left. ${DRAFT_FLOW} ${LIMITS} Only the book's owner and editors can design it.`,
|
||||
inputSchema: { journeyId: idSchema, ...designBookArgs },
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
when: journeyAddonOn,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||