mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-13 15:51:00 +00:00
feat(assistant): photo changes of a journal get their own card and ways back
The four new photo link tools join the journal's tools: the journey chat offers them to editors, the trip chat loads them with the journey group and the home chat keeps none. A journeyPhotos card shows the photos as thumbnails with the entries they leave and reach, the first one marked as the cover in a new order, and a caption old to new; a library add shows the photos once they landed. A linked photo comes off again, an unlinked one goes back, moved photos go back and a caption is written back, each while nothing changed since. A new order of an entry's photos and a library add have no way back yet. The journal prompt names the photo tools.
This commit is contained in:
@@ -108,7 +108,10 @@ function ListMarks({ look }: { look: JourneyEntryLook }) {
|
||||
return (
|
||||
<>
|
||||
{(look.tags ?? []).map((tag, index) => (
|
||||
<SoftPill key={`${tag}-${index}`} icon={<Tag size={10} strokeWidth={2.2} aria-hidden="true" className={ICON} />}>
|
||||
<SoftPill
|
||||
key={`${tag}-${index}`}
|
||||
icon={<Tag size={10} strokeWidth={2.2} aria-hidden="true" className={ICON} />}
|
||||
>
|
||||
<bdi className="truncate">{tag}</bdi>
|
||||
</SoftPill>
|
||||
))}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* FE-AI-JOURNEYPHOTOS-001 to FE-AI-JOURNEYPHOTOS-004
|
||||
*
|
||||
* A change of a journal's photos in the chat: the entries the photos leave
|
||||
* and reach, the photos as thumbnails, a caption old to new, what stays
|
||||
* where, and the receipt that opens the entry.
|
||||
*/
|
||||
import type { AssistantApplied, AssistantChangePreview, JourneyPhotosCard } from '@trek/shared';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { fireEvent, render, screen } from '../../../../../tests/helpers/render';
|
||||
import AssistantChangeCard from '../../AssistantChangeCard';
|
||||
import type { ChangeItem } from '../../assistantParts';
|
||||
import { AssistantViewContext } from '../../assistantViewContext';
|
||||
|
||||
const MOVE: JourneyPhotosCard = {
|
||||
kind: 'journeyPhotos',
|
||||
journeyId: 3,
|
||||
mode: 'move',
|
||||
from: { entryId: 1, title: 'Vik', date: '2026-07-01' },
|
||||
to: { entryId: 2, title: 'Reynisfjara', date: '2026-07-02' },
|
||||
photos: [
|
||||
{ photoId: 101, video: false, caption: 'Black sand' },
|
||||
{ photoId: 102, video: true, caption: null },
|
||||
],
|
||||
more: 4,
|
||||
kept: false,
|
||||
caption: null,
|
||||
};
|
||||
|
||||
function photoChange(card: JourneyPhotosCard, verbKey: AssistantChangePreview['verbKey'], applied = false): ChangeItem {
|
||||
const preview: AssistantChangePreview = {
|
||||
toolCallId: 'p1',
|
||||
verbKey,
|
||||
subject: 'Reynisfjara',
|
||||
rows: [],
|
||||
dayId: null,
|
||||
placeIds: [],
|
||||
undoable: false,
|
||||
domain: 'journey',
|
||||
card,
|
||||
};
|
||||
const receipt: AssistantApplied = {
|
||||
toolCallId: 'p1',
|
||||
entity: 'journey_entry',
|
||||
ids: [2],
|
||||
dayId: null,
|
||||
undoUntil: null,
|
||||
};
|
||||
return { toolCallId: 'p1', preview, applied: applied ? receipt : null, state: applied ? 'applied' : 'pending', card };
|
||||
}
|
||||
|
||||
function draw(item: ChangeItem, onOpen?: (target: unknown) => void) {
|
||||
return render(
|
||||
<AssistantViewContext.Provider value={{ tripId: -3, variant: 'window', onOpen }}>
|
||||
<AssistantChangeCard items={[item]} />
|
||||
</AssistantViewContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
describe('a change of a journal photos', () => {
|
||||
it('FE-AI-JOURNEYPHOTOS-001: a move names both entries, shows the photos and says they stay in the gallery', () => {
|
||||
const { container } = draw(photoChange(MOVE, 'moveJournalPhotos'));
|
||||
expect(screen.getByText('Move photos to another entry')).toBeInTheDocument();
|
||||
expect(screen.getByText('Vik')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('Reynisfjara').length).toBeGreaterThan(0);
|
||||
const thumbs = [...container.querySelectorAll('img')].map((img) => img.getAttribute('src'));
|
||||
expect(thumbs).toEqual(['/api/photos/101/thumbnail', '/api/photos/102/thumbnail']);
|
||||
expect(screen.getByText('4 more')).toBeInTheDocument();
|
||||
expect(screen.getByText("The photos stay in the journal's gallery.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-AI-JOURNEYPHOTOS-002: a kept move and a new order say what stays where', () => {
|
||||
const first = draw(photoChange({ ...MOVE, kept: true }, 'moveJournalPhotos'));
|
||||
expect(screen.getByText('They also stay on the first entry.')).toBeInTheDocument();
|
||||
first.unmount();
|
||||
draw(photoChange({ ...MOVE, mode: 'order', from: null, more: 0 }, 'orderJournalPhotos'));
|
||||
expect(screen.getByText("The first photo becomes the entry's cover.")).toBeInTheDocument();
|
||||
expect(screen.getByText('Entry cover')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-AI-JOURNEYPHOTOS-003: a caption reads old to new, a cleared one as removed', () => {
|
||||
const caption = {
|
||||
...MOVE,
|
||||
mode: 'caption' as const,
|
||||
from: null,
|
||||
to: null,
|
||||
more: 0,
|
||||
photos: [{ photoId: 101, video: false, caption: 'Old' }],
|
||||
};
|
||||
const first = draw(photoChange({ ...caption, caption: { before: 'Old', after: 'New' } }, 'captionJournalPhoto'));
|
||||
expect(screen.getByText('Old')).toHaveClass('line-through');
|
||||
expect(screen.getByText('New')).toBeInTheDocument();
|
||||
first.unmount();
|
||||
draw(photoChange({ ...caption, caption: { before: 'Old', after: null } }, 'captionJournalPhoto'));
|
||||
expect(screen.getByText('Removed')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-AI-JOURNEYPHOTOS-004: the receipt opens the entry on the journal page', () => {
|
||||
const onOpen = vi.fn();
|
||||
draw(photoChange(MOVE, 'moveJournalPhotos', true), onOpen);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Open in the journal' }));
|
||||
expect(onOpen).toHaveBeenCalledWith({ kind: 'page', path: '/journey/3?entry=2' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
import type { JourneyPhotosCard, JourneyPhotosEntry } from '@trek/shared';
|
||||
import { ArrowRight, Images, MessageSquareText, Play } from 'lucide-react';
|
||||
|
||||
import { useTranslation } from '../../../../i18n';
|
||||
import { cardDateLabel } from '../../../Journey/journeyCard';
|
||||
import { SoftPill } from '../../../Planner/planParts';
|
||||
import type { ChangeItem } from '../../assistantParts';
|
||||
import { FactChange } from '../parts/CardFacts';
|
||||
|
||||
/**
|
||||
* What a change row of a journal's photos adds under the row: the entries
|
||||
* the photos leave and reach, the photos themselves as small thumbnails (the
|
||||
* first marked as the entry's cover in a new order), a caption old to new,
|
||||
* and what the change leaves where. A change that failed, was discarded or
|
||||
* ran out shows the row alone.
|
||||
*/
|
||||
|
||||
const GONE: ReadonlySet<ChangeItem['state']> = new Set(['failed', 'discarded', 'expired']);
|
||||
const NOTE = 'm-0 mt-1.5 text-caption text-content-muted';
|
||||
|
||||
function EntryName({ entry }: { entry: JourneyPhotosEntry }) {
|
||||
const { locale } = useTranslation();
|
||||
const day = entry.date ? cardDateLabel(entry.date, locale) : null;
|
||||
return <bdi className="min-w-0 truncate">{entry.title ?? day ?? ''}</bdi>;
|
||||
}
|
||||
|
||||
/** The entries the photos leave and reach, the way between them marked. */
|
||||
function Route({ card }: { card: JourneyPhotosCard }) {
|
||||
if (!card.from && !card.to) return null;
|
||||
return (
|
||||
<p className="m-0 flex min-w-0 items-center gap-1.5 text-caption text-content-secondary">
|
||||
{card.from && <EntryName entry={card.from} />}
|
||||
{card.from && card.to && (
|
||||
<ArrowRight size={11} aria-hidden="true" className="flex-none text-content-faint rtl:-scale-x-100" />
|
||||
)}
|
||||
{card.to && <EntryName entry={card.to} />}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
function Thumbs({ card }: { card: JourneyPhotosCard }) {
|
||||
const { t } = useTranslation();
|
||||
if (card.photos.length === 0 && card.more === 0) return null;
|
||||
return (
|
||||
<div className="mt-1.5 flex flex-wrap items-center gap-1.5">
|
||||
{card.photos.map((photo, index) => (
|
||||
<span key={`${photo.photoId}-${index}`} className="relative size-11 flex-none">
|
||||
<img
|
||||
src={`/api/photos/${photo.photoId}/thumbnail`}
|
||||
alt={photo.caption ?? ''}
|
||||
loading="lazy"
|
||||
className="size-full rounded-lg bg-surface-secondary object-cover"
|
||||
/>
|
||||
{photo.video && (
|
||||
<SoftPill className="absolute bottom-0.5 end-0.5" icon={<Play size={9} aria-hidden="true" />}>
|
||||
<span className="sr-only">{t('assistant.card.journey.video')}</span>
|
||||
</SoftPill>
|
||||
)}
|
||||
{card.mode === 'order' && index === 0 && (
|
||||
<span className="sr-only">{t('assistant.card.journeyPhotos.cover')}</span>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
{card.more > 0 && (
|
||||
<SoftPill icon={<Images size={10} strokeWidth={2.2} aria-hidden="true" />}>
|
||||
{t('assistant.card.common.more', { count: card.more })}
|
||||
</SoftPill>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** What the change leaves where: on the first entry too, in the gallery, the first photo the cover. */
|
||||
function Notes({ card }: { card: JourneyPhotosCard }) {
|
||||
const { t } = useTranslation();
|
||||
if (card.mode === 'move' && card.kept) return <p className={NOTE}>{t('assistant.card.journeyPhotos.kept')}</p>;
|
||||
if (card.mode === 'unlink' || card.mode === 'move') {
|
||||
return <p className={NOTE}>{t('assistant.card.journeyPhotos.staysInGallery')}</p>;
|
||||
}
|
||||
if (card.mode === 'order' && card.photos.length > 0) {
|
||||
return <p className={NOTE}>{t('assistant.card.journeyPhotos.firstIsCover')}</p>;
|
||||
}
|
||||
if (card.mode === 'add') return <p className={NOTE}>{t('assistant.card.journeyPhotos.fromLibrary')}</p>;
|
||||
return null;
|
||||
}
|
||||
|
||||
export function JourneyPhotosBody({ card, item }: { card: JourneyPhotosCard; item: ChangeItem }) {
|
||||
const { t } = useTranslation();
|
||||
if (GONE.has(item.state)) return null;
|
||||
return (
|
||||
<div>
|
||||
<Route card={card} />
|
||||
<Thumbs card={card} />
|
||||
{card.caption && (
|
||||
<span className="mt-1.5 flex">
|
||||
<FactChange
|
||||
icon={MessageSquareText}
|
||||
before={card.caption.before}
|
||||
after={card.caption.after ?? t('assistant.card.common.removed')}
|
||||
tone="info"
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
<Notes card={card} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,17 @@
|
||||
import {
|
||||
ArrowDownUp,
|
||||
ArrowRightLeft,
|
||||
BookCheck,
|
||||
BookOpen,
|
||||
BookOpenText,
|
||||
BookPlus,
|
||||
BookX,
|
||||
Eye,
|
||||
ImageMinus,
|
||||
ImagePlus,
|
||||
Images,
|
||||
Link,
|
||||
MessageSquareText,
|
||||
RotateCcw,
|
||||
Settings2,
|
||||
Type,
|
||||
@@ -48,6 +53,16 @@ export const JOURNEY_LOOK = {
|
||||
labelKey: 'assistant.change.verb.restoreJournalSuggestions',
|
||||
},
|
||||
setJournalPreferences: { Icon: Eye, tone: 'info', labelKey: 'assistant.change.verb.setJournalPreferences' },
|
||||
linkJournalPhoto: { Icon: ImagePlus, tone: 'success', labelKey: 'assistant.change.verb.linkJournalPhoto' },
|
||||
unlinkJournalPhoto: { Icon: ImageMinus, tone: 'warning', labelKey: 'assistant.change.verb.unlinkJournalPhoto' },
|
||||
moveJournalPhotos: { Icon: ArrowRightLeft, tone: 'info', labelKey: 'assistant.change.verb.moveJournalPhotos' },
|
||||
orderJournalPhotos: { Icon: ArrowDownUp, tone: 'info', labelKey: 'assistant.change.verb.orderJournalPhotos' },
|
||||
captionJournalPhoto: {
|
||||
Icon: MessageSquareText,
|
||||
tone: 'info',
|
||||
labelKey: 'assistant.change.verb.captionJournalPhoto',
|
||||
},
|
||||
addJournalPhotos: { Icon: Images, tone: 'success', labelKey: 'assistant.change.verb.addJournalPhotos' },
|
||||
},
|
||||
tools: {
|
||||
create_journey: { labelKey: 'assistant.tool.create_journey' },
|
||||
@@ -66,16 +81,19 @@ export const JOURNEY_LOOK = {
|
||||
journey_entry: {
|
||||
Icon: BookOpen,
|
||||
openLabelKey: 'assistant.card.journey.open',
|
||||
open: (applied, card) =>
|
||||
card?.kind === 'journeyEntries'
|
||||
open: (applied, card) => {
|
||||
if (card?.kind === 'journeyPhotos') return journalTarget(card.journeyId, applied.ids[0] ?? null, false);
|
||||
return card?.kind === 'journeyEntries'
|
||||
? journalTarget(card.journeyId, applied.ids[0] ?? card.entries[0]?.entryId ?? null, card.tripJournal)
|
||||
: null,
|
||||
: null;
|
||||
},
|
||||
},
|
||||
journey: {
|
||||
Icon: BookOpen,
|
||||
openLabelKey: 'assistant.card.journeyHeader.open',
|
||||
open: (applied, card) => {
|
||||
const journeyId = applied.ids[0] ?? (card?.kind === 'journeyHeader' ? card.journeyId : null);
|
||||
const fromCard = card?.kind === 'journeyHeader' || card?.kind === 'journeyPhotos' ? card.journeyId : null;
|
||||
const journeyId = applied.ids[0] ?? fromCard;
|
||||
return journeyId ? journalTarget(journeyId, null, false) : null;
|
||||
},
|
||||
},
|
||||
@@ -101,5 +119,8 @@ export const JOURNEY_LOOK = {
|
||||
journeyHeader: {
|
||||
entity: () => 'journey',
|
||||
},
|
||||
journeyPhotos: {
|
||||
entity: (card) => (card.mode === 'caption' ? 'journey' : 'journey_entry'),
|
||||
},
|
||||
},
|
||||
} satisfies PackLook<'journeyEntries' | 'journeyHeader'>;
|
||||
} satisfies PackLook<'journeyEntries' | 'journeyHeader' | 'journeyPhotos'>;
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { CardView } from '../cardTypes';
|
||||
import { JourneyEntryAside, JourneyEntryBody, JourneyEntryFacts } from './JourneyChangeSlots';
|
||||
import JourneyEntriesBlock from './JourneyEntriesBlock';
|
||||
import { JourneyHeaderBody, JourneyHeaderFacts } from './JourneyHeaderSlots';
|
||||
import { JourneyPhotosBody } from './JourneyPhotosSlots';
|
||||
|
||||
/**
|
||||
* How the kinds of the journeys draw in the chat: the entries a read found
|
||||
@@ -24,4 +25,7 @@ export const JOURNEY_VIEWS = {
|
||||
Facts: JourneyHeaderFacts,
|
||||
Body: JourneyHeaderBody,
|
||||
},
|
||||
} satisfies { [K in 'journeyEntries' | 'journeyHeader']: CardView<K> };
|
||||
journeyPhotos: {
|
||||
Body: JourneyPhotosBody,
|
||||
},
|
||||
} satisfies { [K in 'journeyEntries' | 'journeyHeader' | 'journeyPhotos']: CardView<K> };
|
||||
|
||||
Reference in New Issue
Block a user