Compare commits

..

51 Commits

Author SHA1 Message Date
Maurice de497d15a4 test(assistant): the attachments repository on its own, every read, write and sweep step
The repositories are held to 99 percent of their statements, and the new
attachments repository was reached only through its services' mocks.
2026-10-24 22:32:48 +02:00
Maurice 05666295e0 test(assistant): every keep proposal in the attachments e2e takes a call id of its own
The refusal test proposed two keeps in one thread under the same call id, so
the second decision met the first call, already decided, and no refusal came
back. Each proposal now has its own id, and the refused right is set after the
proposal so it is the apply that refuses.
2026-10-24 22:32:48 +02:00
Maurice 62da83f5f7 test(assistant): the keep test reads the createFile call through a typed tuple
The mock's call list types as an empty tuple, so indexing its fourth argument
failed typecheck:tests.
2026-10-24 22:32:48 +02:00
Maurice b333910533 test(assistant): the attachments e2e provider answers the model list a saved connection asks for
Saving the custom connection lists the server's models first, and the fake
provider answered that with a 404, so the setup got 502. It answers /api/tags
the way the other assistant suites' fake provider does.
2026-10-24 22:32:48 +02:00
Maurice 2d1da19152 fix(assistant): the step that keeps an attached file's bytes is called stash
lint:test-sql counts every .prepare( under tests/ as a raw statement, and the
attachments service's prepare() was called five times in its unit test. The
test holds no SQL; the method takes a name that does not read as one.
2026-10-24 22:32:48 +02:00
Maurice e7ac46c756 fix(assistant): the keys of the attached-file answer are named where lint:i18n-keys sees them
The server built the answer through words.text(), words.count() and maps of
plain strings, so the check read all 13 assistant.attach keys as unused. The
helper's lookup is t() now (a numeric count picks the plural form, as the
client's does) and the chip and reason maps hold their keys under *Key
properties.
2026-10-24 22:32:48 +02:00
Maurice f1a2174f96 chore(assistant): the attachment work exports only what other files use
Types that only their own file reads lose their export, so knip's
unused-exports ratchet does not grow with the attachment work.
2026-10-24 22:32:48 +02:00
Maurice 89c33ac3f4 feat(assistant): an attached file can be kept in Files, on its own or with a booking or an expense
The chips offered to keep an attached file in Files, but nothing could do it:
the assistant's upload_trip_file stays denied, and the file never reached
Files. A new server write, keep_attached_file, is offered to the model where
the thread holds an attachment and held as a change card naming the file and
the booking, expense or place it goes with.

Applied, it runs the checks of a Files upload with the user's rights (the
demo block, the trip, file_upload and file_edit for a link, the instance's
file types, every linked id on the same trip), copies the bytes to a trip
file of their own, and writes the trip file, its expense link and the
attachment's pointer to it in one transaction; the members' planners get
file:created after it. A second keep of the same attachment answers the file
kept the first time. FilesService gains storeBytes and discardBytes so the
storage write stays out of that transaction.

The receipt opens the Files tab; the card's verb, its "Attached to" row and
the receipt's open label are new keys in all 27 locales. Undo is not offered:
no tool takes a trip file back yet.
2026-10-24 22:32:48 +02:00
Maurice aeda658f18 feat(assistant): the model reads an attached file once the user agrees
The assistant could not read a file the user attached: the bytes were gone
after the request, the thread only held the file's name, and read_trip_file
stays denied because it would send any document of the trip to the provider.

A new server tool, read_attached_file, reads only the files attached in this
conversation by this user; any other id is answered "No such attachment in
this conversation.", whoever it belongs to. It is offered in a trip turn whose
thread holds a file with text. The first read of a file is held as a change
card that names the file and the model and host its text would go to; once
the user agrees, the agreement is kept on the attachment and later reads run
at once. The text reaches the model through the history and, for a later
read, as a tool answer only the model gets (AgentToolRun.modelText): no message
part, stream chunk, shared answer or client store ever carries it.

The model is told each kept file's attachment id, and the standing
instructions say to read the file when the user wants to work with it, to
propose a booking found in it through create_reservation and then to offer
keeping the file with it. The card's verb, its "Sent to" row and the step's
name are new keys in all 27 locales.
2026-10-24 22:32:47 +02:00
Maurice 2f3a0219cd feat(assistant): every attached booking file gets an answer with what to do next
A booking file without a booking ended the answer on the error "No booking
found in this file." with no action, and the user was left to type. Now the
server answers every booking file itself, without a model: one line that
names the file and what TREK found in it, or that it found nothing, why, and
asks what to do with the file. Under it come up to three chips, picked from
what the user may do on the trip and what the file allows: keep it in Files,
make a booking from its text, attach it to a booking or an expense, ask what
is in it, or keep it with the booking just found. A chip sends its words as
an ordinary chat message.

The line and the chips are written in the language of the request from new
assistant.attach.* keys in all 27 locales, through a small helper the sports
proposals now share. The empty answer finishes as stop; no_bookings_found
stays in the contract for the threads older builds wrote. Route files keep
their answer as it was.
2026-10-24 22:32:47 +02:00
Maurice 41fb5759e2 feat(assistant): an attached file without a booking says why
The chat's booking reader kept only the bookings of the import preview and
dropped its warnings, so "AI reading is not set up", "the AI failed",
"a scan without text" and "nothing in it" all came out as the same
no_bookings_found. The reader now reads the reason from the preview (the mode
it ran in and the AI fallback's warnings) and keeps it in the stored read
step, and the note the model is sent names it.
2026-10-24 22:32:47 +02:00
Maurice 6a77496438 feat(assistant): an attached file stays with its conversation
A file attached in the trip chat lived only as a buffer for the one request
that read it, so the assistant could not look at it again and nothing could
keep it in Files. Now its bytes go to storage before the turn writes anything
(under the trip files' category, which is never served by path) and a row in
the new assistant_attachments table keeps its name, type, size, hash and the
text TREK read out of it, written in the turn's opening transaction. The
message's attachment part carries the row's id; old messages without one still
read.

The text is read the way the AI import reads it, through a new public
LlmParseService.readText, and a file whose text cannot be read is kept all the
same. A storage that refuses the bytes refuses the turn before any row exists.
Deleting a thread takes its files along, and the nightly retention sweep drops
files older than seven days and bytes no row names any more.
2026-10-24 22:32:47 +02:00
Maurice 487f8dd886 refactor(assistant): the answer to an attached file is a provider of its own
AssistantService sat three lines under the size limit, and the attachment
work ahead grows exactly this answer. Moving it into AssistantFileAnswers
leaves the rows and their transactions with the service, which hands the
answer the two ways it writes its first row. Nothing the user sees changes.
2026-10-24 22:32:47 +02:00
Maurice 09f286fbfe docs(readme): the feature list as 5.0 has it
Every count checked against the code (410 MCP tools, 35 scopes in 17 groups,
18 booking types, 17 trip permissions, 66 plugin permissions), and the parts
5.0 adds get their own place: Tours, TREK Studio, the AI assistant, the help
center, Road trip, Dawarich and the linked journal. Most lines are shorter.
2026-10-24 22:30:55 +02:00
Maurice 4cc62f4c4e test(journey): the design_book request in the MCP test takes the journey beside the parsed directives 2026-10-24 20:58:49 +02:00
Maurice cb34c46226 fix(studio): CI on the templated auto layout
The rhythm test holds the picker to what it does now: it gives the rhythm up
only where no template of the book's styles frames every photo and keeps it.
A design applied over a stored book keeps that book's theme, as the merge has
always done, so the end to end test checks the pages instead. The MCP test
builds its request through the shared schema, a travel mark's frame is read
with its sides spelled out for the client's looser types, and the client's
react-hooks/refs baseline drops to the 555 it holds now.
2026-10-24 20:54:16 +02:00
Maurice e777118272 feat(studio): auto layout lists bare stops on a words-only template of the book's style
Stops nobody wrote about or photographed were set as a small list on a plain
spread, the old composed page. They now take a words-only template of the
book's styles: the list's label as its title, the stops as its story grouped
by day, as many as its story frame holds, and the day in the template's date
where all of them share one. The words go to their roles by name, since a
short list that opens on a date would otherwise read as a date line. The
composed list stays as the fallback where no template takes it.
2026-10-24 20:28:37 +02:00
Maurice 3f2da7a867 test(studio): repin the templated parity digests on top of the design figure and riso cover fixes 2026-10-24 20:14:06 +02:00
Maurice 9c3649f011 fix(studio): auto layout keeps every page in the book's own styles and every photo in a frame
The picker scored all 108 templates at once, so a page whose photos the own
pool could not frame borrowed a template of another style (a night film page
in a poster pop book). It now goes in tiers: the theme's archetype and its kin
with a frame for every photo, then the same with the rhythm given up for that
page, then another archetype, and only then a photo left in the tray. The
photos of a chapter are cut into the counts the own pool has, and only where
those leave a photo over into counts another archetype has, at a price. Riso
joins poster pop's kin for its one photo story page.

Across the 17 themes and the nine fixture journeys no photo waits in the tray,
no page is laid in another style and no page has a severe finding.
2026-10-24 20:13:44 +02:00
Maurice 73fdbdc0c3 feat(studio): Auto layout lays the book on the designed pages
"The whole book" now lays the book on the designed templates through
autoDesignedBook, in the book's own page format and theme (a recoloured
one of its own included), its title kept and every page holding a locked
element kept; each press takes a new seed, so a second press gives another
book, one undo away as before. "This spread" lays the page on another
design than the one on screen and its neighbours, its id kept, and now
works on the cover, the summary and the countries too, so the menu offers
it wherever the auto layout knows the page. Loose gallery photos reach the
layout with the day they were taken, which it shares them out by. The
menu's two hints say what the button now does, in every language. Nothing
here places the classic summary, countries or marks any more.
2026-10-24 20:13:44 +02:00
Maurice 0879b5aecc feat(shared): the Studio's auto layout entry points
auto/auto-designed.ts holds what the Studio's Auto layout menu calls.
autoDesignedBook lays the whole book on the designed templates in the
book's own theme object (a recoloured one of its own included, the calm
fabric-classic theme for a book without one), over the stored book, its
page format kept and every page holding a locked element kept; another
seed lays another book. autoRelayoutSpread lays one page again around its
locks, its id kept: an entry's page on the best template other than the
one on screen and its neighbours', avoiding one that would cut its story
where it can; the cover on the next cover template of its archetype and
then of its kin; the summary and the countries in their next look, an old
classic summary included. Pages the engine did not make are left alone,
as before. autoRelayoutKind says which pages those are.

designBook's templated path is split so the relayout reads the same
setting (templatedSetting) as the whole book, and the page layer is
shared (layEntryPage); the parity digests are unchanged by it. buildBook
and relayoutSpread stay exported and unchanged.
2026-10-24 20:13:44 +02:00
Maurice 9729b0b575 feat(server): design_book offers the designed templates
The design_book tool says in one sentence that layouts: 'templates' lays
every page on the Studio's designed templates in the archetype of the
book's theme, as the Studio's Auto layout does, and that each plan row then
names its template; style classic alone stays the old built-in Auto
layout. The field comes from the directives contract in @trek/shared, so
the tool's schema takes it without a zod line of its own, and the answer's
plan row gains an optional template. The REST draft routes take no
directives, so nothing changes there. Without the field the answer is the
same as before, no row naming a template.
2026-10-24 20:13:43 +02:00
Maurice 35acdb8ba1 feat(shared): design_book can lay the book on the designed templates
A new optional directive, layouts: 'templates', lays every entry on the
decorated spreads of the book theme's archetype and its kin
(design-templated.ts). An entry's photos are cut into page-sized parts that
leave no frame empty, at most twelve of them, the first part carrying its
story; each part takes the template that suits it best, poured from a page
of the entry's photos and story so the title, place, date and story stay
bound to the journal. A story longer than its frame is cut at a sentence
and goes on over a page of words. Date marks are set as stamps, never in
the classic styles. Loose gallery photos go to the entry they are linked
to, then to the entry of their day, then to the entry with the fewest
(shareGalleryByDay, beside distributeGallery, which stays as it is); what
no entry has room for closes the book on up to three pages of more
moments, and anything past that is said to be left out, so no photo is
dropped without a word.

The rest is the composer's: scope, the photo picker, the page budget, the
stored pages kept, repair and findings. A book can bring its own theme
object (BookDesignInput.theme), recoloured or not, and it is kept whole.
Plan rows name the template a page was laid on. Without the directive
every design is composed exactly as before, and classic is still buildBook.

AUTODSN-001..015 cover the rules, and AUTODSN-020 pins a digest of every
page on the auto layout's parity journeys and a fourth one in three themes.
design-templated.parity.json was computed with the spec's own canonical form
and digest over these inputs, run through tsx; CI checks it from here on.
2026-10-24 20:13:43 +02:00
Maurice a19fdf6161 feat(shared): framing pages from the new travel looks
design-templated-framing.ts builds the pages around the chapters of a book
laid on the designed templates. The cover is laid on one of the theme
archetype's cover templates, poured from a page whose title and subtitle
are bound to the journey, so the binding survives; an archetype with no
cover for the photos there are has its cover composed as before. The
journey at a glance is its map with a photo at every stop beside the
figures as a poster (tiles for the collage and grid books, a sign for the
postcard ones), a ticket stub of how far and how long, and the furthest
point on an odometer. The countries are one country's map cut to its shape
beside its stamp, a card each for up to twelve, one panel of stamps past
that. The back cover adds a band of days, places and photos and a row of
country stamps. A page of words, where no template takes a story, sets it
beside the stop's map, its day as a stamp and its country as a tag. None of
these sets a summary, a country or a mark in the classic look.
2026-10-24 20:13:43 +02:00
Maurice 813dd7a7eb feat(shared): pick a decorated spread for a page's photos and words
design-template-pick.ts says which decorated template a page of the auto
layout is laid on. Every built-in theme belongs to an archetype
(THEME_ARCHETYPE, the five new themes to their own five), and a book picks
from that archetype's spreads and from those of the archetypes that sit
well beside it (ARCHETYPE_KIN). A template that would leave a photo frame
empty, lose the story, draw a map with nothing to draw, repeat the page
before or follow a busy page loudly is never a candidate; of the rest the
best fit wins: the photos all used, the theme's own archetype, the story
fitting its frame (bodyCapacity measures each frame once per format and
face), the first frame the shape of the first photo, not used a few pages
back, and calm where the book should breathe. Equal ones are chosen by the
design's seed. chunkPhotos cuts an entry's photos into page-sized parts
that leave no frame empty, and pickCoverTemplate does the same for the
cover.
2026-10-24 20:13:43 +02:00
Maurice d51b9de2b0 feat(shared): a laid page knows which decorated template it is
matchDecoratedTemplate reads a page's elements by kind and frame, rounded
to the millimetre, and compares them with every template laid on the same
page format: a template is the page's when nearly all of its design is
there and it explains most of the page, and of those the one explaining the
most wins. A page rearranged by hand reads as none. The auto layout's
"this spread" uses it to offer a template other than the one on screen.
2026-10-24 20:13:43 +02:00
Maurice 6254510e0f refactor(shared): the travel looks are built in shared
The factories behind the Travel panel's cards (a summary as a poster, a
ticket or a sign, a figure on a medal or an odometer, a map of the journey
or of one country, a mark as a stamp or a tag, a country as a stamp, a visa
or a postcard, each dressed in the book's theme) move to
book/travel/book-travel-looks.ts, so the auto layout can build its framing
pages from the same looks. Where an element lands stays the caller's: the
panel still centres it on the spread on screen, through the same wrapper and
the same API, and a designed page names its frame. A mark can now also take
an entry's value rather than the journey's.
2026-10-24 20:13:43 +02:00
Maurice c525b2f84c fix(studio): the riso cover's year band deepens a mid-tone ink so the year reads
In a theme whose second ink is a mid tone (riviera's olive), neither white nor
near black reached 4.5:1 on the year's band. The band is taken towards the dark
until the year reads, as the other covers do for small words.
2026-10-24 20:13:42 +02:00
Maurice b3d30a834e test(studio): pin which texts count as a template's figures 2026-10-24 20:13:42 +02:00
Maurice 589d3354eb fix(studio): a template's figures stay its design, never the next template's date line
Figures a template draws as design, a film's timecode or a frame number, came
back as the page's words when another template was laid on the page, and took
its date line. Texts with no letter that are not a date and are bound to
nothing are design now, and the new templates spell their plate numbers and
REC marks so they read as such.
2026-10-24 20:13:41 +02:00
Maurice 8ce3b24a01 feat(studio): 71 more designed pages and five new styles, each with a theme of its own
Every style now has four covers and five double spreads, 108 in all, and the
spreads of each style take different photo counts, from a words-only page to
eight photos, so a book can be laid on them without an empty frame. The five
new styles come with their own themes: Riso (overprinted spot inks), Magazine
(a travel magazine), Retro travel (vintage posters), Midnight (a dark film
look) and Field notes (a traveller's notebook). Each new style has a cover
composer for the design engine and a recipe for the assistant, and its chip,
theme and template names are translated into every language.
2026-10-24 20:13:41 +02:00
Maurice 06a1dcd730 fix(journey): the share link's copy button and its switches show their words again
The filled buttons set their label in text-accent-on, the accent used as
text on a plain surface, which on the accent's own ground is the same
colour: a black button with black words. They take text-accent-text, the
colour made for words on the accent, and a test pins it.
2026-10-24 18:28:47 +02:00
Maurice 4b633d8cd2 feat(admin): release notes show a #### heading as the feature it names
The release notes in Admin read ## as the version and ### as a section,
and printed a #### line as plain text. A release that heads each feature
inside a section with #### (Tours, TREK Studio under Added) now shows it
as a feature heading between the section and its text, on desktop and on
the phone.
2026-10-24 18:28:44 +02:00
Maurice 728a275fef chore(assistant): drop Connect with Claude from the integration settings
The card that opened claude.ai's custom connector dialog for this instance
goes, with its phone twin, the line in the assistant setup that jumped to it,
its endpoint and its contract. Anyone who wants Claude uses TREK's MCP
server under Integrations, or an Anthropic key in the assistant. It came with
the assistant and never shipped in a release.
2026-10-24 18:28:41 +02:00
Maurice f6befaa6b2 fix(vacay): the opened year's broadcast skips the socket the change came from, like every other
The app only sets days in the years it shows, so the tab that made the change
never needs the new year; the assistant, MCP and plugins send no socket and
reach everyone.
2026-10-24 18:28:38 +02:00
Maurice fd6d3fddd5 fix(vacay): a year a day opens is announced after the day is committed
Opening the year sent vacay:settings from inside the day's transaction, so
a client could reload the years before they were there. The rows go in
with the day, and the broadcast follows the commit. The helpers file is
formatted the way the server's Prettier wants it.
2026-10-24 18:07:02 +02:00
Maurice 280e55bbc9 test(vacay): a day in a year the plan has not opened also announces the new year
The broadcast parity cases toggle a day in 2027, which the seeded plan does
not hold, so both MCP and REST now send vacay:settings for the opened year
before vacay:update.
2026-10-24 17:31:47 +02:00
Maurice e84060cdf7 refactor(vacay): the pure constants live in the helpers, so the service keeps its size budget
The holiday feeds' limits and patterns and the colour palette move to
vacay.helpers.ts, which holds the vacay domain's pure half already. That
keeps the service under its line budget after the missing-year fix.
2026-10-24 17:31:47 +02:00
Maurice fbc85b05a1 fix(vacay): a day set for a year the plan has not opened adds that year
The calendar offers only the plan's years, but a vacation day or company
holiday could be written for any date. A day set months ahead, as the
assistant did for 6 January 2027, was stored and counted yet never shown,
because 2027 was not a year of the plan. Writing a day now adds its year,
with every member's allowance, the way opening it by hand does; the year
is the writer's leave period that holds the date. This holds for the app,
MCP, plugins and the assistant alike, since they all write through here.
2026-10-24 17:31:47 +02:00
Maurice 01333c6ff3 chore(client): hold the complexity count at the notifications redesign's lower one 2026-10-24 17:31:46 +02:00
Maurice aecef03090 feat(notifications): a tile per kind, the list cut into days, and clear actions
Every notification gets a tile in the colour of what it is about (a trip, a
message, a booking, packing, a to-do, Vacay, a collection, shared photos, a
tour, an update or an alert) with the sender's face on its corner, the page
and the bell's panel group the list under Today, Yesterday, the weekday or
the date, and answering or following a notification is a proper button.
The server's UTC timestamps are read as UTC for the grouping and the time.
2026-10-24 17:31:46 +02:00
Maurice 8dacd0a50c fix(book): the auto layout's drawn templates no longer carry photos of the book they were drawn in
Three photo frames in the templates kept the photo ids of the journey the
templates were read out of (68 and 69). Only empty frames take an entry's
photos, so every book laid out on those templates pointed at those ids: a
broken picture on most installs, and on one where the id exists, someone
else's photo or an unrelated one of the user's own. The frames are empty
now and take the entry's photos like the others. The parity digests of the
five spreads that use them are written anew, as the spec asks for a
deliberate layout change.
2026-10-24 16:59:53 +02:00
Maurice 3c73da4427 fix(assistant): bring the end of a long answer into view once it is written
A new question is held at the top of the view, so its answer reads from its
first line. When the answer carries steps, a strip of places and cards, its
closing words and the changes to apply ended below the view, and the user
saw only the places found. Once the answer is written, the thread now
scrolls to where those closing blocks begin, or to the bottom when they are
shorter than the view, unless the user scrolled it by hand meanwhile.
2026-10-24 16:33:14 +02:00
Maurice cff2e55db7 feat(help): Studio's help as Studio is now, with guides for themes, stickers, travel, text effects, backgrounds, layers and own layouts
The screen texts name the top bar, the rail and the workbench as they are
after the redesign, the travel guide walks through the four tabs, and six
new guides cover what Studio gained since its help was written. Every
Studio picture is taken again, and all texts are translated.
2026-10-24 15:38:36 +02:00
Maurice 429a25faa9 fix(admin): name the addon columns by start and end 2026-10-24 15:10:13 +02:00
Maurice 486a2eeca3 feat(admin): addons tab in the cards of the other tabs, with a search and an on/off filter
The addons sit in a card per type now, the way every other admin tab sets
its cards, instead of three columns nested in one card. A head card shows
how many of each type are on and finds an addon by its name or description,
with a switch between all, the ones on and the ones off.
2026-10-24 15:10:13 +02:00
Maurice 26a6147e8d fix(book): a summary's and a country's look are optional, so a stored book reads and saves as it did
Absent means the classic look. Defaulting it wrote the field into every
element the auto layout and the templates build, which moved their parity
fixtures and grew the page tools past their schema ceiling; the new
descriptions are also shorter for the same reason.
2026-10-24 14:59:00 +02:00
Maurice a7b53988ff feat(studio): rethink the travel tab with many new looks, a saved tile and a newer-book overlay
The travel tab is four tabs now, figures, maps, countries and marks, and
offers only the newer looks, each dressed in the book's theme and linked to
it. The looks books already hold still draw on their pages exactly as
before; they are just no longer offered.

- Figures: a summary as a poster, a ticket, tiles, a road sign, an
  odometer, a handwritten note, a pencil sketch or a band, and each figure
  on its own as a medal, on odometer drums or ringed by hand.
- Maps: the route with its photographs, its stops numbered, its two ends
  marked or on a dark ground, over satellite or relief imagery, a map per
  trip, and a map of each country with its stops alone, cut to its shape.
- Countries: each country as a passport stamp, a visa, an airmail
  postcard, its flag in cloth, a map pin, a luggage label or a sketch.
- Marks: the date, the day, the distance, the coordinates and the flag as
  stamps, tags, pills, ribbons, die-cut stickers, embroidered patches and
  sketches.

The schema gains a summary's and a country's look, seven mark styles and
the numbered and ends stops of a map, all additive, as revision 11.

Also in Studio: the save state is a tile beside who is here, a book a newer
Studio saved is said by an overlay that holds the whole Studio until it
reloads, and the page rail draws the first and the last page beside the
blank inside of their cover. The notifications page and the bell's panel
take the dialogs' look: a head with a tile, cards with an unread dot, and
tools that come forward on hover.
2026-10-24 14:45:04 +02:00
Maurice 3f3e2f0bcf chore(client): hold the refs count and the stylesheet size at what they are 2026-10-24 14:06:40 +02:00
Maurice f956ab07f3 fix(studio): tidy the head row, the layouts tab and the theme panel
Every piece of the head row is one raised tile of one height: the format
picker moved beside the tools, the assistant and help stand alone, the
close button is square like the rest, and the beta mark is gone. The
layouts tab lays the book's own and the built-in layouts out in sections
and in the same grid as the designed pages. In the theme panel the accent
dots sit in the sheet's corner, the three accents share one name, a font
keeps its name ahead of its kind, a doubled rule is gone, and the
inspector's empty hint is shorter.
2026-10-24 13:54:49 +02:00
Maurice 736594696e fix(tours): one summit per mountain in the places along a tour
The index holds a mountain as many pins named by whoever listed it, at its
foot as much as on top, so a hike up Mt. Takao listed it five times under
names like Mt.Takao, 東京都 高尾山 and 高尾山 (Mt. Takao). Rows that are one
place now merge as a group: like names in one spot for every kind, and for a
summit of the index like names anywhere on the mountain, linked through a
name that carries both scripts. The row kept is an OpenStreetMap object over
an index pin, then the plainest name. Two OpenStreetMap summits stay two.
2026-10-24 13:54:48 +02:00
438 changed files with 40940 additions and 3024 deletions
+97 -67
View File
@@ -62,7 +62,7 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
<br />
Most of what follows is an addon an admin switches on or off. Lists, Costs, Documents, Collab, Vacay and Atlas ship on; Journey, Collections, MCP, AI Parsing and AirTrail ship off and are marked below.
Most of what follows is an addon an admin switches on or off. Lists, Costs, Documents, Collab, Tours, Vacay and Atlas ship on; Journey, Collections, MCP, the AI assistant, AI Parsing, Road trip, Dawarich and AirTrail ship off and are marked below.
<table>
<tr>
@@ -70,76 +70,110 @@ Most of what follows is an addon an admin switches on or off. Lists, Costs, Docu
#### 🧭 Planning
- **Day plans**: drag places between days and reorder inside a day, with undo. Notes and bookings drag the same way, and a map marker drops straight onto a day
- **Maps**: Leaflet, Mapbox GL or MapLibre GL (OpenFreeMap, no token), with clustering, photo markers and route lines. 3D buildings and terrain are Mapbox only
- **Place search**: Google Places when a key is set (photos, ratings, opening hours), otherwise OpenStreetMap with no key
- **Place enrichment**: descriptions, facts, hours and photo candidates from OpenStreetMap, Wikipedia, Wikidata and Wikimedia Commons
- **POI explore**: pull OpenStreetMap POIs by category for the current viewport over Overpass
- **Import**: shared Google Maps and Naver Maps lists, plus GPX, KML and KMZ files
- **Export**: GPX of a trip's places and tracks, and an ICS feed per trip or across all of them
- **Routes**: auto-sort a day (nearest neighbour then 2-opt, locked stops and hotel anchors stay put), driving, walking or cycling profiles over OSRM, then open it in Google Maps or CoMaps
- **Public transport**: door-to-door itineraries over Transitous
- **Weather**: 16-day forecast from Open-Meteo, no key. Dates outside that window read the archive for the same date instead
- **Day notes**: markdown body with an icon and a colour, reordered by drag and drop or moved to another day
- **Trip dates**: move a trip and the days re-date themselves, either dragging the bookings along or re-anchoring them. Trips also copy and archive
- **Day plans**: drag places, notes and bookings between days with undo, or drop a map marker straight onto a day
- **Maps**: Leaflet, Mapbox GL or MapLibre GL (OpenFreeMap, no token) with clustering, photo markers, route lines and a whole-trip route overview
- **Places**: search the TREK Places index and OpenStreetMap with no key, or Google Places and Amap with one. Descriptions, hours and photos from Wikipedia, Wikidata and Wikimedia Commons, plus a rating and a custom image per place
- **Explore**: nearby places by category for the map view, from the TREK Places index with Overpass as fallback
- **Routes**: auto-sort a day (nearest neighbour then 2-opt, locked stops and hotels stay put), a travel mode per leg over OSRM or Valhalla, then open the day in Google Maps or CoMaps and a place in seven navigation apps
- **Public transport**: door-to-door itineraries over Transitous, or Google where Transitous has no data
- **Import and export**: Google Maps and Naver Maps lists, Google Maps directions links, GPX, KML and KMZ in; GPX and an ICS feed per trip or across all trips out
- **Weather**: 16-day forecast from Open-Meteo, no key, and the archive for dates beyond it
- **Trip dates**: move a trip and its days re-date themselves, with the bookings along or re-anchored. Trips also copy and archive
- **Road trip** (off by default): drives with stops, day windows and arrival times that recompute, route alternatives, hazards and EV charging with range
</td>
<td width="50%" valign="top">
#### 🧳 Bookings and money
#### 🥾 Tours
- **Reservations**: 16 booking types with status, confirmation code, travellers and attached files
- **Flights and trains**: several legs with stopovers, per-leg times and endpoint timezones, against 4,045 bundled airports so local times resolve without a key
- **Accommodation**: a stay spans a range of days with a check-in window, and shows on every night it covers
- **Booking import**: EML, PDF, PKPass, HTML and TXT confirmations through [KItinerary](https://invent.kde.org/pim/kitinerary). Needs the `kitinerary-extractor` binary, which ships in the Docker image
- **AirTrail** (off by default): link a self-hosted AirTrail instance to import flights as reservations and keep them in sync
- **Costs**: split expenses in integer cents with equal or custom shares, several payers per expense, settle-up suggestions, a settlement log, and CSV export
- **Currencies**: a currency per expense with the rate frozen at entry. Rates come from Frankfurter, no key
- **Packing lists**: categories, admin-managed templates, assignees, three visibility tiers, and a packed-of-total line. Bags with weight roll-up are a separate admin switch, off by default
- **To-dos**: assignee, due date, priority, and a reminder before one falls due
- **Files**: attach to a trip, place, day entry or reservation. 50 MB each, 500 MB for video, with trash and restore
- **PDF export**: cover page, place photos, day notes, bookings and costs, with an optional page break per day
- **Seven tour types**: hike, city walk, run, bike (touring, road, gravel, MTB), paddle (kayak, canoe, SUP), horse ride, and ski and winter (alpine, cross-country, touring, snowshoe, winter hike)
- **Routing**: legs routed over Valhalla with shaping points, hand-drawn legs, route variants, round hikes of a wished length and trip places as stops, all with undo
- **Hikes**: routed within a maximum SAC grade (T1 to T6), the trail grades along the way, and via ferratas on the map
- **Terrain**: elevation profile, ascent and descent, and a 3D terrain map with grade colouring and a flyover, no token needed. Topo, hiking, cycling and satellite bases with sea marks and ski pistes as overlays
- **Multi-day tours**: one route cut into stages and spread across the trip's days
- **Along the way**: viewpoints, huts, water, food and more within a corridor of the route, inserted as a stop with the detour it adds
- **Ski, paddle and city walks**: a piste router by difficulty and lift type; a water router with portages, locks and a wind forecast; city walks with stays, opening hours and transit between stops
- **Plan against reality**: link a recorded GPX to a planned day and compare distance, ascent and time. Import trails from a wanderer instance, save tours to a collection and take them into another trip
</td>
</tr>
<tr>
<td width="50%" valign="top">
#### 🧳 Bookings and money
- **Reservations**: 18 booking types with status, confirmation code, travellers and attached files
- **Flights and trains**: several legs with stopovers and endpoint timezones, against 4,045 bundled airports so local times resolve without a key
- **Accommodation**: a stay spans a range of nights with a check-in window
- **Booking import**: EML, PDF, PKPass, HTML, TXT and photos of confirmations, through [KItinerary](https://invent.kde.org/pim/kitinerary) or an AI model, with a review step before anything is saved. Prices become linked costs
- **AirTrail** (off by default): import flights from a self-hosted AirTrail instance and keep them in sync
- **Costs**: split in integer cents with equal or custom shares, several payers, settle-up suggestions, a per-person summary, a settlement log and CSV export. A receipt photo can fill in an expense
- **Currencies**: one per expense with the rate frozen at entry, from Frankfurter with no key
- **Packing lists and to-dos**: categories, templates, assignees and visibility tiers; bags with weights as an admin switch; to-dos with due dates, priorities and reminders
- **Files**: attach to a trip, place, day or booking, 50 MB each and 500 MB for video, with trash and restore
- **PDF export**: cover, place photos, day notes, bookings and costs, with an optional page break per day
</td>
<td width="50%" valign="top">
#### 👥 Collaboration
- **Real-time sync** (WebSocket): edits land live for everyone who has that trip open
- **Members**: add by email or username, hand ownership to someone else, or add guests who have no login at all
- **Permissions**: an admin maps each of 16 trip actions to admin, trip owner, trip member or everybody
- **Invite links**: one reusable link per trip with an optional expiry. Admins can also issue signup invites with a use limit that drop the new account straight into a trip
- **Public share**: a read-only trip page anyone can open without an account
- **Collab**: group chat with replies, reactions and link previews, shared notes with attachments, polls, and a What's Next list of upcoming activities. Each of the four switches on its own
- **Real-time sync** (WebSocket): edits land live for everyone who has the trip open
- **Members**: add by email or username, hand over ownership, or add guests who have no login at all
- **Permissions**: an admin maps each of 17 trip actions to admin, trip owner, trip member or everybody
- **Invites**: one reusable link per trip with an optional expiry, and admin signup invites that drop a new account straight into a trip
- **Public share**: a read-only trip page without an account, with switches for map, bookings, packing, costs and chat
- **Collab**: group chat with replies, reactions and link previews, shared notes, links, polls and a What's Next list, each one its own switch
- **Notifications**: an inbox grouped by day with answers right from the notification, and a per-user matrix of events against in-app, email, webhook, ntfy, Web Push and plugin channels
#### 📔 Journal, Atlas and Vacay
</td>
</tr>
<tr>
<td width="50%" valign="top">
- **Journey** (off by default): dated entries with story, mood, weather and tags, photos and video from uploads or a linked [Immich](https://immich.app) or Synology Photos library, map views, co-authors, and a public share link
- **Atlas**: mark countries and sub-national regions visited on [geoBoundaries](https://www.geoboundaries.org/) outlines, plus a bucket list, travel stats and a consecutive-years-travelled count
- **Vacay**: leave calendar with half days, public holidays from date.nager.at, school holiday overlays for 16 European countries, carry-over between years, and joint or read-only sharing of a plan
- **Collections** (off by default): a place library outside any trip, with labels, ratings, a custom image per place, copy-into-a-trip, and sharing by invite
#### 📔 Journey and TREK Studio
- **Journey** (off by default): dated entries with story, mood, weather and tags, photos and video from uploads, [Immich](https://immich.app) or Synology Photos, map views, co-authors, drafts and a public share link
- **Linked journal**: a trip can show one journal as its own, with the members picked on the trip as viewers or editors
- **Recorded days** (with Dawarich): keep a day's recorded route and turn its stays into entries
- **TREK Studio**: turn a journal into a printable photo book. 34 print-licensed typefaces, 17 themes, 108 designed pages in twelve styles, 160 recolourable stickers, text effects, layers and a Travel tab with route maps, country stamps and trip figures
- **Auto layout**: lays the whole book on the designed pages in the style of its theme, every photo in a frame, or rebuilds one spread
- **Print and share**: PDF export as pages or spreads with crop marks, several people editing at once, and design files to move a spread into another book
</td>
<td width="50%" valign="top">
#### 🗺️ Atlas, Vacay and Collections
- **Atlas**: countries and regions visited on [geoBoundaries](https://www.geoboundaries.org/) outlines, a bucket list, travel stats, and a tour activities tab with planned against recorded totals
- **Vacay**: leave calendar with half days, public holidays from date.nager.at, school holidays for 16 European countries, company holidays, carry-over between years, and joint or read-only sharing
- **Collections** (off by default): a place library outside any trip with lists, labels, ratings, a status per place, cover images, GPX and file export, copy into a trip, and sharing by invite
- **Dawarich** (off by default): link a Dawarich instance for recorded tracks, suggested stays and countries for the Atlas
</td>
</tr>
<tr>
<td width="50%" valign="top">
#### 🤖 AI assistant and MCP
- **AI assistant** (off by default): a chat that plans with you in a trip, across all your trips, in a journal and in Studio, and works with tours, costs, bookings, packing, Vacay and the Atlas
- **Your own model**: an API key for Anthropic, OpenAI, Gemini or OpenRouter, a ChatGPT plan, or any OpenAI-compatible endpoint, set for the instance or per user
- **You stay in charge**: reads run at once, every change waits as a card to apply or discard and can be undone for 15 minutes. Nothing leaves before you agree to the provider, and a file's text only with your consent
- **Attachments**: drop a booking confirmation, a GPX file or a photo into the chat, and the assistant offers what to do with it
- **MCP server** (off by default): OAuth 2.1 with mandatory PKCE and dynamic client registration, 410 tools, 30 resources and 4 prompts, 35 scopes in 17 groups ticked on the consent screen, and every call in the audit log
- **Addon-aware**: twelve addon gates decide which tools a session sees, and flipping one re-registers live sessions
</td>
<td width="50%" valign="top">
#### 🧩 Plugins
- **Third-party plugins**: install from the TREK registry or sideload a zip, switch on per instance, with their own pages under `/plugins/<id>`
- **Sandboxed**: one child process per plugin, 63 grantable permissions, an admin-edited outbound host allowlist, memory and RPC caps, and daily caps on AI and notification calls
- **Trust**: registry downloads are pinned by sha256 and checked against the author's minisign key. A sideloaded zip is marked unverified, and `TREK_PLUGINS_ENABLED=false` turns the whole system off
- **Extension points**: map markers and layers, place details, day schedules, PDF sections, Atlas layers, journal rows, trip warnings, calendar sources, route profiles and notification channels. Plugin pages run in an opaque-origin iframe
- **SDK**: [`trek-plugin-sdk`](https://www.npmjs.com/package/trek-plugin-sdk) on npm, with a manifest validator, a mock host, and a dev-link mode that runs a local build against real data
#### 🤖 AI and MCP
- **MCP server** (off by default): OAuth 2.1 with mandatory PKCE and dynamic client registration. 199 tools, 30 resources, 4 prompts
- **Scopes**: 29 scopes in 14 groups, each one tickable on the consent screen. Tokens are bound to the `/mcp` resource, and every tool call lands in the audit log
- **Reach**: create trips and days, edit places, packing lists, to-dos, costs, reservations, collections and journeys, mark countries visited, all inside the scopes the token holds
- **Prompts**: `trip-summary`, plus `packing-list` and `budget-overview` when those addons are on
- **Addon-aware**: seven addon gates decide which tools and resources a session sees. Flipping one drops live sessions so the surface re-registers
- **Booking extraction** (off by default): read a confirmation with a local Ollama server, any OpenAI-compatible endpoint, or Anthropic, configured instance-wide or per user
- **Third-party plugins**: install from the TREK registry or sideload a zip, with their own pages under `/plugins/<id>`
- **Sandboxed**: one child process per plugin, 66 grantable permissions, an admin-edited outbound host allowlist, memory and call caps
- **Trust**: registry downloads are pinned by sha256 and checked against the author's minisign key, a sideloaded zip is marked unverified, and `TREK_PLUGINS_ENABLED=false` turns the whole system off
- **Extension points**: map layers, place details, day schedules, PDF sections, Atlas layers, journal rows, trip warnings, search and photo providers, route profiles, notification channels and MCP tools
- **SDK**: [`trek-plugin-sdk`](https://www.npmjs.com/package/trek-plugin-sdk) with a CLI to create, validate, run against real data, sign and publish
</td>
</tr>
@@ -148,30 +182,26 @@ Most of what follows is an addon an admin switches on or off. Lists, Costs, Docu
#### 📱 Mobile and offline
- **Installable**: iOS and Android straight from the browser, no App Store. Runs standalone without browser chrome, with the status bar tinted per theme
- **Phone layouts**: a separate shell below 768px with its own tokens, a bottom bar and safe-area insets. Which items sit in that bar is up to the user
- **Offline reads**: the app shell and every route chunk are precached, and trips, places and file blobs live in a per-user IndexedDB store, so a trip opens with no network
- **Offline writes**: mutations queue and replay with an `X-Idempotency-Key`, so a reconnect cannot double-apply. A stale edit is parked for a keep-mine or keep-theirs choice
- **Offline maps**: pre-download a trip's raster tiles, and clear them again
- **API responses are never cached** by the service worker. They vary per session, so they always come from the network
- **Installable**: iOS and Android straight from the browser, no App Store, standalone with a themed status bar
- **Phone layouts**: their own screens for every part of the app, with a bottom bar and dashboard widgets the user orders
- **Offline reads**: the app shell is precached and trips, places, tours, files and nearby places live in a per-user IndexedDB store, so a trip opens with no network
- **Offline writes**: changes queue and replay with an `X-Idempotency-Key`, and a stale edit waits for a keep-mine or keep-theirs choice
- **Offline maps**: raster and vector tiles of a trip downloaded ahead, and a switch that takes the whole app offline on purpose
</td>
<td width="50%" valign="top">
#### ⚙️ Admin, accounts and security
- **Login methods**: password login, password registration, OIDC login, OIDC registration and passkey login each switch on and off on their own. `OIDC_ONLY` leaves SSO as the only way in
- **SSO (OIDC)**: one provider by discovery, with PKCE and `id_token` verification (Authentik, Keycloak, Google and the like)
- **2FA**: TOTP with ten single-use backup codes, and an admin can require it instance-wide
- **Passkeys**: WebAuthn login by fingerprint, face, PIN or security key, off until an admin enables it. A passkey also satisfies the 2FA requirement
- **Hardening**: per-IP limits on login, password reset and 2FA attempts, a password policy, secrets encrypted at rest and masked on read, and an SSRF guard on every URL you configure
- **Admin panel**: users and invites, the permission matrix, packing templates, categories, addons, plugins, API keys, MCP tokens and OAuth sessions, backups, storage, audit log, and GitHub releases
- **Backups**: manual or scheduled hourly, daily, weekly or monthly, with retention in days. The zip carries the at-rest key, so a restore can decrypt its own secrets
- **Storage**: pluggable storage backends per content category — keep everything on local disk, or add S3-compatible backends and replicate any category to them, configured entirely from the admin panel
- **Notifications**: a per-user matrix of events against in-app, email (SMTP), webhook, ntfy and Web Push (on every device you turn it on for, even with TREK closed), plus any channel a plugin registers
- **Appearance**: light, dark or follow the OS, seven colour schemes plus a custom accent, transparency, compact density, reduce motion, and text size per tier
- **Sign-in**: password, OIDC and passkeys each switch on and off on their own, `OIDC_ONLY` leaves SSO as the only way in, and an OIDC claim can grant admin
- **SSO and 2FA**: one OIDC provider by discovery with PKCE (Authentik, Keycloak, Google and the like), TOTP with backup codes that an admin can require, and passkeys that count as the second factor
- **Sessions**: every sign-in is a session you can see and end, one by one or all others at once
- **Hardening**: rate limits on sign-in, reset and 2FA, a password policy, secrets encrypted at rest and masked on read, and an SSRF guard on every URL you configure
- **Admin panel**: users and the permission matrix, defaults for new users, categories and templates, addons, plugins, storage, notifications, MCP tokens, backups, audit log and releases
- **Backups and storage**: scheduled backups with retention that restore their own secrets, and local or S3-compatible storage per content category with replicas
- **Appearance**: light, dark or follow the OS, seven colour schemes plus a custom accent, transparency, density, reduced motion and text size
- **27 languages**: en, de, es, fr, it, nl, hu, ru, zh, zh-TW, pl, cs, ar (right to left), br, id, tr, ja, ko, uk, gr, sv, vi, ca, az, et, sk, th
- **In-app help**: the wiki ships inside the image and is served from disk at `/help`, so the docs match the version you are running
- **Help center**: a ? in the top bar with 381 step-by-step guides with screenshots, searched together with the wiki that ships in the image
</td>
</tr>
+162 -2
View File
@@ -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(),
},
],
},
Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 29 KiB

+2 -2
View File
@@ -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,
+1 -1
View File
@@ -38,8 +38,8 @@
"src/components/Roadtrip/RoadtripSidebarDriveBands.tsx": 1,
"src/components/Roadtrip/RoadtripSidebarRefuelBand.tsx": 1,
"src/components/Studio/StudioShell.tsx": 1,
"src/components/Studio/TravelElements.tsx": 1,
"src/components/Studio/sidebar/StudioEntryCard.tsx": 1,
"src/components/Studio/travel/BadgeIcon.tsx": 1,
"src/components/Todo/useTodoList.ts": 1,
"src/components/shared/ConfirmDialog.tsx": 1,
"src/components/shared/CountryFlag.tsx": 1,
+2 -2
View File
@@ -16,7 +16,7 @@
"src/components/Planner/PlaceFormModal.test.tsx": 2836,
"src/components/Planner/PlaceFormModal.tsx": 1157,
"src/components/Planner/PlaceInspector.tsx": 1290,
"src/components/Studio/TravelElements.tsx": 1738,
"src/components/Studio/TravelElements.tsx": 1559,
"src/components/Studio/countryShapes.ts": 2595,
"src/index.css": 1636,
"src/mobile/screens/admin/MAdminPluginsPanel.tsx": 1507,
@@ -26,5 +26,5 @@
"src/pages/atlas/useAtlas.ts": 1211,
"src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx": 3518,
"src/pages/tripPlanner/useTripPlanner.test.tsx": 3270,
"src/styles/studio.css": 1936
"src/styles/studio.css": 1918
}
+1 -3
View File
@@ -52,7 +52,7 @@
"components/Journey/moodConfig.ts": 5,
"components/Layout/BottomNav.tsx": 3,
"components/Layout/DemoBanner.tsx": 25,
"components/Layout/InAppNotificationBell.tsx": 8,
"components/Layout/InAppNotificationBell.tsx": 3,
"components/Layout/MobileTopBar.tsx": 51,
"components/Layout/Navbar.tsx": 21,
"components/Layout/NewVersionNotice.tsx": 3,
@@ -72,7 +72,6 @@
"components/Map/poiCategories.ts": 7,
"components/Map/ratingBadge.ts": 2,
"components/Map/reservationsMapbox.ts": 1,
"components/Notifications/InAppNotificationItem.tsx": 8,
"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": 9,
"pages/JoinTripPage.tsx": 4,
"pages/JourneyDetailPage.tsx": 53,
"pages/JourneyPage.tsx": 64,
+2 -1
View File
@@ -44,9 +44,10 @@
"components/Studio/SpreadView.tsx": 1,
"components/Studio/StudioElementsPanel.tsx": 2,
"components/Studio/StudioSwatches.tsx": 1,
"components/Studio/TravelElements.tsx": 6,
"components/Studio/TravelElements.tsx": 3,
"components/Studio/peerColour.ts": 1,
"components/Studio/printSheets.ts": 4,
"components/Studio/travel/badgeLook.ts": 2,
"components/Trips/TripMemberAvatar.tsx": 2,
"components/Vacay/VacayPersons.tsx": 1,
"components/shared/DialogShell.tsx": 2,
-5
View File
@@ -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),
};
@@ -712,4 +712,33 @@ describe('AddonManager', () => {
expect(roadtrip.querySelector('svg.lucide-car-front')).toBeInTheDocument();
expect(tours.querySelector('svg.lucide-puzzle')).not.toBeInTheDocument();
});
it('FE-ADMIN-ADDON-045: finds an addon by name or description and shows the ones on or off', async () => {
const user = userEvent.setup();
server.use(
addonsRoute([
buildAddon({ id: 'x-lists', name: 'Todo List', description: 'Track tasks', type: 'trip', enabled: true }),
buildAddon({ id: 'x-costs', name: 'Budget', description: 'Split costs', type: 'trip', enabled: false }),
buildAddon({ id: 'x-holiday', name: 'Vacay', description: 'Holiday planner', type: 'global', enabled: true }),
])
);
render(<AddonManager />);
await screen.findByText('Todo List');
await user.type(screen.getByRole('searchbox', { name: 'Search addons' }), 'costs');
expect(screen.getByText('Budget')).toBeInTheDocument();
expect(screen.queryByText('Todo List')).toBeNull();
expect(screen.queryByText('Vacay')).toBeNull();
await user.clear(screen.getByRole('searchbox', { name: 'Search addons' }));
await user.click(screen.getByRole('button', { name: 'On' }));
expect(screen.getByText('Todo List')).toBeInTheDocument();
expect(screen.getByText('Vacay')).toBeInTheDocument();
expect(screen.queryByText('Budget')).toBeNull();
await user.click(screen.getByRole('button', { name: 'Off' }));
await user.type(screen.getByRole('searchbox', { name: 'Search addons' }), 'nothing like this');
expect(screen.getByText('No addon matches the search or the filter.')).toBeInTheDocument();
});
});
+140 -61
View File
@@ -1,7 +1,7 @@
import type { ComponentType } from 'react'
import { useState, type ComponentType } from 'react'
import { useTranslation } from '../../i18n'
import { useIsDark } from '../../hooks/useIsDark'
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, Sparkles, Luggage, Plane, Bookmark, Users, Loader2, Mountain, CarFront } from 'lucide-react'
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, Sparkles, Luggage, Plane, Bookmark, Users, Loader2, Mountain, CarFront, Search } from 'lucide-react'
import EmptyState from '../shared/EmptyState'
import DawarichIcon from '../shared/DawarichIcon'
import AirTrailIcon from '../shared/AirTrailIcon'
@@ -76,6 +76,8 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
toggleToursWanderer,
mapSwitches,
} = useAddonManager()
const [query, setQuery] = useState('')
const [view, setView] = useState<AddonView>('all')
if (loading) {
return (
@@ -194,10 +196,10 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
)
}
/* One column per type, side by side, instead of three stacked sections. Stacked, each
section opened its own set of columns and the tall tiles (Collab, Journey) set a
section's height while its neighbours ran out early. Side by side the column's
head carries the type, so the tiles need no badge of their own. */
/* A card per type, the way every other admin tab sets its cards: Trip in the
left column, Global and Integration in the right one, each card with the
dialogs' head band carrying the type, its count and what it means. Above
them one card says how many are on and finds an addon by name. */
const groups = [
{ key: 'trip', addons: tripAddons },
{ key: 'global', addons: globalAddons },
@@ -214,68 +216,145 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
</span>
)
return (
<SettingsCard
icon={Puzzle}
title={t('admin.addons.title')}
hint={subtitle}
badge={totalCount > 0 ? (
<Tooltip label={t('admin.addons.group.count', { enabled: enabledCount, total: totalCount })}>
<span className="inline-flex">
<StatusPill tone={enabledCount > 0 ? 'success' : 'neutral'}>{enabledCount}/{totalCount}</StatusPill>
</span>
</Tooltip>
) : undefined}
>
{addons.length === 0 ? (
<EmptyState scene="idle" title={t('admin.addons.noAddons')} surface="var(--bg-secondary)" />
) : (
<div className="grid grid-cols-1 items-start gap-4 md:grid-cols-2 xl:grid-cols-3">
{groups.map(g => {
const meta = TYPE_META[g.key]
return (
<section key={g.key} className="min-w-0 overflow-hidden rounded-[14px] border border-edge-faint bg-surface-card">
<GroupHead icon={meta.icon} label={t(meta.labelKey)} hint={t(meta.hintKey)} addons={g.key === 'integration' ? [...aiAddons, ...g.addons] : g.addons} t={t} />
<div className="divide-y divide-edge-faint">
{/* The AI addons share a connection and a settings dialog, so they head the column as one tile. */}
{g.key === 'integration' && aiAddons.length > 0 && <AiAddonTile addons={aiAddons} onToggle={handleToggle} />}
{g.addons.map(tile)}
</div>
</section>
)
})}
/** Whether an addon is shown under the search and the filter. */
const shown = (addon: Addon) => {
if (view === 'on' && !addon.enabled) return false
if (view === 'off' && addon.enabled) return false
const q = query.trim().toLocaleLowerCase()
if (!q) return true
const label = getAddonLabel(t, addon)
return `${label.name} ${label.description ?? ''}`.toLocaleLowerCase().includes(q)
}
const card = (g: (typeof groups)[number]) => {
const meta = TYPE_META[g.key]
const all = g.key === 'integration' ? [...aiAddons, ...g.addons] : g.addons
const visible = g.addons.filter(shown)
const ai = g.key === 'integration' && aiAddons.some(shown)
if (!visible.length && !ai) return null
const on = all.filter(a => a.enabled).length
return (
<SettingsCard
key={g.key}
icon={meta.icon}
title={t(meta.labelKey)}
hint={t(meta.hintKey)}
badge={(
<Tooltip label={t('admin.addons.group.count', { enabled: on, total: all.length })}>
<span className="inline-flex"><StatusPill tone={on > 0 ? 'success' : 'neutral'}>{on}/{all.length}</StatusPill></span>
</Tooltip>
)}
bodyClassName="p-3"
>
<div className="divide-y divide-edge-faint overflow-hidden rounded-[12px] border border-edge-faint bg-surface-card">
{/* The AI addons share a connection and a settings dialog, so they head the card as one tile. */}
{ai && <AiAddonTile addons={aiAddons} onToggle={handleToggle} />}
{visible.map(tile)}
</div>
)}
</SettingsCard>
</SettingsCard>
)
}
const [first, ...rest] = groups
const cards = { start: first ? card(first) : null, end: rest.map(card).filter(Boolean) }
return (
<>
<SettingsCard
icon={Puzzle}
title={t('admin.addons.title')}
hint={subtitle}
badge={totalCount > 0 ? (
<Tooltip label={t('admin.addons.group.count', { enabled: enabledCount, total: totalCount })}>
<span className="inline-flex">
<StatusPill tone={enabledCount > 0 ? 'success' : 'neutral'}>{enabledCount}/{totalCount}</StatusPill>
</span>
</Tooltip>
) : undefined}
>
{addons.length === 0
? <EmptyState scene="idle" title={t('admin.addons.noAddons')} surface="var(--bg-secondary)" />
: <AddonToolbar groups={groups.map(g => ({ key: g.key, addons: g.key === 'integration' ? [...aiAddons, ...g.addons] : g.addons }))} query={query} onQuery={setQuery} view={view} onView={setView} t={t} />}
</SettingsCard>
{addons.length > 0 && (cards.start || cards.end.length > 0 ? (
<div className="grid grid-cols-1 gap-x-5 xl:grid-cols-2 xl:items-start">
<div className="min-w-0">{cards.start}</div>
<div className="min-w-0">{cards.end}</div>
</div>
) : (
<p className="m-0 rounded-2xl border border-dashed border-edge-faint px-4 py-8 text-center text-content-muted" style={fs(13, 'body')}>
{t('admin.addons.noMatch')}
</p>
))}
</>
)
}
/** The head band of one type's column: icon tile, the type, its count and what it means. */
function GroupHead({ icon: Icon, label, hint, addons, t }: {
icon: ComponentType<{ size?: number; className?: string; strokeWidth?: number }>
label: string
hint: string
addons: Addon[]
type AddonView = 'all' | 'on' | 'off'
/**
* The head card's body: a tile per type with how many of it are on, then a
* search over the addons' names and descriptions and a switch between all,
* the ones on and the ones off.
*/
function AddonToolbar({ groups, query, onQuery, view, onView, t }: {
groups: { key: string; addons: Addon[] }[]
query: string
onQuery: (q: string) => void
view: AddonView
onView: (v: AddonView) => void
t: (key: string, params?: Record<string, unknown>) => string
}) {
const enabled = addons.filter(a => a.enabled).length
return (
<div className="flex items-start gap-2.5 border-b border-edge-faint bg-surface-tertiary px-3.5 py-2.5">
<span className="mt-px grid h-7 w-7 flex-none place-items-center rounded-[9px] bg-surface-card text-content-secondary shadow-sm">
<Icon size={13} strokeWidth={2} />
</span>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<h3 className="m-0 min-w-0 flex-1 truncate font-bold text-content" style={fs(13, 'body')}>{label}</h3>
<Tooltip label={t('admin.addons.group.count', { enabled, total: addons.length })}>
<span className="inline-flex">
<StatusPill>{enabled}/{addons.length}</StatusPill>
</span>
</Tooltip>
</div>
{/* Says what the type means: the only place that still explains it. */}
<p className="m-0 mt-0.5 leading-snug text-content-faint" style={fs(11)}>{hint}</p>
<>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-3">
{groups.map(g => {
const meta = TYPE_META[g.key]
const Icon = meta.icon
const on = g.addons.filter(a => a.enabled).length
return (
<div key={g.key} className="flex items-center gap-3 rounded-[12px] border border-edge-faint bg-surface-card px-3 py-2.5">
<span className="grid h-8 w-8 flex-none place-items-center rounded-[10px] bg-surface-secondary text-content-secondary">
<Icon size={15} />
</span>
<span className="min-w-0 flex-1">
<span className="block truncate font-semibold text-content" style={fs(12.5, 'body')}>{t(meta.labelKey)}</span>
<span className="block text-content-faint" style={fs(11)}>{t('admin.addons.group.count', { enabled: on, total: g.addons.length })}</span>
</span>
<span className={`font-geist font-bold tabular-nums ${on > 0 ? 'text-content' : 'text-content-faint'}`} style={fs(18, 'body')} aria-hidden>{on}</span>
</div>
)
})}
</div>
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<label className="relative flex min-w-0 flex-1 items-center">
<Search size={14} className="pointer-events-none absolute start-3 text-content-faint" />
<input
type="search"
value={query}
onChange={e => onQuery(e.target.value)}
placeholder={t('admin.addons.search')}
aria-label={t('admin.addons.search')}
className="h-9 w-full rounded-[10px] border border-edge-faint bg-surface-card ps-8 pe-3 text-content outline-none transition-colors placeholder:text-content-faint focus:border-accent"
style={fs(13, 'body')}
/>
</label>
<div className="inline-flex flex-none gap-1 rounded-[10px] bg-surface-tertiary p-1" role="group" aria-label={t('admin.addons.filter')}>
{(['all', 'on', 'off'] as const).map(v => (
<button
key={v}
type="button"
onClick={() => onView(v)}
aria-pressed={view === v}
className={`h-7 rounded-[8px] px-3 font-medium transition-colors ${view === v ? 'bg-surface-card text-content shadow-sm' : 'text-content-muted hover:text-content'}`}
style={fs(12.5, 'body')}
>
{t(`admin.addons.filter.${v}`)}
</button>
))}
</div>
</div>
</>
)
}
@@ -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 && (
@@ -121,4 +121,40 @@ describe('the questions an answer offers', () => {
expect(followUpsFor(offered, true).map((f) => f.text)).toEqual(['Yes, plan it']);
expect(followUpsFor(of([{ type: 'text', text: 'Here it is.' }]), true).map((f) => f.id)).toEqual(['more', 'next']);
});
it('FE-AI-FOLLOW-008: the answer to an attached file offers what the server chose, also under its booking cards', () => {
const read = {
type: 'tool-call' as const,
toolCallId: 'r',
toolName: 'read_booking_file',
args: { name: 'AirBnB Buchung.pdf' },
result: { bookings: [], reason: 'ai_not_configured' },
};
const chips = {
type: 'data-trek-follow-ups' as const,
data: { questions: ['Keep it in Files', 'What is in the file?'] },
};
const empty = of([
read,
{ type: 'text', text: 'I could not find a booking. What should I do with the file?' },
chips,
]);
expect(followUpsFor(empty, true).map((f) => f.text)).toEqual(['Keep it in Files', 'What is in the file?']);
const change = {
toolCallId: 'b',
verbKey: 'importBooking',
subject: 'Casa',
rows: [],
dayId: null,
placeIds: [],
undoable: false,
};
const found = of([
read,
{ type: 'text', text: 'I found 1 booking.' },
{ type: 'data-trek-change', data: change as AssistantChangePreview },
{ type: 'data-trek-follow-ups', data: { questions: ['Keep the file with this booking'] } },
]);
expect(followUpsFor(found, true).map((f) => f.text)).toEqual(['Keep the file with this booking']);
});
});
@@ -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[] }
@@ -463,4 +463,17 @@ export const CHANGE_LABELS: Record<AssistantChangeLabelKey, { labelKey: string }
track: { labelKey: 'assistant.change.label.track' },
packing: { labelKey: 'assistant.change.label.packing' },
breaks: { labelKey: 'assistant.change.label.breaks' },
sentTo: { labelKey: 'assistant.change.label.sentTo' },
attachedTo: { labelKey: 'assistant.change.label.attachedTo' },
};
/**
* 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;
@@ -65,6 +65,13 @@ describe('cardLooks', () => {
}
});
it('FE-AI-LOOK-003b: a file kept from the chat opens the trip Files and shows nothing on the map', () => {
const kept: AssistantApplied = { toolCallId: 'k', entity: 'file', ids: [], dayId: null, undoUntil: null };
expect(ENTITY_LOOK.file.open?.(kept, null)).toEqual({ kind: 'tab', tab: 'dateien' });
expect(ENTITY_LOOK.file.onMap).toBeUndefined();
expect(ENTITY_LOOK.file.openLabelKey).toBe('assistant.card.file.open');
});
it('FE-AI-LOOK-004: every kind has a look and a view; a pending view groups by kind and gives way to nothing', () => {
expect(Object.keys(CARD_LOOKS).sort()).toEqual([...KINDS].sort());
expect(Object.keys(CARD_VIEWS).sort()).toEqual([...KINDS].sort());
@@ -5,6 +5,9 @@ import {
CalendarMinus,
CalendarPlus,
Circle,
FileSearch,
FileText,
FolderInput,
ListTodo,
Luggage,
MapPin,
@@ -63,6 +66,8 @@ export const VERB_LOOK: Readonly<Record<AssistantChangeVerbKey, VerbLook>> = {
addReservation: { Icon: Ticket, tone: 'success', labelKey: 'assistant.change.verb.addReservation' },
importBooking: { Icon: Ticket, tone: 'success', labelKey: 'assistant.change.verb.importBooking' },
rememberNote: { Icon: NotebookPen, tone: 'info', labelKey: 'assistant.change.verb.rememberNote' },
readAttachment: { Icon: FileSearch, tone: 'info', labelKey: 'assistant.change.verb.readAttachment' },
keepFile: { Icon: FolderInput, tone: 'success', labelKey: 'assistant.change.verb.keepFile' },
importTour: { Icon: Plus, tone: 'success', labelKey: 'assistant.change.verb.importTour' },
genericCreate: { Icon: Plus, tone: 'success', labelKey: 'assistant.change.verb.genericCreate' },
genericUpdate: { Icon: PencilLine, tone: 'info', labelKey: 'assistant.change.verb.genericUpdate' },
@@ -159,6 +164,7 @@ export const STEP_LOOKS: Readonly<Partial<Record<string, StepLook>>> = {
},
search_help: { stepKey: 'assistant.step.help' },
read_booking_file: { stepKey: 'assistant.step.readingBooking' },
read_attached_file: { stepKey: 'assistant.step.readingAttachment' },
...TOUR_LOOK.steps,
...ROADTRIP_LOOK.steps,
...BUDGET_LOOK.steps,
@@ -193,6 +199,8 @@ export const ENTITY_LOOK: Readonly<Record<AssistantApplied['entity'], EntityLook
assignment: { Icon: CalendarDays, onMap: BY_DAY },
day_note: { Icon: StickyNote, onMap: BY_DAY },
memory: { Icon: NotebookPen },
// A file kept from the chat: it opens the trip's Files.
file: { Icon: FileText, openLabelKey: 'assistant.card.file.open', open: () => ({ kind: 'tab', tab: 'dateien' }) },
other: { Icon: Circle },
...TOUR_LOOK.entities,
...ROADTRIP_LOOK.entities,
@@ -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]);
}
+4
View File
@@ -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 {
@@ -68,27 +68,28 @@ export default function InAppNotificationBell(): React.ReactElement {
{/* Click-away catcher only — the bell button itself closes the panel again from the keyboard. */}
<div role="presentation" style={{ position: 'fixed', inset: 0, zIndex: 9998 }} onClick={() => setOpen(false)} />
<div
className="rounded-xl shadow-xl border overflow-hidden bg-surface-card border-edge"
className="rounded-2xl shadow-xl border overflow-hidden bg-surface-card border-edge"
style={{
position: 'fixed',
top: 'var(--nav-h)',
insetInlineEnd: 8,
width: 360,
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',
}}
>
{/* Header */}
<div
className="flex items-center justify-between px-4 py-3 flex-shrink-0 border-b border-edge-secondary"
>
<span className="text-sm font-semibold text-content">
{/* Header: the dialogs' head, a tile, the name and how many are unread, the tools at the end. */}
<div className="flex items-center gap-3 px-4 pt-4 pb-3 flex-shrink-0">
<span className="grid place-items-center w-9 h-9 rounded-xl bg-accent-subtle text-accent flex-shrink-0">
<Bell className="w-4 h-4" />
</span>
<span className="flex-1 min-w-0 flex items-center gap-2 text-subtitle font-semibold text-content">
{t('notifications.title')}
{unreadCount > 0 && (
<span className="ms-2 px-1.5 py-0.5 rounded-full text-xs font-medium bg-content text-surface">
<span className="px-2 h-5 inline-flex items-center rounded-full text-caption font-semibold tabular-nums bg-accent text-accent-text">
{unreadCount}
</span>
)}
@@ -98,49 +99,53 @@ export default function InAppNotificationBell(): React.ReactElement {
<button type="button"
onClick={markAllRead}
title={t('notifications.markAllRead')}
className="p-1.5 rounded-lg transition-colors text-content-muted hover:bg-surface-hover focus-visible:bg-surface-hover"
aria-label={t('notifications.markAllRead')}
className="grid place-items-center w-8 h-8 rounded-lg transition-colors text-content-muted bg-surface-secondary hover:bg-surface-hover hover:text-content focus-visible:bg-surface-hover"
>
<CheckCheck className="w-3.5 h-3.5" />
<CheckCheck className="w-4 h-4" />
</button>
)}
{notifications.length > 0 && (
<button type="button"
onClick={() => setConfirmingDeleteAll(true)}
title={t('notifications.deleteAll')}
className="p-1.5 rounded-lg transition-colors text-content-muted hover:bg-surface-hover focus-visible:bg-surface-hover"
aria-label={t('notifications.deleteAll')}
className="grid place-items-center w-8 h-8 rounded-lg transition-colors text-content-muted bg-surface-secondary hover:bg-danger-soft hover:text-danger focus-visible:bg-danger-soft"
>
<Trash2 className="w-3.5 h-3.5" />
<Trash2 className="w-4 h-4" />
</button>
)}
</div>
</div>
{/* Notification list */}
<div className="overflow-y-auto flex-1">
<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" />
</div>
) : notifications.length === 0 ? (
<div className="flex flex-col items-center justify-center py-10 px-4 text-center gap-2">
<Bell className="w-8 h-8 text-content-faint" />
<p className="text-sm font-medium text-content-muted">{t('notifications.empty')}</p>
<p className="text-xs text-content-faint">{t('notifications.emptyDescription')}</p>
<div className="flex flex-col items-center justify-center py-10 px-6 text-center gap-2">
<span className="grid place-items-center w-12 h-12 rounded-2xl bg-surface-secondary text-content-faint">
<Bell className="w-5 h-5" />
</span>
<p className="text-body font-semibold text-content">{t('notifications.empty')}</p>
<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>
{/* Footer */}
<button type="button"
onClick={handleShowAll}
className="w-full py-2.5 text-xs font-medium transition-colors flex-shrink-0 border-t border-edge-secondary text-content hover:bg-surface-hover focus-visible:bg-surface-hover"
>
{t('notifications.showAll')}
</button>
<div className="p-2 flex-shrink-0 border-t border-edge-secondary">
<button type="button"
onClick={handleShowAll}
className="w-full h-9 rounded-xl text-body font-medium transition-colors bg-surface-secondary text-content hover:bg-surface-hover focus-visible:bg-surface-hover"
>
{t('notifications.showAll')}
</button>
</div>
</div>
</>,
document.body
@@ -1,18 +1,54 @@
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 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 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: 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()
const { responding, handleRespond, handleNavigate, markRead, markUnread, deleteNotification } =
@@ -20,120 +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={`relative px-4 py-3 transition-colors border-b border-edge-secondary ${notification.is_read ? '' : 'bg-accent-subtle'}`}
>
<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 mt-0.5">
{notification.sender_avatar ? (
<img
src={notification.sender_avatar}
alt=""
className="w-8 h-8 rounded-full object-cover"
/>
) : (
<div
className="w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold text-content-muted bg-surface-tertiary"
>
{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-sm font-medium leading-snug text-content">
{hasUnknownTitle ? notification.title_key : titleText}
</p>
<div className="flex items-center gap-0.5 flex-shrink-0">
<span className="text-xs me-1 text-content-faint">
{relativeTime(notification.created_at, locale)}
</span>
<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="p-1 rounded transition-colors text-content-faint hover:bg-surface-hover hover:text-content focus-visible:bg-surface-hover focus-visible:text-content"
>
<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="p-1 rounded transition-colors text-content-faint hover:bg-surface-hover hover:text-content focus-visible:bg-surface-hover focus-visible:text-content"
>
<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"
<button
type="button"
onClick={() => deleteNotification(notification.id)}
title={t('notifications.delete')}
aria-label={t('notifications.delete')}
className="p-1 rounded transition-colors text-content-faint hover:bg-danger-soft hover:text-danger focus-visible:bg-danger-soft focus-visible:text-danger"
className={`${TOOL} hover:text-danger focus-visible:text-danger`}
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
<p className="text-xs mt-0.5 leading-relaxed text-content-muted">
{hasUnknownBody ? notification.text_key : bodyText}
</p>
{/* Boolean actions */}
{notification.type === 'boolean' && notification.positive_text_key && notification.negative_text_key && (
<div className="flex gap-2 mt-2">
<button type="button"
onClick={() => handleRespond('positive')}
disabled={responding || notification.response !== null}
className={`flex items-center gap-1 px-2.5 py-1 rounded-lg text-xs font-medium transition-colors ${
notification.response === 'positive'
? 'bg-content text-surface'
: `bg-surface-tertiary ${notification.response === 'negative' ? 'text-content-faint opacity-50' : 'text-content-secondary'}`
} ${notification.response !== null || responding ? 'cursor-default' : 'cursor-pointer'}`}
>
<Check className="w-3 h-3" />
{t(notification.positive_text_key)}
</button>
<button type="button"
onClick={() => handleRespond('negative')}
disabled={responding || notification.response !== null}
className={`flex items-center gap-1 px-2.5 py-1 rounded-lg text-xs font-medium transition-colors ${
notification.response === 'negative'
? 'bg-danger text-white'
: `bg-surface-tertiary ${notification.response === 'positive' ? 'text-content-faint opacity-50' : 'text-content-secondary'}`
} ${notification.response !== null || responding ? 'cursor-default' : 'cursor-pointer'}`}
>
<X className="w-3 h-3" />
{t(notification.negative_text_key)}
</button>
</div>
)}
{/* Navigate action */}
{notification.type === 'navigate' && notification.navigate_text_key && notification.navigate_target && (
<button type="button"
onClick={handleNavigate}
className="flex items-center gap-1 mt-2 px-2.5 py-1 rounded-lg text-xs font-medium transition-colors bg-surface-tertiary text-content-secondary hover:bg-surface-hover focus-visible:bg-surface-hover"
>
<ArrowRight className="w-3 h-3" />
{t(notification.navigate_text_key)}
</button>
)}
</div>
<p className="text-body mt-0.5 leading-relaxed text-content-muted">{bodyText}</p>
{asks && (
<div className="flex flex-wrap gap-2 mt-3">
<button
type="button"
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`}
>
<Check className="w-3.5 h-3.5" />
{t(yesKey)}
</button>
<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,
};
}
+29 -20
View File
@@ -15,15 +15,13 @@ import type { SaveState } from './useBookStore'
* which is exactly the wrong habit for the one time it says something else.
*/
export function SaveIndicator({
state, t, onAcceptTheirs, onKeepMine, onRetry, onReload = reloadStudio,
state, t, onAcceptTheirs, onKeepMine, onRetry,
}: {
state: SaveState
t: (k: string) => string
onAcceptTheirs: (current: BookRecord) => void
onKeepMine: (current: BookRecord) => void
onRetry: () => void
/** Load this Studio again, for a book a newer one saved. A full reload by default. */
onReload?: () => void
}) {
if (state.status === 'conflict') {
return (
@@ -45,23 +43,8 @@ export function SaveIndicator({
)
}
if (state.status === 'newer') {
/*
* The one state with a way out the user has to take themselves: a newer
* Studio saved this book, this tab cannot write it without losing what
* that one added, and only a reload brings the newer Studio here. Loud
* like a conflict, because until then nothing done here is kept.
*/
return (
<div className="st-save is-conflict" role="alert">
<AlertTriangle size={13} />
<span>{t('journey.studio.saveNewer')}</span>
<button type="button" onClick={onReload}>
{t('journey.studio.saveReload')}
</button>
</div>
)
}
// A book a newer Studio saved is the shell's to say, over everything (NewerBookOverlay).
if (state.status === 'newer') return null
if (state.status === 'readonly') {
/*
@@ -107,6 +90,32 @@ export function SaveIndicator({
return null
}
/**
* A book a newer Studio saved, said over the whole Studio rather than in the
* bar: this tab cannot write the book without losing what the newer one
* added, and only a reload brings the newer Studio here. Until then nothing
* done here would be kept, so nothing can be done: the overlay takes every
* click and offers the one way out there is.
*/
export function NewerBookOverlay({ t, onReload = reloadStudio }: {
t: (k: string) => string
onReload?: () => void
}) {
return (
<div className="st-newer" role="alertdialog" aria-modal="true" aria-labelledby="st-newer-text">
<div className="st-newer-card">
<span className="st-newer-icon"><RefreshCw size={22} strokeWidth={2.2} /></span>
<p id="st-newer-text" className="st-newer-text">{t('journey.studio.saveNewer')}</p>
{/* Focused on arrival, so Enter reloads and nothing behind the overlay takes the keyboard. */}
<button type="button" className="st-newer-reload" onClick={onReload} autoFocus>
<RefreshCw size={15} />
{t('journey.studio.saveReload')}
</button>
</div>
</div>
)
}
/** The default way back to a current Studio: the page again, which loads the newest build. */
function reloadStudio() {
window.location.reload()
+24 -3
View File
@@ -18,6 +18,12 @@ interface SpreadThumbProps {
className?: string;
/** Pixels per millimetre the caller draws at; CSS's own unless the caller zooms. */
pxPerMm?: number;
/**
* Draw the first and the last page beside the blank inside of their cover,
* as the bound book opens on them, so the rail shows why they stand alone.
* The words are the half's name, read out and shown on hover.
*/
coverInside?: string;
}
/**
@@ -37,16 +43,31 @@ export function SpreadThumb({
fold = false,
className,
pxPerMm = PX_PER_MM,
coverInside,
}: SpreadThumbProps) {
const single = spread.role !== 'inner';
const wMm = single ? page.pageWidth : page.pageWidth * 2;
// The first page opens on the right of the cover's inside, the last on the left of the back's.
const opens = coverInside && (spread.role === 'first' || spread.role === 'last') ? spread.role : null;
const wMm = single && !opens ? page.pageWidth : page.pageWidth * 2;
const scale = width / (wMm * pxPerMm);
const pageLeft = opens === 'first' ? page.pageWidth * pxPerMm * scale : 0;
return (
<div dir="ltr" className={className} style={{ width, height: page.pageHeight * pxPerMm * scale }}>
{opens && (
<span
className="st-thumb-inside"
title={coverInside}
aria-label={coverInside}
style={{
insetInlineStart: opens === 'first' ? 0 : page.pageWidth * pxPerMm * scale,
width: page.pageWidth * pxPerMm * scale,
}}
/>
)}
<div
style={{
position: 'absolute',
insetInlineStart: 0,
insetInlineStart: pageLeft,
top: 0,
width: `${wMm}mm`,
height: `${page.pageHeight}mm`,
@@ -56,7 +77,7 @@ export function SpreadThumb({
>
<SpreadView spread={spread} page={page} folios={folios} />
</div>
{fold && !single && <SpreadFold page={page} scaled={pxPerMm * scale} />}
{fold && (!single || opens) && <SpreadFold page={page} scaled={pxPerMm * scale} />}
</div>
);
}
@@ -215,13 +215,13 @@ export function StudioAssistantToggle({ assistant }: { assistant: StudioAssistan
return (
<button
type="button"
className={`st-tool st-assistant-toggle ${assistant.open ? 'is-on' : ''}`}
className={`st-tool is-icon st-head-solo st-assistant-toggle ${assistant.open ? 'is-on' : ''}`}
onClick={assistant.toggle}
aria-pressed={assistant.open}
aria-label={t('journey.studio.assistant.toggle')}
title={t('journey.studio.assistant.toggleHint')}
>
<MessageSquare size={14} />
<span className="st-tool-label">{t('journey.studio.assistant.toggle')}</span>
<MessageSquare size={15} />
</button>
);
}
@@ -53,7 +53,7 @@ describe('StudioHeader', () => {
it("FE-STUDIO-HEAD-001: names the journey under the Studio's mark, with its cover as the tile", () => {
head();
expect(screen.getByRole('heading', { name: 'Japan 2026' })).toBeInTheDocument();
expect(screen.getByText('journey.studio.beta')).toBeInTheDocument();
expect(screen.getByRole('img', { name: 'TREK Studio' })).toBeInTheDocument();
expect(document.querySelector('.st-head-tile img')?.getAttribute('src')).toBe('/uploads/cover.jpg');
});
+30 -29
View File
@@ -15,14 +15,14 @@ import type { StudioAssistant } from './useStudioAssistant';
/**
* The Studio's head band, in the shape every TREK dialog has: a raised tile on
* the left, a small line above the title, the title, a line under it, and the
* round close button at the far end.
* the left, the Studio's mark above the title, the title, a line under it, and
* the close button at the far end.
*
* The tile is the journey's cover and the title its name, so it is always clear
* whose book this is. What the book is printed as and whether it is saved sit
* under the title, because they describe it. The tools on the right are grouped
* by what they do: who else is here, editing (undo, redo, paste), making
* (auto layout, the assistant), and getting the book out (help, export). How
* whose book this is. The tools on the right are grouped by what they do:
* who else is here and whether the book is saved, what the book is printed as, editing (undo, redo, paste), auto
* layout, the assistant, help, and getting the book out. Every piece of
* the row is one raised tile of one height, export alone in the accent. How
* the page is looked at, the book view, lives with the zoom under the page.
*/
export function StudioHeader({
@@ -41,25 +41,26 @@ export function StudioHeader({
<div className="st-head-text">
<div className="st-head-eyebrow">
<StudioWordmark style={{ height: 17 }} />
<span className="st-beta">{s.t('journey.studio.beta')}</span>
</div>
<h1 className="st-head-title">{s.journey?.title || s.t('journey.title')}</h1>
<div className="st-head-sub">
<FormatPicker s={s} />
<SaveIndicator
state={s.saveState}
t={s.t}
onAcceptTheirs={(current) => s.loadDoc(s.acceptTheirs(current))}
onKeepMine={s.keepMine}
onRetry={() => {
void s.saveNow();
}}
/>
</div>
</div>
<div className="st-head-tools">
<PeerBadges peers={s.peers} t={s.t} />
{/* Whether the book is safe, as a tile beside who else is here: both say how the book stands. */}
<SaveIndicator
state={s.saveState}
t={s.t}
onAcceptTheirs={(current) => s.loadDoc(s.acceptTheirs(current))}
onKeepMine={s.keepMine}
onRetry={() => {
void s.saveNow();
}}
/>
<div className="st-head-group">
<FormatPicker s={s} />
</div>
<div className="st-head-group">
<IconTool label={s.t('journey.studio.undo')} disabled={!s.canUndo} onClick={s.undo}>
@@ -73,15 +74,15 @@ export function StudioHeader({
<div className="st-head-group">
<AutoLayoutButton s={s} />
{/* The book's assistant; only where it can run for this user. */}
<StudioAssistantToggle assistant={assistant} />
</div>
{/* The book's assistant, a tile of its own like help; only where it can run for this user. */}
<StudioAssistantToggle assistant={assistant} />
{/* Studio has no navbar, so the help center's button rides on its own bar. */}
<HelpButton className="st-tool is-icon" />
<HelpButton className="st-tool is-icon st-head-solo" />
<button
type="button"
className="st-tool is-primary"
className="st-tool is-primary st-head-solo"
onClick={onExport}
disabled={!s.doc}
title={s.t('journey.studio.export')}
@@ -242,8 +243,8 @@ function AutoLayoutButton({ s }: { s: Studio }) {
}
/**
* The format, as a chip under the book's name: it says what the book is
* printed as, and opening it changes that.
* The format, as a tool of the head row: it says what the book is printed as,
* and opening it changes that.
*
* A native <select> was the first draft and it was wrong: the browser draws the
* list in its own chrome, so it ignores the theme entirely and drops a list that
@@ -264,7 +265,7 @@ function FormatPicker({ s }: { s: Studio }) {
<div className="st-picker" ref={box}>
<button
type="button"
className="st-head-chip"
className="st-tool"
onClick={() => setOpen((o) => !o)}
aria-haspopup="listbox"
aria-expanded={open}
@@ -278,16 +279,16 @@ function FormatPicker({ s }: { s: Studio }) {
: { height: 12, width: Math.max(6, Math.round((12 * width) / height)) }
}
/>
<span>
<span className="st-tool-label">
{s.preset === 'custom'
? `${Math.round(s.page.pageWidth)} × ${Math.round(s.page.pageHeight)} mm`
: s.t(active.labelKey)}
</span>
<ChevronDown size={12} style={{ opacity: 0.55 }} />
<ChevronDown size={13} style={{ opacity: 0.5 }} />
</button>
{open && (
<div className="st-menu st-head-menu" role="listbox">
<div className="st-menu" role="listbox">
{PAGE_PRESET_ORDER.map((id) => {
const p = PAGE_PRESETS[id];
const on = id === s.preset;
@@ -35,10 +35,10 @@ export function StudioSavedLayouts({
const scale = width / (wMm * pxPerMm)
return (
<div className="st-saved-layouts">
<div className="st-section st-saved-layouts">
<div className="st-section-label">{t('journey.studio.myLayouts')}</div>
{fitting.length === 0 && <p className="st-hint">{t('journey.studio.myLayoutsEmpty')}</p>}
<div className="st-thumbs">
<div className={`st-thumbs st-tpl-grid ${single ? 'is-pages' : 'is-spreads'}`}>
{fitting.map(layout => (
<div className="st-thumb-row" key={layout.id}>
<button type="button" className="st-thumb" onClick={() => applyLayout(active, layout.id)}>
@@ -79,7 +79,6 @@ export function StudioSavedLayouts({
{t('journey.studio.saveLayout')}
</button>
</Tooltip>
<div className="st-section-label st-builtin-label">{t('journey.studio.builtInLayouts')}</div>
</div>
)
}
@@ -17,6 +17,7 @@ import { StudioExport } from './StudioExport'
import { StudioAssistantColumn, StudioPageStage } from './StudioAssistantPanel'
import { useStudioAssistant } from './useStudioAssistant'
import { StudioHeader } from './StudioHeader'
import { NewerBookOverlay } from './SaveIndicator'
import { StudioNotices } from './StudioNotices'
import '../../styles/dashboard.css'
import '../../styles/studio.css'
@@ -87,6 +88,8 @@ export default function StudioShell() {
>
<StudioHeader s={s} onExport={() => setExporting(true)} assistant={assistant} />
<StudioNotices />
{/* A newer Studio saved this book: nothing done here would be kept, so the whole Studio waits for a reload. */}
{s.saveState.status === 'newer' && <NewerBookOverlay t={s.t} />}
{/* The journey gallery's picker, inside Studio's own layer (#2271). */}
{s.providerPick && s.journey && (
<ProviderPicker
@@ -3,7 +3,9 @@ import type {
BookBadgeElement, BookCountriesElement, BookElement, BookListElement, BookMapElement,
BookMetric, BookStatsElement, JourneyStats,
} from '@trek/shared'
import { BOOK_METRICS, MAX_PATH_POINTS, MAX_PATH_SEGMENTS, isStale, refreshPatch } from '@trek/shared'
import {
BOOK_BADGE_STYLES, BOOK_COUNTRY_LOOKS, BOOK_METRICS, BOOK_STATS_LOOKS, MAX_PATH_POINTS, MAX_PATH_SEGMENTS, isStale, refreshPatch,
} from '@trek/shared'
import { Swatches } from './StudioSwatches'
import { StudioFontPicker } from './StudioFontPicker'
import { WeightLine } from './StudioTypeControls'
@@ -547,6 +549,8 @@ function MapLook({ el, set, t }: Props<BookMapElement>) {
options={[
{ value: 'dot' as const, label: t('journey.studio.pinDot') },
{ value: 'photo' as const, label: t('journey.studio.pinPhoto') },
{ value: 'numbered' as const, label: t('journey.studio.pinNumbered') },
{ value: 'ends' as const, label: t('journey.studio.pinEnds') },
]}
onPick={pinStyle => set({ pinStyle } as Partial<BookElement>)}
/>
@@ -597,16 +601,26 @@ function StatsProps({ el, set, t }: Props<BookStatsElement>) {
/>
</Section>
<Section label={t('journey.studio.layout')}>
<Line label={t('journey.studio.layout')}>
{/* The look first: the arrangement below belongs to the classic one alone. */}
<Line label={t('journey.studio.look')} wide>
<Choice
value={el.layout}
options={(['grid', 'row', 'column'] as const).map(layout => ({
value: layout,
label: t(`journey.studio.layout${layout[0].toUpperCase()}${layout.slice(1)}`),
}))}
onPick={layout => set({ layout } as Partial<BookElement>)}
value={el.look ?? 'classic'}
options={BOOK_STATS_LOOKS.map(look => ({ value: look, label: t(`journey.studio.statsLook.${look}`) }))}
onPick={look => set({ look } as Partial<BookElement>)}
/>
</Line>
{(el.look ?? 'classic') === 'classic' && (
<Line label={t('journey.studio.layout')}>
<Choice
value={el.layout}
options={(['grid', 'row', 'column'] as const).map(layout => ({
value: layout,
label: t(`journey.studio.layout${layout[0].toUpperCase()}${layout.slice(1)}`),
}))}
onPick={layout => set({ layout } as Partial<BookElement>)}
/>
</Line>
)}
<Line label={t('journey.studio.units')}>
<Choice
value={el.units}
@@ -634,16 +648,25 @@ function StatsProps({ el, set, t }: Props<BookStatsElement>) {
function CountriesProps({ el, set, t }: Props<BookCountriesElement>) {
return (
<Section label={t('journey.studio.countries')}>
<Line label={t('journey.studio.layout')}>
<Line label={t('journey.studio.look')} wide>
<Choice
value={el.layout}
options={(['list', 'grid', 'column'] as const).map(layout => ({
value: layout,
label: t(`journey.studio.layout${layout[0].toUpperCase()}${layout.slice(1)}`),
}))}
onPick={layout => set({ layout } as Partial<BookElement>)}
value={el.look ?? 'classic'}
options={BOOK_COUNTRY_LOOKS.map(look => ({ value: look, label: t(`journey.studio.countryLook.${look}`) }))}
onPick={look => set({ look } as Partial<BookElement>)}
/>
</Line>
{(el.look ?? 'classic') === 'classic' && el.codes.length > 1 && (
<Line label={t('journey.studio.layout')}>
<Choice
value={el.layout}
options={(['list', 'grid', 'column'] as const).map(layout => ({
value: layout,
label: t(`journey.studio.layout${layout[0].toUpperCase()}${layout.slice(1)}`),
}))}
onPick={layout => set({ layout } as Partial<BookElement>)}
/>
</Line>
)}
{/* These three read as raw English out of the code before: `left`,
`center`, `right`, straight from the value. */}
<Line label={t('journey.studio.align')}>
@@ -734,7 +757,7 @@ function BadgeProps({ el, set, t }: Props<BookBadgeElement>) {
<Line label={t('journey.studio.style')} wide>
<Choice
value={el.style}
options={(['plain', 'chip', 'outline', 'stacked'] as const).map(style => ({
options={BOOK_BADGE_STYLES.map(style => ({
value: style, label: t(`journey.studio.markStyle.${style}`),
}))}
onPick={style => set({ style } as Partial<BookElement>)}
+256 -492
View File
@@ -1,18 +1,16 @@
import { useEffect, useRef, useState } from 'react'
import { Check, RouteOff } from 'lucide-react'
import type {
BookBadgeVariant, BookElement, BookMapScope, BookMetric, BookPageSetup, JourneyStats,
} from '@trek/shared'
import {
BOOK_METRICS, bookBadgeValue, bookCountryMark, bookCountryName, bookElementId as uid, bookTravelBadge,
bookTravelCountries, bookTravelMap, bookTravelSize, bookTravelStats,
} from '@trek/shared'
import { useEffect, useId, useRef, useState, type ReactNode } from 'react'
import { Globe, Hash, Map as MapIcon, Stamp } from 'lucide-react'
import type { BookElement, BookPageSetup, JourneyStats } from '@trek/shared'
import { bookElementId as uid } from '@trek/shared'
import { useStudioStore } from '../../store/studioStore'
import { PanelHead, Shelf } from './StudioPanelHead'
import { studioTravelContext } from './studioTravelContext'
import { Section } from './StudioControls'
import { TravelPreview } from './TravelPreview'
import { StudioTabPanel, StudioTabs } from './StudioTabs'
import { fetchRoads } from './roadRoute'
import { TravelCard } from './travel/TravelCard'
import { TravelStops } from './travel/TravelStops'
import {
COUNTRY_LOOKS, FIGURE_SHELVES, MAP_CARDS, MARK_STYLES, MARK_VARIANTS, SUMMARY_LOOKS, travelBuilders,
} from './travel/travelBuilders'
/**
* The journey's own figures, as things you can put on a page.
@@ -22,90 +20,39 @@ import { fetchRoads } from './roadRoute'
* distance, the countries and the dates are already in TREK; the work is
* offering them as objects rather than as numbers someone has to retype.
*
* ── Why every tile is a real preview ──────────────────────────────────────
* Four tabs, the way the Elements panel sorts what it adds: the figures, the
* maps with the stops they are drawn from, the countries, and the marks. Every
* card is the element its click places, built by the book engine and drawn by
* the page's own renderer with this journey's numbers in it, in the newer
* looks and in the book's theme (travel/travelBuilders.ts). The looks books
* had before these still draw on the pages that hold them; they are no longer
* offered here.
*
* These elements differ from one another by what they *say*, not by their
* shape. Four map styles are four maps; three summary layouts are the same six
* numbers arranged three ways. A named row with the value beside it — which is
* what this panel was first — tells you none of that: you cannot tell the dark
* map from the paper one, or a row of figures from a grid of them, until you
* have placed one and undone it.
*
* So each tile builds the element it would place and renders it through the
* page's own renderer, with this journey's real numbers in it. You are picking
* from the things themselves.
*
* Every button resolves its values *now* and writes them into the element, so
* Every card resolves its values now and writes them into the element, so
* what lands on the page is finished rather than a placeholder that needs the
* server to mean anything. The reasoning is in TravelElements.tsx, and the
* short version is that the print renderer must never depend on a fetch.
* server to mean anything: the print renderer must never depend on a fetch.
*/
/**
* One row of the stops list: a point of the route or a stop switched off.
*
* `entryId` is null for a stop the server took straight from a trip's places,
* which is what a journey has before anyone wrote an entry. Such a stop has no
* switch, because there is no entry to put it on.
*/
type StopRow = {
entryId: number | null
label: string
date: string | null
country: string | null
excluded: boolean
}
type Tab = 'figures' | 'maps' | 'countries' | 'marks'
/*
* How many cards share a row.
*
* A stats panel is the width of a spread; shown two-up its figures scale down
* past reading, so it gets the row to itself. Maps, country lists and marks are
* compact enough that two-up still tells them apart, and two-up is what makes
* the section scannable. The tile measures its own width — see TravelPreview.
*/
/** The words a mark carries, as the book engine resolves them. */
type BadgeValue = NonNullable<ReturnType<typeof bookBadgeValue>>
const TABS: { id: Tab; icon: typeof Hash; labelKey: string }[] = [
{ id: 'figures', icon: Hash, labelKey: 'journey.studio.travelFigures' },
{ id: 'maps', icon: MapIcon, labelKey: 'journey.studio.travelMaps' },
{ id: 'countries', icon: Globe, labelKey: 'journey.studio.countries' },
{ id: 'marks', icon: Stamp, labelKey: 'journey.studio.marks' },
]
/*
* The two imagery maps, satellite first: the recommendation should not be the
* second thing read. Their tiles and credit are the book engine's own
* (BOOK_MAP_SOURCES), so a satellite map placed here is the one the assistant
* places.
* second thing read. Relief stops at about six hundred metres to the pixel, a
* smudge at country size; satellite is the one that prints for the page most
* people make, and the ring says so.
*/
const IMAGERY = [
{ id: 'satellite', labelKey: 'journey.studio.mapSourceSatellite' },
{ id: 'relief', labelKey: 'journey.studio.mapSourceRelief' },
] as const
function Card({ label, onClick, children, recommended = false, title }: {
label: string
onClick: () => void
/**
* Marked as the one to reach for, with a ring rather than with words.
*
* The label sits in a grid cell about eighty pixels wide, where "Satellite,
* recommended" would take both of its lines. A ring says the same thing in
* no space at all, and the tooltip carries the word for anyone who wonders
* why this card looks different.
*/
recommended?: boolean
title?: string
children: React.ReactNode
}) {
return (
<button type="button"
className={`st-travel-card ${recommended ? 'is-recommended' : ''}`}
onClick={onClick}
title={title ? `${label}: ${title}` : label}
>
{children}
<span className="st-travel-card-label">{label}</span>
</button>
)
}
export function StudioTravelPanel({
page, stats, path, t, locale, canEdit, onToggleStop,
}: {
@@ -123,16 +70,16 @@ export function StudioTravelPanel({
const addElement = useStudioStore(s => s.addElement)
const updateElement = useStudioStore(s => s.updateElement)
const active = useStudioStore(s => s.activeSpread)
const doc = useStudioStore(s => s.doc)
const spread = doc?.spreads[active]
const single = !!spread && spread.role !== 'inner'
const role = useStudioStore(s => s.doc?.spreads[active]?.role)
const theme = useStudioStore(s => s.doc?.theme)
const [tab, setTab] = useState<Tab>('figures')
const tabsId = useId()
/*
* The road lookups still running when the panel goes away.
*
* fetchRoads walks the legs at a deliberate pace, so a twenty-stop map keeps
* asking a public router for minutes after Studio is closed, and the answer
* lands in whatever document the store holds by then.
* The road lookups still running when the panel goes away. fetchRoads walks
* the legs at a deliberate pace, so a twenty-stop map keeps asking a public
* router for minutes, and the answer would land in whatever document the
* store holds by then.
*/
const roadJobs = useRef(new Set<AbortController>())
useEffect(() => () => {
@@ -140,22 +87,6 @@ export function StudioTravelPanel({
roadJobs.current.clear()
}, [])
/*
* The stops whose switch is on its way to the server.
*
* No optimistic flip: which stop is furthest and which countries remain is
* the server's sum, so the row reads its state from the stats the hook
* fetches once the change has landed. Until then the chip is only held,
* so a second click cannot send the opposite of what the first one is
* still delivering.
*/
const [pending, setPending] = useState<Set<number>>(() => new Set())
const centre = (w: number, h: number) => {
const W = single ? page.pageWidth : page.pageWidth * 2
return { x: (W - w) / 2, y: (page.pageHeight - h) / 2, w, h }
}
if (!stats) {
return (
<>
@@ -167,146 +98,17 @@ export function StudioTravelPanel({
)
}
/*
* What the book engine's factories read (studioTravelContext.ts). Every tile
* below is built by the factory the engine's `add` calls. Where it lands is
* the panel's own: centred across the spread, where `add` takes the right page.
*/
const travel = studioTravelContext(stats, path, locale, t)
const at = (size: { w: number; h: number }) => centre(size.w, size.h)
const build = travelBuilders({ page, stats, path, locale, t, theme, single: !!role && role !== 'inner' })
/**
* Country names in the reader's language, not the server's.
*
* The API answers in English, because it has no idea who will read the book.
* `Intl.DisplayNames` is the same CLDR data Atlas already uses for regions, so
* a German book says "Island" and an English one says "Iceland" — from one set
* of codes, with no translation table to keep in step.
*/
const countryName = (code: string): string => bookCountryName(code, locale, stats)
/*
* Each builder returns the finished element. The tile renders it and the
* click places it, so the preview and the result are the same object, and
* the object is the engine's, so there is no second description of what a
* card means.
*/
/** The linked trips that actually put stops on the route. */
const tripsWithRoute = (stats.trips ?? []).filter(t => t.points > 0)
/*
* The route as rows, counting and left-out stops together.
*
* Every point is listed, so the list reads as the route. The left-out
* stops sit among the counting ones in date order rather than in a list of
* their own, because the question the section answers is "which of these
* count?", and that is only answerable with all of them in one sequence.
* Undated stops go last, then by name.
*/
const stops: StopRow[] = [
...stats.points.map(p => ({
entryId: p.entryId, label: p.label, date: p.date, country: p.country, excluded: false,
})),
...(stats.excluded ?? []).map(x => ({
entryId: x.entryId, label: x.label, date: x.date, country: null, excluded: true,
})),
].sort((a, b) => {
if (a.date !== b.date) {
if (a.date == null) return 1
if (b.date == null) return -1
return a.date < b.date ? -1 : 1
}
return a.label.localeCompare(b.label)
})
const excludedCount = stops.filter(s => s.excluded).length
/** Whether the country is worth a badge, or the same word on every row. */
const manyCountries = stats.countries.length > 1
/**
* The day of a stop, without the weekday.
*
* `formatDate` is the journal's format and carries "Tue," because a journal
* entry is about a day somebody lived. A row here is about a leg of a route,
* and it has 236px to hold a place name in as well: the weekday costs a
* third of the badge and answers nothing anybody asks of this list.
*/
const stopDay = (iso: string) =>
new Date(`${iso}T00:00:00Z`).toLocaleDateString(locale, { day: 'numeric', month: 'short', timeZone: 'UTC' })
const toggleStop = async (entryId: number, excluded: boolean) => {
setPending(prev => new Set(prev).add(entryId))
// A refusal is the hook's to report; here it only frees the chip again.
await onToggleStop(entryId, excluded)
setPending(prev => {
const next = new Set(prev)
next.delete(entryId)
return next
})
}
/**
* One country, as the entry a list would have made of it.
*
* The list and the grid are gone: they were compositions somebody else made,
* deciding the order, the spacing and the type size, and the only way to
* change any of that was to not use them. What is left is the same entry —
* name over silhouette, set the same way — as an element you place one at a
* time. Three of them down a page is the list, with the spacing yours; one of
* them at 80mm is a chapter opener, which the list could never be.
*
* The name is editable afterwards, so numbering them "1. Germany",
* "2. Netherlands" is typing rather than a feature.
*/
const countryEl = (code: string): BookElement =>
bookTravelCountries(travel, { id: uid('co'), frame: at(bookTravelSize('countries', page)), codes: [code] })
/**
* A map as the engine's `add` places it, in the style the card offers.
*
* The scope is the journey or one trip. A trip map carries no recorded way:
* the trail is the journey's, and a map fitted to it would frame the whole
* journey around one trip and lose the bow its legs are drawn with.
*/
const mapEl = (
style: 'minimal' | 'outline' | 'dark' | 'paper',
scope: BookMapScope = 'journey',
source: 'vector' | 'relief' | 'satellite' = 'vector',
): BookElement => ({
...bookTravelMap(travel, stats, { id: uid('mp'), frame: at(bookTravelSize('map', page)), scope, source }),
style,
})
/** A mark at the size that suits what it holds, which is the engine's size for its variant. */
const badgeEl = (variant: BookBadgeVariant, value: BadgeValue): BookElement =>
bookTravelBadge({ id: uid('bd'), frame: at(bookTravelSize('badge', page, { variant })), variant, value })
/**
* One figure on its own.
*
* A summary panel is a composition; a single figure is a mark you drop next
* to a photograph. Both are the same element with a different metric list,
* and offering only the composition meant anyone wanting "14 DAYS" beside a
* picture had to place all four and delete three.
*/
const singleEl = (metric: BookMetric): BookElement =>
bookTravelStats(stats, { id: uid('st'), frame: at(bookTravelSize('stats', page)), metrics: [metric] })
/** Place a copy — the previewed element keeps its own id for React. */
/** Place a copy; the previewed element keeps its own id for React. */
const place = (el: BookElement) => {
const placed = { ...el, id: uid(el.kind[0]) } as BookElement
const placed = { ...el, id: uid(el.kind[0] ?? 'e') } as BookElement
addElement(active, placed)
/*
* A map arrives following the roads.
*
* Asked for after the element is on the page rather than before, so the map
* appears at once and the line firms up a moment later: a panel that sat
* there for fifteen seconds after a click would read as broken, and the
* straight line it starts with is the one it would have had anyway.
*
* Legs long enough to have been flights are never sent (see roadRoute.ts),
* the router's own cache answers a second placement of the same journey for
* free, and a failure leaves the leg exactly as it was.
* A map arrives following the roads, asked for after it is on the page so
* it appears at once and the line firms up a moment later. Legs long enough
* to have been flights are never sent (roadRoute.ts), and a failure leaves
* the leg as it was.
*/
if (placed.kind === 'map' && placed.points.length > 1) {
const job = new AbortController()
@@ -314,266 +116,228 @@ export function StudioTravelPanel({
void fetchRoads(placed.points.map(pt => ({ lat: pt.lat, lng: pt.lng })), { signal: job.signal })
.then(roads => {
if (job.signal.aborted) return
if (roads.some(r => r && r.length > 1)) {
updateElement(active, placed.id, { roads } as Partial<BookElement>)
}
if (roads.some(r => r && r.length > 1)) updateElement(active, placed.id, { roads } as Partial<BookElement>)
})
.catch(() => { /* No roads is a state the map already draws. */ })
.finally(() => { roadJobs.current.delete(job) })
}
}
/*
* The marks of the journey, with the words the engine resolves for each. A
* mark the journey cannot fill (no start date, no stop, no country) is not
* offered.
*/
const marks: { el: BookElement; label: string }[] = ([
['date', t('journey.studio.dateMark')],
['day', t('journey.studio.dayMark')],
['coords', t('journey.studio.coordsMark')],
['flag', t('journey.studio.flagMark')],
['distance', t('journey.studio.distanceMark')],
] as const).flatMap(([variant, label]) => {
const value = bookBadgeValue(travel, variant, null)
return value ? [{ el: badgeEl(variant, value), label }] : []
})
return (
<>
<PanelHead label={t('journey.studio.travel')} />
<div className="st-panel-scroll">
<Shelf label={t('journey.studio.singleFigures')}>
<div className="st-travel-grid">
{BOOK_METRICS.map(metric => {
const el = singleEl(metric)
return (
<Card key={metric} label={t(`journey.studio.metric.${metric}`)} onClick={() => place(el)}>
<TravelPreview el={el} minHeight={44} maxHeight={62} />
</Card>
)
})}
</div>
</Shelf>
{/*
The switch is here, beside the maps and the figures it changes, and
not only in the entry editor: the moment somebody notices the home
airport on the printed route is the moment they are looking at this
panel. The rows carry no preview because they are not things to
place; they are the input every tile below reads.
*/}
{stops.length > 0 && (
<Section
label={t('journey.studio.stops')}
hint={t('journey.studio.stopsHint')}
/*
* Folded to begin with, and saying how many count while it is.
*
* A fortnight is fourteen rows, which is longer than everything
* else in this panel put together, and most books never need to
* touch it. The figure in the head is what anyone opening it came
* to check anyway, so the closed section still answers the
* question and the maps below stay in reach.
*/
defaultOpen={false}
badge={`${stops.length - excludedCount}/${stops.length}`}
>
<ul className="st-route-stops">
{stops.map((stop, i) => {
const entryId = stop.entryId
const on = !stop.excluded
const state = t(on ? 'journey.studio.stopOn' : 'journey.studio.stopOff')
const chip = `st-chip is-pick is-icon ${on ? 'is-on' : ''}`
/*
* A mark rather than the word.
*
* "Counts" and "Left out" are different lengths, so a column of
* them jitters, and together they cost a third of a row that
* has a place name to fit. The tick and the crossed-out route
* say the same thing at a glance, and the word is still there
* for anyone who hovers or reads with a screen reader.
*/
const mark = on ? <Check size={13} strokeWidth={2.4} /> : <RouteOff size={13} strokeWidth={2.2} />
return (
<li key={entryId ?? `place-${i}`} className={`st-route-stop ${on ? '' : 'is-off'}`}>
{/*
One line: the day, then the name, then the switch. The
name and the day were stacked, which doubled the height
of a list whose whole job is being read down.
*/}
{stop.date && <span className="st-badge">{stopDay(stop.date)}</span>}
<span className="st-route-stop-label">{stop.label || t('journey.studio.untitled')}</span>
{/*
The country only where there is more than one to tell
apart. On a journey inside one country it is the same
word fourteen times, in the row that has least room.
*/}
{manyCountries && stop.country && (
<span className="st-badge is-quiet">{countryName(stop.country)}</span>
)}
{canEdit && entryId != null ? (
<button type="button"
className={chip}
title={`${state}: ${t('journey.studio.stopToggle')}`}
aria-label={t('journey.studio.stopToggle')}
aria-pressed={on}
disabled={pending.has(entryId)}
onClick={() => void toggleStop(entryId, !stop.excluded)}
>
{mark}
</button>
) : (
<span className={chip} title={state} aria-label={state}>{mark}</span>
)}
</li>
)
})}
</ul>
</Section>
)}
<Shelf label={t('journey.studio.routeMap')}>
{stats.points.length ? (
<>
<div className="st-travel-grid">
{(['minimal', 'outline', 'paper', 'dark'] as const).map(style => {
const el = mapEl(style)
return (
<Card key={style} label={t(`journey.studio.mapStyle.${style}`)} onClick={() => place(el)}>
<TravelPreview el={el} minHeight={62} maxHeight={80} />
</Card>
)
})}
{/*
The two imagery maps, as cards rather than as settings.
They were reachable only by placing an outline map and then
changing its source in the inspector, which is a thing nobody
finds. They are different pictures, not variants of one: relief
is land shaded in its own colours against a dark sea, drawn at
a scale that suits a continent, and satellite is the ground
itself, which is what a page about one country wants.
*/}
{IMAGERY.map(src => (
<Card
key={src.id}
/*
* Satellite is marked rather than merely listed. The three
* imagery options are not equals: relief stops at about six
* hundred metres to the pixel, which is a continent's worth
* of detail and a smudge at country size, and the outlines
* are a diagram. For the page most people are making —
* a route across one or two countries — this is the one
* that prints, and saying so beats letting them find out
* after the book is bound.
*/
label={t(src.labelKey)}
recommended={src.id === 'satellite'}
title={src.id === 'satellite' ? t('journey.studio.recommended') : undefined}
onClick={() => place(mapEl('minimal', 'journey', src.id))}
>
<TravelPreview
el={mapEl('minimal', 'journey', src.id)}
minHeight={62}
maxHeight={80}
/>
</Card>
))}
</div>
{/*
A map per trip, for a journey made of more than one.
Two trips printed as a single route draw a line from the last
stop of the first to the first stop of the second, which is
usually the longest leg on the page and one nobody travelled.
Offered rather than imposed: some journeys really are one route
across several trips, and those still get the map above.
Only trips that have stops on the route are listed — a linked
trip nobody wrote about would place an empty frame.
*/}
{tripsWithRoute.length > 1 && (
<div className="st-shelf-sub">
<h4 className="st-section-label">{t('journey.studio.mapPerTrip')}</h4>
<div className="st-travel-grid">
{tripsWithRoute.map(trip => {
const el = mapEl('minimal', { tripId: trip.id })
return (
<Card
key={trip.id}
label={trip.title || t('journey.studio.untitled')}
onClick={() => place(el)}
>
<TravelPreview el={el} minHeight={62} maxHeight={80} />
</Card>
)
})}
</div>
</div>
)}
</>
) : (
<p className="st-hint">{t('journey.studio.noRoute')}</p>
<StudioTabs<Tab>
idBase={tabsId}
label={t('journey.studio.travel')}
value={tab}
onChange={setTab}
columns={2}
tabs={TABS.map(x => ({ id: x.id, icon: x.icon, label: t(x.labelKey) }))}
/>
<StudioTabPanel idBase={tabsId} value={tab} className="st-tab-body">
<div className="st-panel-scroll">
{tab === 'figures' && <FiguresTab build={build} t={t} place={place} />}
{tab === 'maps' && (
<MapsTab build={build} stats={stats} t={t} place={place}>
<TravelStops
stats={stats} t={t} locale={locale} canEdit={canEdit}
onToggleStop={onToggleStop} countryName={build.countryName}
/>
</MapsTab>
)}
</Shelf>
<Shelf label={t('journey.studio.countries')}>
{stats.countries.length ? (
<>
{/*
One country per element, rather than a block listing them all.
A list of countries is a composition somebody else made: it
decides the order, the spacing and the type size, and the only
way to change any of it is to not use it. Placed one at a time
they are ordinary elements — numbered, moved, set at different
sizes, put on different pages — which is what a book of a trip
through several countries actually wants.
*/}
<div className="st-travel-grid">
{stats.countries.slice(0, 12).map(c => {
const el = countryEl(c.code)
return (
<Card key={c.code} label={countryName(c.code)} onClick={() => place(el)}>
<TravelPreview el={el} minHeight={44} maxHeight={64} />
</Card>
)
})}
</div>
{/*
And the same country as a mark: the outline beside the name
rather than under it, at the size of a line of type. It belongs
next to a date or a coordinate, not on a page of its own.
*/}
<div className="st-travel-grid" style={{ marginTop: 6 }}>
{stats.countries.slice(0, 12).map(c => {
const el = badgeEl('country', bookCountryMark('country', c.code, countryName(c.code)))
return (
<Card key={`mark-${c.code}`} label={countryName(c.code)} onClick={() => place(el)}>
<TravelPreview el={el} minHeight={30} maxHeight={44} />
</Card>
)
})}
</div>
</>
) : (
<p className="st-hint">{t('journey.studio.noCountries')}</p>
)}
</Shelf>
<Shelf label={t('journey.studio.marks')}>
<div className="st-travel-grid">
{marks.map(({ el, label }) => (
<Card key={el.id} label={label} onClick={() => place(el)}>
<TravelPreview el={el} minHeight={34} maxHeight={52} />
</Card>
))}
</div>
</Shelf>
</div>
{tab === 'countries' && <CountriesTab build={build} stats={stats} t={t} place={place} />}
{tab === 'marks' && <MarksTab build={build} t={t} place={place} />}
</div>
</StudioTabPanel>
</>
)
}
type Build = ReturnType<typeof travelBuilders>
interface TabProps {
build: Build
t: (k: string) => string
place: (el: BookElement) => void
}
/** The summary in its four looks, full width, and every figure on its own as a medal. */
function FiguresTab({ build, t, place }: TabProps) {
return (
<>
<Shelf label={t('journey.studio.summary')}>
<div className="st-travel-grid is-wide">
{SUMMARY_LOOKS.map(look => (
<TravelCard
key={look}
el={build.summaryEl(look)}
label={t(`journey.studio.statsLook.${look}`)}
onPlace={place}
minHeight={look === 'band' ? 30 : 70}
maxHeight={look === 'band' ? 40 : 120}
inset={look === 'note' || look === 'sketch' ? 0.05 : 0}
/>
))}
</div>
</Shelf>
{/* Each figure on its own, in the looks that suit a single figure: a medal, an odometer, a ring drawn by hand. */}
{FIGURE_SHELVES.map(({ look, metrics }) => (
<Shelf key={look} label={`${t('journey.studio.singleFigures')}: ${t(`journey.studio.statsLook.${look}`)}`}>
<div className={`st-travel-grid ${look === 'odometer' ? 'is-wide' : ''}`}>
{metrics.map(metric => (
<TravelCard
key={metric}
el={build.figureEl(metric, look)}
label={t(`journey.studio.metric.${metric}`)}
onPlace={place}
minHeight={look === 'odometer' ? 50 : 80}
maxHeight={look === 'odometer' ? 64 : 104}
inset={look === 'odometer' ? 0 : 0.06}
/>
))}
</div>
</Shelf>
))}
</>
)
}
/**
* The route: drawn with its photographs, its stops numbered, its two ends
* marked or on a dark ground, then over satellite and relief imagery; a map
* per trip for a journey of several; and the stops it is drawn from.
*/
function MapsTab({ build, stats, t, place, children }: TabProps & { stats: JourneyStats; children: ReactNode }) {
if (!stats.points.length) return <p className="st-hint">{t('journey.studio.noRoute')}</p>
// Only the trips that put stops on the route: a linked trip nobody wrote about would place an empty frame.
const trips = (stats.trips ?? []).filter(trip => trip.points > 0)
return (
<>
<Shelf label={t('journey.studio.routeMap')}>
<div className="st-travel-grid">
{MAP_CARDS.map(card => (
<TravelCard key={card.id} el={build.mapEl(card)} label={t(card.labelKey)} onPlace={place} minHeight={70} maxHeight={92} />
))}
{IMAGERY.map(src => (
<TravelCard
key={src.id}
el={build.mapEl({ style: 'minimal' }, 'journey', src.id)}
label={t(src.labelKey)}
onPlace={place}
minHeight={70}
maxHeight={92}
recommended={src.id === 'satellite'}
title={src.id === 'satellite' ? t('journey.studio.recommended') : undefined}
/>
))}
</div>
{/*
A map per trip: two trips printed as one route draw a line from the
last stop of one to the first of the next, a leg nobody travelled.
*/}
{trips.length > 1 && (
<div className="st-shelf-sub">
<h4 className="st-section-label">{t('journey.studio.mapPerTrip')}</h4>
<div className="st-travel-grid">
{trips.map(trip => (
<TravelCard
key={trip.id}
el={build.mapEl({ style: 'minimal', pins: 'numbered' }, { tripId: trip.id })}
label={trip.title || t('journey.studio.untitled')}
onPlace={place}
minHeight={70}
maxHeight={92}
/>
))}
</div>
</div>
)}
</Shelf>
{/* Each country on its own, its stops alone, cut to its shape or in a box. */}
{stats.countries.slice(0, 6).map(c => (
<Shelf key={c.code} label={build.countryName(c.code)}>
<div className="st-travel-grid">
{COUNTRY_MAPS.map(m => (
<TravelCard
key={m.kind}
el={build.countryMapEl(c.code, m.kind)}
label={t(m.labelKey)}
onPlace={place}
minHeight={80}
maxHeight={104}
inset={m.kind === 'box' ? 0 : 0.04}
/>
))}
</div>
</Shelf>
))}
{children}
</>
)
}
/** The maps of one country: cut to its shape on the paper, cut to it over the satellite picture, and in a box. */
const COUNTRY_MAPS = [
{ kind: 'shape', labelKey: 'journey.studio.mapClipCountry' },
{ kind: 'satellite', labelKey: 'journey.studio.mapSourceSatellite' },
{ kind: 'box', labelKey: 'journey.studio.mapClipRect' },
] as const
/** Each country in every look, one shelf per country: a stamp, a visa, a postcard, its flag, a pin, a label, a sketch. */
function CountriesTab({ build, stats, t, place }: TabProps & { stats: JourneyStats }) {
if (!stats.countries.length) return <p className="st-hint">{t('journey.studio.noCountries')}</p>
return (
<>
{stats.countries.slice(0, 12).map(c => (
<Shelf key={c.code} label={build.countryName(c.code)}>
<div className="st-travel-grid">
{COUNTRY_LOOKS.map(look => (
<TravelCard
key={look}
el={build.countryEl(c.code, look)}
label={t(`journey.studio.countryLook.${look}`)}
onPlace={place}
minHeight={84}
maxHeight={100}
inset={look === 'postcard' ? 0.04 : 0.09}
/>
))}
</div>
</Shelf>
))}
</>
)
}
/** The marks the journey fills, as stamps, tags and pills. A mark it cannot fill is not offered. */
function MarksTab({ build, t, place }: TabProps) {
const label = (variant: (typeof MARK_VARIANTS)[number]) => t(`journey.studio.${variant}Mark`)
return (
<>
{MARK_STYLES.map(({ style, variants }) => {
const cards = variants.flatMap(variant => {
const el = build.markEl(variant, style)
return el ? [{ el, variant }] : []
})
if (!cards.length) return null
return (
<Shelf key={style} label={t(`journey.studio.markStyle.${style}`)}>
<div className="st-travel-grid">
{cards.map(({ el, variant }) => (
<TravelCard
key={variant}
el={el}
label={label(variant)}
onPlace={place}
minHeight={style === 'stamp' ? 56 : 38}
maxHeight={style === 'stamp' ? 76 : 54}
inset={style === 'stamp' || style === 'sticker' ? 0.12 : 0.08}
/>
))}
</div>
</Shelf>
)
})}
</>
)
}
+43 -222
View File
@@ -1,20 +1,21 @@
import type { CSSProperties } from 'react'
import type {
BookBadgeElement, BookCountriesElement, BookElement, BookListElement, BookMapElement,
BookMetric, BookStatsElement, BookUnits,
BookStatsElement,
} from '@trek/shared'
import {
Camera, CalendarDays, Footprints, Globe, MapPin, Navigation, Route,
type LucideIcon,
} from 'lucide-react'
import { fontStack, lighterWeight, nearestWeight } from './bookFonts'
import { fontStack } from './bookFonts'
import { heavy, inkAt as rgba, light, METRIC_ICONS, metricValue, round2 } from './travel/travelInk'
import { BadgeLookView } from './travel/BadgeLooks'
import { CountryLookView } from './travel/CountryLooks'
import { StatsLookView } from './travel/StatsLooks'
import { badgeIconShare, badgeInk, inkOn, isBadgeLook } from './travel/badgeLook'
import { BadgeIcon } from './travel/BadgeIcon'
import { EndMarker } from './travel/EndMarker'
import { FlagMark } from './travel/FlagMark'
import { haversineKm } from '../../utils/geo'
import { brightness } from './folioColour'
import { COUNTRY_SHAPES, countryParts, countryWorldPath, projectMercator, unprojectMercator } from './countryShapes'
import { projectOntoTiles, tileView, usableStaticUrl } from './mapTiles'
import { useCartoApiKey } from '../../hooks/useTileUrl'
import { FLAG_H, FLAG_W, flagBands, flagDisc, flagSpec } from './flags'
import { MOOD_CONFIG, WEATHER_CONFIG } from '../../pages/journeyDetail/JourneyDetailPage.constants'
import { useTranslation } from '../../i18n'
/**
@@ -71,8 +72,6 @@ function typeSize(el: TravelElement, base: number): number {
return Math.max(1.2, el.frame.h * base * el.textScale)
}
/** Two decimals of a millimetre, matching what the document stores. */
const round2 = (n: number) => Math.round(n * 100) / 100
/** Tile geometry, kept finer than the rest: see the note where it is used. */
const round3 = (n: number) => Math.round(n * 1000) / 1000
@@ -100,12 +99,6 @@ const round3 = (n: number) => Math.round(n * 1000) / 1000
*/
const tileOverlapMm = (size: number) => Math.max(0.8, size * 0.04)
/** #rrggbb plus an alpha. */
function rgba(hex: string, alpha: number): string {
const n = Number.parseInt(hex.slice(1), 16)
return `rgba(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}, ${alpha})`
}
export function TravelElementView({ el, frameStyle, big = false }: {
el: BookElement
frameStyle: CSSProperties
@@ -113,9 +106,22 @@ export function TravelElementView({ el, frameStyle, big = false }: {
big?: boolean
}) {
if (el.kind === 'map') return <MapView el={el} frameStyle={frameStyle} big={big} />
if (el.kind === 'stats') return <StatsView el={el} frameStyle={frameStyle} />
if (el.kind === 'countries') return <CountriesView el={el} frameStyle={frameStyle} />
if (el.kind === 'badge') return <BadgeView el={el} frameStyle={frameStyle} />
// The newer looks draw in files of their own; `classic` is what every book had before them.
if (el.kind === 'stats') {
return (el.look ?? 'classic') === 'classic'
? <StatsView el={el} frameStyle={frameStyle} />
: <StatsLookView el={el} frameStyle={frameStyle} />
}
if (el.kind === 'countries') {
return (el.look ?? 'classic') === 'classic'
? <CountriesView el={el} frameStyle={frameStyle} />
: <CountryLookView el={el} frameStyle={frameStyle} />
}
if (el.kind === 'badge') {
return isBadgeLook(el.style)
? <BadgeLookView el={el} frameStyle={frameStyle} />
: <BadgeView el={el} frameStyle={frameStyle} />
}
if (el.kind === 'list') return <ListView el={el} frameStyle={frameStyle} />
return null
}
@@ -132,17 +138,6 @@ export function TravelElementView({ el, frameStyle, big = false }: {
* a slab of ink for no reason. What distinguishes the four styles is how the
* land is drawn, which is enough.
*/
/**
* How heavy the type is set, and one step down for the labels under it.
*
* These elements draw at two levels at once — a figure over its caption, a
* country's name over its outline — and the pair has to move together, the way
* `textScale` moves their size. Hard-coded 700s meant the only way to lighten a
* stats block was to not use one.
*/
const heavy = (el: { font: string; weight: number }) => nearestWeight(el.font, el.weight)
const light = (el: { font: string; weight: number }) => lighterWeight(el.font, el.weight)
const MAP_PALETTES = {
minimal: { land: '#e9e5dc', border: '#d6d0c4', onDark: false },
outline: { land: 'transparent', border: '#b9b3a6', onDark: false },
@@ -160,28 +155,6 @@ const MAP_PALETTES = {
*/
const DEFAULT_ACCENT = '#111111' // theme-lint-disable — the contract's default, matched here
/** Ink for a mark that picks its own, and its opposite. */
const BADGE_DARK_INK = '#1c1b19' // theme-lint-disable — ink on a printed page, not a UI token
const BADGE_LIGHT_INK = '#ffffff' // theme-lint-disable — paper white on a filled chip
/**
* What colour a mark's words are.
*
* Automatic unless the element says otherwise, and what automatic means depends
* on what the mark is: a chip carries its words on its own fill, so they answer
* to the fill's brightness; a day or a date drawn plain takes the accent,
* because that is the figure the page is built around. Anything else is
* ordinary text in the text colour.
*
* The moment somebody picks a colour this steps aside completely. That is the
* point of the flag: automatic is a good default and a bad cage.
*/
function badgeInk(el: BookBadgeElement): string {
if (!el.autoColor) return el.color
if (el.style === 'chip') return brightness(el.accent) > 0.55 ? BADGE_DARK_INK : BADGE_LIGHT_INK
if (el.variant === 'day' || el.variant === 'date') return el.accent
return el.color
}
function routeInk(accent: string, onDark: boolean): string {
if (!onDark) return accent
return accent.toLowerCase() === DEFAULT_ACCENT ? '#f2efe9' : accent // theme-lint-disable — paper on a dark map
@@ -512,6 +485,8 @@ function MapView({ el, frameStyle, big = false }: {
const S = Math.min(W, H)
const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v))
const drawn = el.routeStyle === 'drawn'
// Numbered stops and marked ends are markers of the drawn kind, whatever the line is drawn as.
const drawnPins = drawn || el.pinStyle === 'numbered' || el.pinStyle === 'ends'
const coreW = clamp(S * 0.0055, 0.32, 1.3)
const casingW = coreW * 1.9
const beadR = clamp(coreW * 3.1, 1.7, 4.8)
@@ -528,7 +503,7 @@ function MapView({ el, frameStyle, big = false }: {
* so it inverts with it: dark on a light line, light on a dark one. Measured
* the same way the marks decide their ink.
*/
const numInk = brightness(el.accent) > 0.55 ? '#1c1b19' : '#ffffff' // theme-lint-disable — print ink
const numInk = inkOn(el.accent)
const dashOn = coreW * 2.4
const dashOff = coreW * 1.8
@@ -748,16 +723,16 @@ function MapView({ el, frameStyle, big = false }: {
*/
const beads: { i: number; x: number; y: number; photo: number | null }[] = []
let numbered = false
if (drawn) {
if (drawnPins) {
// Too small to carry photographs at all: a bead wider than a twelfth of the
// frame is a picture with a map around it.
const roomForPhotos = el.pinStyle === 'photo' && big && beadR * 2 <= S * 0.16
/*
* Numbers stand in for the photographs that are not there, so they belong
* to the photo treatment alone. A map asked for dots gets dots, and a
* thumbnail gets dots too: two digits at 130px is a smudge.
* Numbers stand in for the photographs that are not there, and a map asked
* for numbers numbers every stop. A map asked for dots gets dots, and a
* photo map's thumbnail gets dots too: two digits at 130px is a smudge.
*/
numbered = el.pinStyle === 'photo' && big && numR * 2 <= S * 0.12
numbered = (el.pinStyle === 'numbered' || (el.pinStyle === 'photo' && big)) && numR * 2 <= S * 0.12
let last: { x: number; y: number } | null = null
route.forEach((pt, i) => {
const photoId = (el.points[i] as { photoId?: number | null } | undefined)?.photoId ?? null
@@ -979,7 +954,7 @@ function MapView({ el, frameStyle, big = false }: {
</>
)}
{el.showPins && !drawn && route.map((p, i) => (
{el.showPins && !drawnPins && route.map((p, i) => (
<circle
key={i}
cx={p.x}
@@ -1003,8 +978,10 @@ function MapView({ el, frameStyle, big = false }: {
is also what a photograph that fails to load degrades to, so the paper
shows a deliberate marker rather than a hole.
*/}
{el.showPins && drawn && beads.map(m => (
m.photo != null ? (
{el.showPins && drawnPins && beads.map(m => (
el.pinStyle === 'ends' && (m.i === 0 || m.i === route.length - 1) ? (
<EndMarker key={m.i} x={m.x} y={m.y} r={numR} finish={m.i > 0} ink={coreInk} paper={numInk} />
) : m.photo != null ? (
<g key={m.i}>
{/* The shadow, as two translucent discs rather than a filter. */}
<circle cx={m.x} cy={m.y + coreW * 0.3} r={beadR + coreW * 0.55} fill="#000000" fillOpacity={0.1} />
@@ -1127,49 +1104,6 @@ function MapView({ el, frameStyle, big = false }: {
/* ── Stats ────────────────────────────────────────────────────────────── */
/**
* The icon beside each figure.
*
* lucide, like every other icon in TREK — a hand-drawn set was the first pass
* and it was the wrong call twice over: it put a second drawing vocabulary next
* to the one the whole app already uses, and it meant seven paths nobody would
* ever revisit when lucide gained a better glyph.
*
* They size in millimetres despite lucide taking a pixel `size`, because an
* inline width and height override the attributes it writes while the viewBox
* stays 24×24 — so the stroke scales with the drawing instead of thinning out
* on a large panel.
*/
const METRIC_ICONS: Record<BookMetric, LucideIcon> = {
distance: Route,
days: CalendarDays,
steps: Footprints,
photos: Camera,
countries: Globe,
places: MapPin,
furthest: Navigation,
}
function metricValue(
metric: BookMetric,
values: Record<string, number>,
units: BookUnits,
locale: string,
): { value: string; unit: string } {
const raw = values[metric] ?? 0
const n = (v: number, digits = 0) =>
new Intl.NumberFormat(locale, { maximumFractionDigits: digits }).format(v)
if (metric === 'distance' || metric === 'furthest') {
// Stored in metres and converted here, so switching units never rounds a
// second time on top of a rounded number.
const km = raw / 1000
const shown = units === 'imperial' ? km * 0.621371 : km
return { value: n(shown, shown < 100 ? 1 : 0), unit: units === 'imperial' ? 'mi' : 'km' }
}
return { value: n(raw), unit: '' }
}
/**
* The trip summary — the page Polarsteps' books open on, and the one that makes
* a book feel like a record of a journey rather than a folder of pictures.
@@ -1404,75 +1338,6 @@ function CountriesView({ el, frameStyle }: { el: BookCountriesElement; frameStyl
)
}
/**
* A flag, drawn.
*
* The first version set a regional-indicator pair and let the platform draw it.
* That is the standard trick and it is wrong for most of the people who will
* use this: Chrome on Windows ships no flag glyphs, so it renders the two
* letters, and "DE" printed in a photo book is not a flag.
*
* So flags are constructed from `flags.ts` — exact for the stripe, cross and
* disc constructions listed there, and absent for anything with an emblem
* rather than approximated. A country with no construction falls back to its
* silhouette, which is correct for all 198 of them.
*/
function FlagMark({ code, size }: { code: string; size: number }) {
const spec = flagSpec(code)
const height = size
const width = (height * FLAG_W) / FLAG_H
if (spec) {
const disc = flagDisc(spec)
return (
<svg
width={`${round2(width)}mm`}
height={`${round2(height)}mm`}
viewBox={`0 0 ${FLAG_W} ${FLAG_H}`}
style={{ display: 'block', flex: '0 0 auto', borderRadius: `${round2(height * 0.08)}mm` }}
aria-label={code.toUpperCase()}
>
{flagBands(spec).map((b, i) => (
<rect key={i} x={b.x} y={b.y} width={b.w} height={b.h} fill={b.fill} />
))}
{disc && <circle cx={disc.cx} cy={disc.cy} r={disc.r} fill={disc.fill} />}
{/* A hairline keeps a white-edged flag off a white page. */}
<rect x="0" y="0" width={FLAG_W} height={FLAG_H} fill="none" stroke="rgba(0,0,0,.18)" strokeWidth="0.5" />
</svg>
)
}
const shape = COUNTRY_SHAPES[code.toUpperCase()]
if (!shape) {
// Neither a construction nor an outline: the code, set as a mark rather
// than as two stray capitals.
return (
<span
style={{
fontSize: `${round2(size * 0.62)}mm`,
fontWeight: 700,
letterSpacing: '0.08em',
lineHeight: 1,
display: 'inline-block',
}}
>
{code.toUpperCase()}
</span>
)
}
return (
<svg
width={`${round2((height * shape.w) / shape.h)}mm`}
height={`${round2(height)}mm`}
viewBox={`0 0 ${shape.w} ${shape.h}`}
style={{ display: 'block', flex: '0 0 auto' }}
aria-label={code.toUpperCase()}
>
<path d={shape.d} fill="currentColor" />
</svg>
)
}
/* ── Badges ───────────────────────────────────────────────────────────── */
/**
@@ -1495,7 +1360,6 @@ function BadgeView({ el, frameStyle }: { el: BookBadgeElement; frameStyle: CSSPr
*/
const big = typeSize(el, el.variant === 'date' ? 0.52 : 0.42)
const small = typeSize(el, 0.2)
const shape = el.code ? COUNTRY_SHAPES[el.code.toUpperCase()] : null
const stacked = el.style === 'stacked' || el.variant === 'date'
const chip = el.style === 'chip'
@@ -1511,7 +1375,6 @@ function BadgeView({ el, frameStyle }: { el: BookBadgeElement; frameStyle: CSSPr
*/
const showIcon = el.showIcon !== false
const showLabel = el.showLabel !== false
const iconTint = (automatic: string) => (el.autoIconColor === false ? el.iconColor : automatic)
/*
* A mark with no words is a pictogram, and a pictogram should fill its box.
* Beside a label the icon is a share of the height; on its own it takes four
@@ -1544,54 +1407,12 @@ function BadgeView({ el, frameStyle }: { el: BookBadgeElement; frameStyle: CSSPr
overflow: 'hidden',
}}
>
{el.variant === 'flag' && el.code && showIcon && (
<FlagMark code={el.code} size={el.frame.h * (showLabel ? 0.5 : iconScale)} />
)}
{/*
Mood and weather come from the journal entry, and they are drawn with
the *same* icon and the same palette the journey page uses — imported
rather than re-listed, so a mood added to TREK appears here without
anyone remembering to add it twice.
The picture: a flag, an outline, or the journal's own mood and weather
icons, imported rather than re-listed so a mood added to TREK appears
here without anyone adding it twice.
*/}
{(el.variant === 'mood' || el.variant === 'weather') && el.code && showIcon && (() => {
// Kept apart rather than narrowed out of one union: only a mood has a
// colour of its own, and `'text' in config` types it as unknown.
const mood = el.variant === 'mood' ? MOOD_CONFIG[el.code] : null
const config = mood ?? WEATHER_CONFIG[el.code]
if (!config) return null
const Icon = config.icon
const size = el.frame.h * iconScale
/*
* Automatic is the journal's own palette for a mood and the element's
* accent for the weather. Note the colour has to be passed rather than
* inherited: lucide writes it onto the SVG's stroke, so `currentColor`
* from the wrapper never reaches it — which is why the Colour swatch
* appeared to do nothing to a mood icon.
*/
const tint = iconTint(mood ? mood.text : el.accent)
return (
<Icon
color={tint}
strokeWidth={1.7}
style={{ width: `${round2(size)}mm`, height: `${round2(size)}mm`, display: 'block', flex: '0 0 auto' }}
/>
)
})()}
{el.variant === 'country' && shape && showIcon && (() => {
const h = el.frame.h * (showLabel ? 0.72 : iconScale)
return (
<svg
width={`${round2((h * shape.w) / shape.h)}mm`}
height={`${round2(h)}mm`}
viewBox={`0 0 ${shape.w} ${shape.h}`}
style={{ display: 'block', flex: '0 0 auto' }}
>
<path d={shape.d} fill={iconTint(el.accent)} />
</svg>
)
})()}
{showIcon && <BadgeIcon el={el} height={el.frame.h * (showLabel ? badgeIconShare(el.variant) : iconScale)} />}
{el.text && showLabel && (
<span

Some files were not shown because too many files have changed in this diff Show More