Compare commits

..

25 Commits

Author SHA1 Message Date
Maurice 322d5d5209 test(map): the phone hook mock keeps the number of hooks stable
The mock called the real useIsPhone only while a test left the answer open,
so a map still mounted when the answer was reset rendered with one hook more
and React threw. It now calls the real hook every time and overrides only its
answer.
2026-11-08 00:05:54 +01:00
Maurice 0346f9c13d refactor(mobile): the file preview's heading as its own part
The type tile beside the name took the preview pane past the complexity
limit; the heading (tile, name, type, wallet, size, date, uploader) is now a
part of its own and the pane reads as before.
2026-11-08 00:04:47 +01:00
Maurice dfd87d413e fix(atlas): measure the opening pan only where there is chrome to clear
The opening view asked Leaflet for the north edge's point on every atlas,
though only the touch shell's rail and header need a pan. The desktop atlas
now opens exactly as before, without the call, which its map mocks also
never had.
2026-11-08 00:02:20 +01:00
Maurice 27aab5c783 test(journey): wait for the dropped create parameter to reach the URL
The draft opens and the parameter goes in the same effect, but the URL shows
it a render later. Under CI load the check ran in between and saw
?create=entry; it now waits for the empty search like it waits for the draft.
2026-11-07 23:59:38 +01:00
Maurice a7cc1b9e57 fix(mobile): the checks the large-screen merge left red
- The trip's segmented control writes its fit through a ref, not into the
  element held in state (react-hooks/immutability).
- The amount field of a payment uses the body type tier, which follows the
  text size setting, instead of a raw size.
- The form sheet tests wait the frame the edge fade now measures on, and read
  the wide variant from the size class the shell writes.
- The native dashes spec lists its locales through node:fs, so shared needs no
  vite types for it (knip).
- Baselines lowered where the fixes removed warnings: no-misused-promises,
  no-non-null-assertion in tests, the MSheet rtl entry.
2026-11-07 23:56:33 +01:00
Maurice 8955790f17 fix(mobile): the trip's screens on an iPad and an unfolded foldable, nothing cut or covered
- Top bar: the segmented control keeps to the room between the back button
  and the trailing button, draws its segments tighter and then scrolls with
  faded edges instead of covering the back button. On a wide window every
  list tab puts its primary pill in the middle and its round buttons at the
  trailing end, so the pill no longer jumps between tabs.
- Day strip: a chevron over the faded end pages the days along on a wide
  window, and the fade reaches further; the chips keep clear of it.
- Plan pane: the day card ends on the rail's bottom line beside the map (as
  tall as its day across the page), scrolls inside a frame and fades the
  rows at its edges. Addresses and the hotel chip end in an ellipsis instead
  of being cut mid-letter; the weather chip keeps its width.
- Map: in the dark theme the map beside the pane takes the dark basemap.
- Places: the All / Unplanned / Planned chips are never narrower than their
  names and scroll with a faded edge; on a short screen Places/Tours shares
  their row.
- Chat: the list fades out under the floating controls instead of cutting a
  bubble. Notes and to-do chip rows fade at their edges and New Note stays
  outside the scrolling chips.
- Costs: the balance amounts share a line, the settle-up action wraps to a
  line of its own, the totals stand under their labels.
- Bookings and transports: one column until two cards fit, titles take two
  lines with the type under them, the time cell is never cut and a range
  names its unit once.
- Files: the preview pane shows a type tile beside the name where no PDF can
  be drawn in place, its actions keep their labels on one line and wrap as a
  row, link labels are badges, and a tile that opens its file is no toggle.
- Polls get a fill that differs from card and track, packing filters are two
  segmented controls, empty checkboxes get an edge with 3:1 contrast, and the
  middle dots between values are gone.
2026-11-07 23:25:16 +01:00
Maurice 61d925f3dd fix(mobile): the chat watches its list once it exists, keeps its draft, and the desktop map opens as before
- useCollabChatData: the resize observer that keeps the chat at its newest
  message attaches when the list is drawn (after the messages load), not at
  mount when there is no list yet. The test renders the real list after load.
- MCollabTab: the chat stays mounted, hidden, while a board fills the page on
  its own, so leaving two panes with Notes picked keeps the half typed
  message and the scroll position. The links card hugs its links on a wide
  window instead of a page-high empty card.
- openingView: the floored opening zoom and the second fit for the map's own
  size apply only beside the pane in the touch shell; the desktop planner
  and the phone open at Leaflet's nearest level again.
2026-11-07 23:25:16 +01:00
Maurice bf00412863 fix(mobile): Arabic reads in its direction: arrows turn round, Latin names keep their ends, figures stay whole
In the touch shell the back, forward, disclosure, date-range and flow
arrows kept pointing right in Arabic, so Settle up showed the debt the
wrong way round and Vacay's year arrows pointed inward. The shell now
mirrors lucide's direction arrows in right-to-left languages; an icon
that names its own treatment keeps it, and the lightbox arrows stay on
their physical sides.

Latin user text in Arabic rows lost its beginning when cut and moved its
punctuation to the wrong end. Names, titles, addresses and file names take
their direction from their own text (dir=auto) and still line up with the
row; markdown paragraphs, chat messages, poll options and journal stories
are isolated; signed balances, amounts, sizes, rates and leg figures read
as one left-to-right run; URL fields read left to right. Name pairs joined
by an arrow isolate each name and point from the first to the second.

The hero progress and poll result bars fill from the start edge. Arabic
and Hebrew uppercase caption labels grow a step and take the muted ink,
and a todo's due date in a notification is written in the reader's
language instead of as YYYY-MM-DD.
2026-11-07 23:25:16 +01:00
Maurice 2a6c771ad9 fix(native): the first screens fit a phone on its side, fill an upright iPad, and read right in Arabic and Russian
On a phone or a folded foldable turned landscape the welcome, address,
TREK Cloud and plan screens were taller than the glass: the second choice
and the main button sat below the edge and Back was cut at the top. There
the explanation and the choices now stand side by side, the explanation
pinned while a long plan list scrolls. Every screen centres while it fits
and starts at the top when it does not.

Upright on an iPad, half of one in Split View and an unfolded foldable,
the photo spans the full width and fades only downwards, and the choices
stand centred in the room below it. Buttons and fields are pills like the
app's, choice cards take the app's card radius, and headings and subtitles
balance their lines.

In Arabic (and Hebrew, which never ran right to left here) chevrons point
along the reading direction and sit at the far edge of their card, the
Cloud lockup stays unmirrored on the leading edge, prices and addresses
read left to right, and a plan's period no longer touches its price. The
slides follow the reading direction. Russian gets Cyrillic stand-ins under
the same family names, so the headline keeps a rounded face.
2026-11-07 23:25:15 +01:00
Maurice 641b1882b5 fix(i18n): the app's first screens read without dashes in every language
The welcome subtitle and the TREK Cloud texts used an em dash as a pause in
all locales. Each is reworded with a comma, a colon or a new clause, and a
spec keeps a dash from coming back in any locale's native strings.
2026-11-07 23:25:15 +01:00
Maurice 5346182807 fix(mobile): the rail, the docks and the top-level screens on a large screen
From the large-screen review (visual findings of the shell and the top-level
screens):

- Rail: 80px wide, one caption size for every name (no shrinking per item),
  balanced two-line names with every entry as tall as the tallest, short rail
  names where a language's word is too long (mobileNav.rail*, all locales),
  and near-opaque glass so a map's colours no longer smear through it.
- Rail head: always one 56px circle, so the entries never jump between pages:
  the create action, the screen's own action (the vacation calendar's
  year/editor switch moves there instead of floating over the days) or the
  quiet logo on settings and administration. It stays put while a short rail
  scrolls.
- Vacation calendar: weekday letters over each month on a wide window; the
  editor's mode switch sits at the foot with room kept for it.
- Docks: More is marked with a ring rather than the centre button's fill, the
  dock glass is denser, and lists fade out under the dock instead of showing
  in the gap below it.
- Dashboard: trip names get the card's width with the dates under them, the
  hero's facts wrap as whole chips, the filter chips scroll with a fade, the
  hero ends above the dock on a phone on its side and its buttons sit on a
  dark ground; the header bar stands in the content's column.
- Journeys: on a wide window the header is the same glass bar, in the column
  of the cards, and the other journeys' label starts with them.
- Notifications: the round chevron back button, in the list's column.
- Settings and administration share one frame and width. Admin's new-user
  action sits under the section list's title, the users card header wraps
  inside the card, stat labels ellipsize, role badges read in dark mode.
- Collections: one card per place with the status inside it, worded in a
  readable mix of its colour.
- Atlas and the journey map frame clear of the rail and the header; the
  journey's day bar sits on a glass plate, and a phone on its side gets
  smaller cards.
- Rail label tests rewritten for the new rule; FE-MOB-PANES-016 duplicate
  renumbered to 017.
2026-11-07 23:25:15 +01:00
Maurice 4dfccaa2a4 fix(native): one size class the keyboard cannot move, shortcuts that wait for sheets
From the large-screen review (code findings H1, M1, M2, M3, M4, M7, L1, L3, L4, L6, L7):

- The size class is one value in JavaScript (hooks/sizeClass.ts) and the
  stylesheet follows it: MobileShell writes it to the shell as data-size, the
  wide:/split: variants and mobile.css key on that instead of media queries of
  their own. A window that only grows shorter at the same width while a field
  has focus is Android's soft keyboard, not a new window, so the rail, the form
  sheets and the panes no longer fold away (and take the focused field with
  them) while one types.
- The large-screen layout is the app's only. In a browser the touch shell is
  the phone's below 768px and stays compact there, as before this branch.
- Shortcuts are the app's only, so a browser keeps Ctrl+1 and Alt+Left. The
  command key is the platform's own (Cmd on Apple, Ctrl elsewhere), digits are
  matched by the key's place as well (AZERTY), and nothing runs while a sheet
  or another modal dialog is open: the page listens to nothing and the app is
  handed no key commands, so Cmd+[ can no longer drop an unsaved form.
- Inside a trip Cmd+N belongs to the trip shell and creates what the tab on
  show creates, named after it.
- Inside the touch shell the desktop navbar of the few PageShell routes is
  hidden and --nav-h is 0.
- The rail comes before the page in the DOM, so a keyboard reaches it first.
- The phone's deep day colour for a journey card without a photo stays the
  phone's; the desktop and shared timelines keep their faint wash. Its card is
  opaque, a suggestion included, so the map's names do not read through.
- The trip widget's iPad mini layout keys on the iPad mini's width alone.
- The trip dock gets the scroll-edge strip and the denser dock glass the
  global dock has.
- Duplicate test id FE-MOB-PANES-016 renumbered.
2026-11-07 23:25:14 +01:00
Maurice 18a16d2aa2 fix(mobile): forms on the shared form sheet, and the details the review found
Expense, payment, booking, transport, day note, place edit, new trip,
journey entry, collab note, poll, task, packing item and packing import
use the form sheet: one header with its icon tile, a body that scrolls
with a fade, the footer, and full height with the actions beside the
title on a short window. Create New Trip loses its nested section cards.

Details:
- Add payment gets its icon tile and the currency inside the amount field.
- The expense's category field has the same fill as every other field.
- Day and date fields keep their year at phone width; From and To stack
  in a narrow form.
- The place sheet's category chip sits under the name, uncut, the photo
  on the cards' leading edge, labels in the sheets' small capitals.
- Stars no longer keep a hover preview after a tap.
- A hotel booking shows its stay's check-in and check-out when it has
  none of its own, instead of the grey hint reading as a saved time.
- The collab note's title field is labelled Title (common.title).
- Shorter airport hint in German and Russian; long hints end in an ellipsis.
- The journey story grows with its lines (useAutoGrow).
- Member avatars show the member's initial, yours too, and turn over on
  a selected chip.
- The trip's More sheet scrolls, dims a short window and is marked in
  the dock while open; long titles wrap to two lines.
2026-11-07 23:25:14 +01:00
Maurice b7a91fc84e fix(mobile): one form sheet for every dialog, opaque and placed beside the rail
Every sheet now stands on the opaque sheet surface: the frosting the
translucent one relied on is dropped while the scrim fades, so the page
read through the fields as sharp ghost text.

On a wide window a sheet is centred in the content beside the navigation
rail instead of on the whole window, so it never lies on the rail, and it
grows to the window's height instead of stopping at 880px. On a phone the
card stands between the status bar and the dock rather than across the
dock. A sheet marked as a form fills the height of a window as short as a
phone on its side, with Cancel and Save beside the title (new `short:`
variant, m-form-sheet grid in mobile.css).

The form chrome gains a scrolling body that fades at the edges with more
behind them (useEdgeFade on the vertical axis), a date field whose
keyboard entry moves into the picker, a field wrapper for amounts with a
currency, and two-line titles. The focused sheet no longer draws the
app-wide focus frame or loses its corners, and the desktop's dark field
paint stays out of the touch shell, which painted a dark box inside every
rounded field.
2026-11-07 23:25:14 +01:00
Maurice bb725ac8d8 fix(native): the first screens on an upright iPad read as one page, not a phone column
On an iPad upright or an unfolded foldable the welcome, address, TREK Cloud
and plan screens kept the phone's narrow column in the middle of the glass,
with the photo fading out across the top left. Now the photo spans the
width as a band across the top, the choices stand under it in a wider
column, and the plans take two columns. Landscape keeps the photo as a
panel beside the choices; phones are unchanged.
2026-11-07 23:25:14 +01:00
Maurice b644235eb6 fix(mobile): level card columns, the day framed beside the plan, the reader's place kept across a fold
From a screen by screen pass over the iPad and foldable sizes:

- Card lists (transports, bookings, tasks, polls) flow down their columns
  instead of filling rows, so the columns start level and a short card no
  longer ends above its neighbour in a ragged row. The cards' own top
  margin gives way to an even gap under each, so every column starts on
  one line.
- A booking's or transport's title may take two lines in a column of a wide
  window instead of being cut after a few words.
- The map beside the plan opened too close: the opening frame was worked
  out for the window, not the narrower map, and Leaflet rounded its zoom up
  to the next whole level, so the day ran off the top and bottom. The map
  now opens at the whole level that fits and, once it knows its own size,
  works the frame out again for it (OpeningFrameFit). On a wide window the
  touch shell frames a day clear of its floating toolbar and corner
  buttons.
- Costs and to-dos in two panes are one element tree with the stacked
  arrangement, so a rotation or a fold between them keeps what is typed or
  open in either pane, and the row at the top of the list stays at the top.
- The place sheet's category pill has room for its name on a wide window.
2026-11-07 23:25:13 +01:00
Maurice d259ecb11c fix(ios): widgets hold at an iPad mini's sizes, the extra large ones balanced
From the iPad renders of the widget previews:

- An iPad mini's small and medium widgets are smaller than an iPhone's. The
  trip widget during the trip lets the weather give way in the small one and
  narrows its stops panel in the medium one, the today widget's weather
  shrinks to its chip and then steps aside before the trip's name is cut,
  the atlas puts "countries" under the number in the small one and lets
  its map shrink in the large one, so the continents and latest countries
  are no longer cut off at the foot.
- The extra large widgets fill their width and height: the trip during the
  trip sets the next stop at the foot of the left column and the next
  booking under the day's stops, today sets the next two bookings across
  the foot, the bookings list spaces its rows beside the card.
- The Live Activity's next stop shrinks a little before it is cut.
2026-11-07 23:25:13 +01:00
Maurice 5d05363f36 feat(ios): the iPad gets the extra large widgets, no Wallet button, a centred share sheet
The app is one binary for iPhone and iPad, so what was built for the
iPhone has to hold on an iPad too:

- Widgets: the trip, today and bookings widgets offer the iPad's extra large
  size. The trip sets its countdown or its day beside the bookings or the
  day's stops, today sets the day, its progress and weather beside the
  timeline, bookings sets the next one as a card beside the rest. The other
  widgets stay at the sizes they were designed for.
- The widget renderer draws every home-screen size of each widget as a 13"
  iPad, an 11" iPad and an iPad mini give it (a mini's small widget is
  smaller than an iPhone's), an iPad lock screen with the accessories and
  the travel day's Live Activity, and that Live Activity at the iPhone's and
  the iPad's widths. To draw it there, its lock-screen banner takes plain
  values (TripDayBanner, now beside the today widget).
- Apple Wallet: an iPad cannot take a pass. The shell reports the wallet
  feature only where PKPassLibrary and PKAddPassesViewController can add
  one, so the UI shows no "Add to Apple Wallet" button there and hands a
  .pkpass to the share sheet like any other file.
- Share extension: on an iPad it is presented over the full screen instead
  of in a form sheet, and in a window of regular width its card stands
  centred over the dimmed app, without the grabber, instead of rising from
  the bottom edge. A narrow Split View window keeps the iPhone's card.
2026-11-07 23:25:13 +01:00
Maurice 6906f80ff8 feat(native): Android tablets get the shortcuts, two panes meet at a foldable's fold
The Android shell now reports keyCommands and windowLayout, so the large
screen layout no longer assumes an iPad:

- The keyboard host hands the shortcuts to the app whenever the shell has
  the keyCommands feature, whatever the platform. Before it also required
  iOS, so an Android tablet with a keyboard answered the keys in the page
  and never listed them in the system's shortcuts.
- useWindowFold follows the layout the Android shell reports (feature
  windowLayout). When a vertical fold separates the window, a foldable half
  open like a book, MobileShell marks it with data-fold and its place, and
  the leading pane (the trip's plan, a tab's summary, a list beside its
  detail) ends at the fold while the trailing one starts there, on either
  reading direction. A flat fold that does not separate stays one screen.
- The split size class starts at 840pt, Android's expanded width, instead
  of 900pt, so a foldable unfolded on its side (about 841dp) gets two panes
  like an iPad does. An 11" iPad upright (834pt) stays a single pane. The
  address screen of the native shell follows the same width.
- TrekShell exports NativeWindowLayout for it.

Also: the sheet's width and anchor types and the collab board type stay
private to their modules, which nothing else imports (knip).
2026-11-07 23:25:13 +01:00
Maurice 7083667cbf fix(mobile): grid cards keep their height, a previewed photo its proportions
Cards in the large-screen grid keep their own height instead of stretching
to a taller neighbour in their row, which drew a short booking as an empty
panel. The file preview beside the grid shows a photo at its own
proportions rather than letterboxed in a portrait frame.

On a 13" iPad on its side the cloud plans of the native shell stand two by
two in their half of the screen, so the whole offer is in view at once.
2026-11-07 23:25:12 +01:00
Maurice 0b7cee0b0d feat(mobile): the trip's tabs, administration, collections and the journal use the large screen
On an iPad or an unfolded foldable the list screens kept one phone-wide
column in the middle of the page. They now lay out for the room they have:

- Transports, bookings, polls and tasks put their cards in a grid of as many
  phone-wide columns as the page holds (CardGrid), three on a 13" iPad on
  its side, two upright, one in a narrow Split View.
- Costs: with room for two panes (split size class) the summary (total,
  owe and owed, settle up, final budget, categories) stands in a pane of its
  own beside the expenses, each scrolling on its own (TabPanes). The search
  field sits in the expenses' column again instead of running past it.
- Files: a grid of tiles with each photo's picture; with room for two panes
  the picked file is previewed beside the grid with what it belongs to and
  its actions, the way Files on an iPad does. The signed preview address
  comes through fileRepo.previewUrl.
- Packing: the categories stand in columns, each card whole in its column;
  the action menu drops from its button at a popover's width.
- To-dos: the four smart filters as tiles and the trip's lists in a pane
  beside the tasks, which take the filter's name as their head.
- Collab: the chat stays in view at the trailing edge beside the notes,
  links or polls, and the header switches only the board. The chat keeps its
  place in the tree, so a fold or a rotation into two panes keeps the draft;
  a chat at its newest message stays there when its box changes size. Notes
  stand as a board in columns.
- Administration and settings share a section list beside the page
  (MSectionNav); the stats grid takes four columns.
- Collections: the lists stand in a column beside the places, which take two
  columns, and the head names the list on show.
- Journal: the entries become a feed beside the map, under their days, with
  photo, place and the start of the story, in step with the map like the
  strip of cards it replaces; it fades out under the header.
- The assistant opens as a tall panel at the trailing edge over a dimmed
  page, as Slide Over would, instead of covering the whole screen.
- The rail fits every name: a word too wide for it shrinks the label before
  it is hyphenated, and the admin entry uses its short name.
- The journeys and notifications headers keep to their column.
- The address screen of the native shell sets the photo as a panel beside
  the choices on a large screen on its side.

On a foldable that reports its fold, a leading pane ends at the fold and a
trailing one starts there. Phones keep their layout.
2026-11-07 23:25:12 +01:00
Maurice 36ed0e0d23 fix(mobile): the map frames the day beside the pane, atlas search as a centred panel
With the plan beside the map, the map frames the day once the splash has
gone, and again once its box has settled after the pane opens or hides, a
rotation or a fold: a frame fitted to the old box left the day half outside
the new one. On a wide window the atlas country search stands as a centred
panel instead of a band across the screen.
2026-11-07 23:25:12 +01:00
Maurice b1c0353548 feat(native): iPad shortcuts in the app menu, pointer input on iPad
The UI's keyboard shortcuts for the screen on show become native key
commands (TrekShell.setKeyCommands, feature keyCommands): they appear in the
overlay a held Command key brings up and in the iPadOS menu bar, named in the
UI's language, and a press is reported back to the page ("keyCommand").
Each screen replaces the list, so only what works there is ever offered.

UIApplicationSupportsIndirectInputEvents turns on the iPad's pointer input
for trackpads and mice.
2026-11-07 23:25:12 +01:00
Maurice c204a25a9c feat(mobile): the touch shell laid out for iPad and unfolded foldables
The app shows the touch shell at every size now, an iPad's and an unfolded
foldable's included, instead of switching to the desktop pages from 768px.
One route tree survives rotation, Split View, Stage Manager and folding, and
the app's own features (Wallet, sharing into a trip, the edge swipe back)
are there on the big screen too.

Two size classes drive the layout (hooks/sizeClass.ts, Tailwind variants
wide: and split:):

- wide (600pt and taller than a phone on its side): a glass navigation rail
  on the leading edge replaces the dock and holds every destination with its
  name; sheets become centred form sheets; the trip's More menu is a popover
  beside the rail; lists keep a readable measure; dashboard, journey and
  vacay get columns; full-bleed maps run under the floating rail.
- split (900pt): the trip's plan, places or road trip chain sit in a pane
  beside the map, which follows the plan's day; the list/map switch hides
  and shows the pane like a sidebar; on a foldable that reports its fold the
  pane ends at the fold. Settings show their sections beside the page and the
  dashboard keeps its widgets in a column of their own.

A hardware keyboard gets the shortcuts an iPad app has (⌘1 to ⌘9 for the
sections, ⌘N, ⌘, , ⌥←/⌥→ through the days, ⌃⌘S for the pane, ⌘[ back),
answered by the page or, with a binary that has the keyCommands feature,
handed to it as native key commands.

The day strip fades at an edge that runs on, journey cards grow on a wide
window and a card without a photo takes the day's colour, and the Leaflet
map measures itself again when its box changes size.
2026-11-07 23:25:12 +01:00
Maurice b008e010a5 fix(native): sort the offline manifest with localeCompare, so Sonar's reliability gate holds 2026-11-07 22:03:20 +01:00
330 changed files with 10403 additions and 1624 deletions
+11
View File
@@ -60,6 +60,17 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
// Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:.
}
// MARK: - Hardware keyboard
/// The UI's shortcuts for the screen on show (TrekKeyCommands), in the app's
/// menu: the overlay a held Command key brings up lists them there.
override func buildMenu(with builder: UIMenuBuilder) {
super.buildMenu(with: builder)
guard builder.system == .main, !TrekKeyCommands.current.isEmpty else { return }
let parent: UIMenu.Identifier = builder.menu(for: .application) != nil ? .application : .root
builder.insertChild(TrekKeyCommands.menu(), atStartOfMenu: parent)
}
func application(_ application: UIApplication,
configurationForConnecting connectingSceneSession: UISceneSession,
options: UIScene.ConnectionOptions) -> UISceneConfiguration {
+2
View File
@@ -160,6 +160,8 @@
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UIViewControllerBasedStatusBarAppearance</key>
<true/>
</dict>
@@ -50,6 +50,87 @@ class TrekBridgeViewController: CAPBridgeViewController {
// the page follows the finger and an open sheet closes first.
webView?.allowsBackForwardNavigationGestures = false
}
/// A key command of the UI's (TrekKeyCommands) was pressed. The commands sit
/// in the main menu, which sends their action up the responder chain from
/// the web view, and this controller is the first stop that answers it.
@objc func trekKeyCommand(_ sender: UIKeyCommand) {
guard let id = sender.propertyList as? String else { return }
TrekKeyCommands.onPress?(id)
}
}
// MARK: - Hardware keyboard (TrekShell.setKeyCommands)
/// The UI's keyboard shortcuts as key commands in the app's main menu. There
/// they show in the overlay a held Command key brings up and, on iPadOS 26, in
/// the menu bar, under the names the UI gives them in its language. Each screen
/// replaces the list (mobile/keyboard/MKeyboardHost.tsx), and the menu is
/// rebuilt with it, so only what works here and now is ever offered.
enum TrekKeyCommands {
struct Command {
let id: String
let title: String
let input: String
let modifiers: UIKeyModifierFlags
init?(raw: JSObject) {
guard let id = raw["id"] as? String, let title = raw["title"] as? String,
let input = raw["input"] as? String, !input.isEmpty else { return nil }
var flags: UIKeyModifierFlags = []
for name in raw["modifiers"] as? [String] ?? [] {
switch name {
case "command": flags.insert(.command)
case "shift": flags.insert(.shift)
case "option": flags.insert(.alternate)
case "control": flags.insert(.control)
default: break
}
}
self.id = id
self.title = title
self.input = Command.key(input)
self.modifiers = flags
}
private static func key(_ name: String) -> String {
switch name {
case "left": return UIKeyCommand.inputLeftArrow
case "right": return UIKeyCommand.inputRightArrow
case "up": return UIKeyCommand.inputUpArrow
case "down": return UIKeyCommand.inputDownArrow
case "escape": return UIKeyCommand.inputEscape
default: return name
}
}
}
static let menuIdentifier = UIMenu.Identifier("com.liketrek.trek.shortcuts")
private(set) static var current: [Command] = []
static var onPress: ((String) -> Void)?
static func set(_ commands: [Command]) {
current = commands
UIMenuSystem.main.setNeedsRebuild()
}
/// The commands as one menu, without a title of its own: it joins the app's
/// menu, where iPadOS lists the shortcuts under the app's name.
static func menu() -> UIMenu {
let children = current.map { command -> UIKeyCommand in
let key = UIKeyCommand(
title: command.title,
action: #selector(TrekBridgeViewController.trekKeyCommand(_:)),
input: command.input,
modifierFlags: command.modifiers,
propertyList: command.id
)
// ⌘[ and ⌘, belong to the app here, not to the web view's editing.
key.wantsPriorityOverSystemBehavior = true
return key
}
return UIMenu(title: "", identifier: menuIdentifier, options: .displayInline, children: children)
}
}
/// The remembered server and the offline copy of its start page.
+29 -3
View File
@@ -48,6 +48,7 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr
CAPPluginMethod(name: "addToWallet", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "setWatchSnapshot", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "takeWatchActions", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "setKeyCommands", returnType: CAPPluginReturnPromise),
]
/// What this binary offers the server's UI, raised with every native feature
@@ -55,10 +56,19 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr
/// and a server can require a minimum (minNativeShellApi in /api/health/features).
static let shellApi = 2
/// The native features by name, so the UI can turn each on by itself.
/// "watch" only where WatchConnectivity is (an iPhone, not an iPad), so the UI never builds a snapshot for nothing.
/// "watch" only where WatchConnectivity is (an iPhone, not an iPad), so the UI never
/// builds a snapshot for nothing; "wallet" only where Wallet can take a pass (an
/// iPhone: an iPad has no Wallet app), so the UI offers no button that does nothing.
static var features: [String] {
["capabilities", "edgeBack", "tileCache", "biometricLock", "geolocation", "sharedFiles", "nativePush", "widgets", "liveActivity", "widgetTrips", "storeKit", "shareSheet", "wallet"]
+ (TrekWatchLink.isAvailable ? ["watch"] : [])
var names = ["capabilities", "edgeBack", "tileCache", "biometricLock", "geolocation", "sharedFiles", "nativePush", "widgets", "liveActivity", "widgetTrips", "storeKit", "shareSheet", "keyCommands"]
if canAddWalletPasses { names.append("wallet") }
if TrekWatchLink.isAvailable { names.append("watch") }
return names
}
/// Whether this device can add a pass to Apple Wallet (an iPhone; not an iPad).
static var canAddWalletPasses: Bool {
PKPassLibrary.isPassLibraryAvailable() && PKAddPassesViewController.canAddPasses()
}
// MARK: - Shared booking documents
@@ -72,6 +82,22 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr
}
TrekStore.watchUpdates()
TrekWatchLink.shared.onAction = { [weak self] in self?.notifyListeners("watchAction", data: [:]) }
TrekKeyCommands.onPress = { [weak self] id in
self?.notifyListeners("keyCommand", data: ["id": id])
}
}
// MARK: - Hardware keyboard
/// The UI's keyboard shortcuts for the screen it shows (mobile/keyboard),
/// named in its language, as key commands in the app's menu.
@objc func setKeyCommands(_ call: CAPPluginCall) {
let raw = call.getArray("commands", JSObject.self) ?? []
let commands = raw.compactMap { TrekKeyCommands.Command(raw: $0) }
DispatchQueue.main.async {
TrekKeyCommands.set(commands)
call.resolve()
}
}
// MARK: - Widgets and the travel-day Live Activity
+22 -11
View File
@@ -6,16 +6,24 @@ import SwiftUI
struct ShareView: View {
@ObservedObject var model: ShareModel
@Environment(\.colorScheme) private var scheme
@Environment(\.horizontalSizeClass) private var widthClass
@State private var shown = false
/// An iPad window of regular width: the card stands centred over the dimmed
/// app, as a form does there, instead of rising from the bottom edge. A
/// narrow Split View or Slide Over window keeps the iPhone's bottom card.
private var centred: Bool { widthClass == .regular }
var body: some View {
ZStack(alignment: .bottom) {
Color.black.opacity(shown ? 0.28 : 0)
ZStack(alignment: centred ? .center : .bottom) {
Color.black.opacity(shown ? (centred ? 0.36 : 0.28) : 0)
.ignoresSafeArea()
.onTapGesture { dismiss() }
card
.offset(y: shown ? 0 : 520)
.offset(y: shown ? 0 : (centred ? 40 : 520))
.opacity(centred && !shown ? 0 : 1)
.padding(.vertical, centred ? 24 : 0)
}
.onAppear {
withAnimation(.spring(response: 0.5, dampingFraction: 0.86)) { shown = true }
@@ -31,14 +39,17 @@ struct ShareView: View {
private var card: some View {
VStack(spacing: 0) {
Capsule()
.fill(ShareStyle.faint(scheme).opacity(0.5))
.frame(width: 38, height: 5)
.padding(.top, 8)
// The grabber belongs to a card that rises from the bottom edge.
if !centred {
Capsule()
.fill(ShareStyle.faint(scheme).opacity(0.5))
.frame(width: 38, height: 5)
.padding(.top, 8)
}
header
.padding(.horizontal, 20)
.padding(.top, 14)
.padding(.top, centred ? 22 : 14)
Group {
switch model.phase {
@@ -50,7 +61,7 @@ struct ShareView: View {
}
.padding(.top, 16)
}
.padding(.bottom, 12)
.padding(.bottom, centred ? 20 : 12)
.frame(maxWidth: 560)
.background(
RoundedRectangle(cornerRadius: 32, style: .continuous)
@@ -60,9 +71,9 @@ struct ShareView: View {
.strokeBorder(ShareStyle.edge(scheme), lineWidth: 1)
)
.shadow(color: .black.opacity(0.25), radius: 30, y: 10)
.ignoresSafeArea(edges: .bottom)
.ignoresSafeArea(edges: centred ? [] : .bottom)
)
.padding(.horizontal, 6)
.padding(.horizontal, centred ? 24 : 6)
}
private var header: some View {
@@ -9,6 +9,24 @@ import UIKit
final class ShareViewController: UIViewController {
private let model = ShareModel()
// On an iPad the system would put the extension in a form sheet, a box in the
// middle of the screen with this sheet's own dimming and card inside it. Over
// the full screen instead, the card stands centred over the app that shared,
// the way an iPad presents a small form.
override init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?) {
super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil)
presentOverFullScreenOnPad()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
presentOverFullScreenOnPad()
}
private func presentOverFullScreenOnPad() {
if UIDevice.current.userInterfaceIdiom == .pad { modalPresentationStyle = .overFullScreen }
}
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .clear
+30 -13
View File
@@ -44,9 +44,16 @@ struct AtlasContent: View {
Text("\(Image(systemName: "globe.europe.africa.fill")) \(atlas?.count ?? 0) \(labels?.countries ?? "")")
case .small:
VStack(alignment: .leading, spacing: 0) {
HStack(alignment: .firstTextBaseline, spacing: 4) {
Text("\(atlas?.count ?? 0)").font(Trek.number(38, .heavy)).foregroundStyle(ink)
Text(labels?.countries ?? "").font(.system(size: 13, weight: .bold)).foregroundStyle(muted)
// The word goes under the number where both do not fit on one line (an iPad mini).
ViewThatFits(in: .horizontal) {
HStack(alignment: .firstTextBaseline, spacing: 4) {
Text("\(atlas?.count ?? 0)").font(Trek.number(38, .heavy)).foregroundStyle(ink)
Text(labels?.countries ?? "").font(.system(size: 13, weight: .bold)).foregroundStyle(muted)
}
VStack(alignment: .leading, spacing: -2) {
Text("\(atlas?.count ?? 0)").font(Trek.number(38, .heavy)).foregroundStyle(ink)
Text(labels?.countries ?? "").font(.system(size: 13, weight: .bold)).foregroundStyle(muted)
}
}
Text(share(atlas?.count ?? 0, labels)).font(.system(size: 11, weight: .semibold)).foregroundStyle(muted)
Spacer(minLength: 6)
@@ -71,18 +78,15 @@ struct AtlasContent: View {
}
default:
VStack(alignment: .leading, spacing: 12) {
HStack(alignment: .lastTextBaseline, spacing: 6) {
Text("\(atlas?.count ?? 0)").font(Trek.number(40, .heavy)).foregroundStyle(ink)
VStack(alignment: .leading, spacing: 0) {
Text(labels?.countries ?? "").font(.system(size: 14, weight: .heavy)).foregroundStyle(ink)
Text(share(atlas?.count ?? 0, labels)).font(.system(size: 11, weight: .semibold)).foregroundStyle(muted)
}
Spacer(minLength: 4)
if let trips = atlas?.trips { Stat(value: "\(trips)", symbol: "airplane", scheme: scheme) }
if let days = atlas?.days { Stat(value: "\(days)", symbol: "calendar", scheme: scheme) }
// The trips and days step aside before the share of the world is cut (an iPad mini).
ViewThatFits(in: .horizontal) {
largeHeader(atlas, labels, stats: true)
largeHeader(atlas, labels, stats: false)
}
// The map takes what the rows leave, so a smaller large widget (an
// iPad mini's) keeps the continents and the latest countries whole.
DotWorld(visited: visited, latest: latest, accent: glow, rest: rest, dot: 2.2)
.frame(height: 150)
.frame(minHeight: 60, maxHeight: 150)
ContinentBars(continents: atlas?.continents ?? [], scheme: scheme)
HStack(spacing: 6) {
ForEach(Array((atlas?.recent ?? []).prefix(3).enumerated()), id: \.offset) { index, country in
@@ -94,6 +98,19 @@ struct AtlasContent: View {
}
}
private func largeHeader(_ atlas: WidgetSnapshot.Atlas?, _ labels: WidgetSnapshot.Labels?, stats: Bool) -> some View {
HStack(alignment: .lastTextBaseline, spacing: 6) {
Text("\(atlas?.count ?? 0)").font(Trek.number(40, .heavy)).foregroundStyle(ink)
VStack(alignment: .leading, spacing: 0) {
Text(labels?.countries ?? "").font(.system(size: 14, weight: .heavy)).foregroundStyle(ink)
Text(share(atlas?.count ?? 0, labels)).font(.system(size: 11, weight: .semibold)).foregroundStyle(muted)
}
Spacer(minLength: 4)
if stats, let trips = atlas?.trips { Stat(value: "\(trips)", symbol: "airplane", scheme: scheme) }
if stats, let days = atlas?.days { Stat(value: "\(days)", symbol: "calendar", scheme: scheme) }
}
}
private var ink: Color { Trek.ink(scheme) }
private var muted: Color { Trek.muted(scheme) }
private var glow: [Color] { [Color(hex: 0x3DDC97), Color(hex: 0x4A7DDB)] }
@@ -29,6 +29,7 @@ struct BookingsContent: View {
switch family {
case .small: hero(first, labels)
case .medium: list(Array(bookings.prefix(2)), labels, featureFirst: true)
case .extraLarge: wide(bookings, labels)
default: list(Array(bookings.prefix(5)), labels, featureFirst: true)
}
} else {
@@ -74,7 +75,8 @@ struct BookingsContent: View {
// MARK: Medium and large: a list, the first one featured
private func list(_ bookings: [WidgetSnapshot.Booking], _ labels: WidgetSnapshot.Labels, featureFirst: Bool) -> some View {
VStack(alignment: .leading, spacing: family == .large ? 8 : 7) {
// The extra large's list stands beside a card as tall as the widget: its rows space out to match.
VStack(alignment: .leading, spacing: family == .extraLarge ? 14 : family.isTall ? 8 : 7) {
HStack {
Text(labels.bookings ?? "").trekEyebrow(scheme)
Spacer(minLength: 0)
@@ -89,7 +91,33 @@ struct BookingsContent: View {
}
}
}
if family == .large { Spacer(minLength: 0) }
if family.isTall { Spacer(minLength: 0) }
}
}
// MARK: Extra large (iPad): the next one as a card, the rest beside it
private func wide(_ bookings: [WidgetSnapshot.Booking], _ labels: WidgetSnapshot.Labels) -> some View {
let first = bookings[0]
let look = Trek.booking(first.kind)
let rest = Array(bookings.dropFirst().prefix(5))
return HStack(alignment: .top, spacing: 18) {
RowLink(path: first.path) {
hero(first, labels)
.padding(14)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 18, style: .continuous)
.fill(LinearGradient(colors: [look.color.opacity(scheme == .dark ? 0.24 : 0.14), look.color.opacity(scheme == .dark ? 0.08 : 0.04)], startPoint: .topLeading, endPoint: .bottomTrailing))
)
.clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
.overlay(RoundedRectangle(cornerRadius: 18, style: .continuous).stroke(look.color.opacity(0.25), lineWidth: 0.8))
}
.frame(maxWidth: rest.isEmpty ? CGFloat.infinity : 240)
if !rest.isEmpty {
list(rest, labels, featureFirst: false)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
}
}
}
@@ -51,6 +51,7 @@ extension TrekFamily {
switch family {
case .systemSmall: self = .small
case .systemMedium: self = .medium
case .systemExtraLarge: self = .extraLarge
case .accessoryCircular: self = .circular
case .accessoryRectangular: self = .rectangular
case .accessoryInline: self = .inline
@@ -92,7 +93,8 @@ struct NextTripWidget: Widget {
}
.configurationDisplayName("widget.trip.name")
.description("widget.trip.description")
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge, .accessoryCircular, .accessoryRectangular, .accessoryInline])
// systemExtraLarge is the iPad's; an iPhone leaves it out of its gallery.
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge, .systemExtraLarge, .accessoryCircular, .accessoryRectangular, .accessoryInline])
}
}
@@ -123,7 +125,7 @@ struct TodayWidget: Widget {
}
.configurationDisplayName("widget.today.name")
.description("widget.today.description")
.supportedFamilies([.systemMedium, .systemLarge])
.supportedFamilies([.systemMedium, .systemLarge, .systemExtraLarge])
}
}
@@ -146,7 +148,7 @@ struct BookingsWidget: Widget {
}
.configurationDisplayName("widget.bookings.name")
.description("widget.bookings.description")
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge, .accessoryRectangular, .accessoryInline])
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge, .systemExtraLarge, .accessoryRectangular, .accessoryInline])
}
}
+151 -36
View File
@@ -14,55 +14,49 @@ struct TodayContent: View {
let total = today.total ?? snapshot.tripLength ?? 1
let timed = today.stops.filter { $0.minutes != nil }
let done = timed.filter { ($0.minutes ?? 0) < nowMinutes }.count
VStack(alignment: .leading, spacing: 0) {
HStack(alignment: .center, spacing: 10) {
DayBadge(index: today.index ?? 1, total: total)
VStack(alignment: .leading, spacing: 1) {
Text(trip.title).trekEyebrow(scheme)
Text(today.dayLabel)
.font(.system(size: family == .large ? 19 : 16, weight: .heavy))
.foregroundStyle(Trek.ink(scheme))
.lineLimit(1)
if family == .extraLarge {
wide(snapshot, trip, today, total: total, done: done, timed: timed.count, nowMinutes: nowMinutes)
} else {
VStack(alignment: .leading, spacing: 0) {
// On a narrow widget (an iPad mini's) the weather shrinks to its
// chip, then steps aside, before the trip's name is cut short.
ViewThatFits(in: .horizontal) {
header(trip, today, total: total, weather: family == .large ? .full : .chip)
header(trip, today, total: total, weather: .chip)
header(trip, today, total: total, weather: .hidden)
}
Spacer(minLength: 0)
if let weather = snapshot.weather {
if family == .large {
PaperWeather(weather: weather)
} else {
WeatherChip(weather: weather)
if family == .large {
DayProgress(done: done, total: timed.count, scheme: scheme, label: snapshot.labels.allDone)
.padding(.top, 12)
.padding(.bottom, 14)
} else {
Spacer(minLength: 8)
}
StopList(
stops: today.stops, nowMinutes: nowMinutes, limit: family == .large ? 7 : 3,
nothing: snapshot.labels.nothingPlanned, nowLabel: snapshot.labels.now
)
if family == .large {
Spacer(minLength: 8)
if let booking = snapshot.bookings?.first {
NextBookingStrip(booking: booking, labels: snapshot.labels, now: now, onPhoto: false)
}
}
}
if family == .large {
DayProgress(done: done, total: timed.count, scheme: scheme, label: snapshot.labels.allDone)
.padding(.top, 12)
.padding(.bottom, 14)
} else {
Spacer(minLength: 8)
}
StopList(
stops: today.stops, nowMinutes: nowMinutes, limit: family == .large ? 7 : 3,
nothing: snapshot.labels.nothingPlanned, nowLabel: snapshot.labels.now
)
if family == .large {
Spacer(minLength: 8)
if let booking = snapshot.bookings?.first {
NextBookingStrip(booking: booking, labels: snapshot.labels, now: now, onPhoto: false)
}
}
}
} else if let snapshot, let trip = snapshot.trip {
// Before the trip there is no day yet: count down to it instead.
let days = max(snapshot.daysUntilStart(from: now) ?? 0, 0)
let roomy = family == .extraLarge
VStack(alignment: .leading, spacing: 4) {
Text(snapshot.labels.nextTrip).trekEyebrow(scheme)
Text(trip.title).font(.system(size: 17, weight: .heavy)).foregroundStyle(Trek.ink(scheme)).lineLimit(2)
Text(trip.title).font(.system(size: roomy ? 24 : 17, weight: .heavy)).foregroundStyle(Trek.ink(scheme)).lineLimit(2)
Spacer(minLength: 0)
HStack(alignment: .firstTextBaseline, spacing: 4) {
Text("\(days)").font(Trek.number(40, .heavy)).foregroundStyle(Trek.ink(scheme))
Text(snapshot.labels.days ?? "").font(.system(size: 13, weight: .bold)).foregroundStyle(Trek.muted(scheme))
Text("\(days)").font(Trek.number(roomy ? 56 : 40, .heavy)).foregroundStyle(Trek.ink(scheme))
Text(snapshot.labels.days ?? "").font(.system(size: roomy ? 16 : 13, weight: .bold)).foregroundStyle(Trek.muted(scheme))
}
RouteArc(progress: 1 - min(Double(days), 60) / 60, color: Trek.ink(scheme)).frame(height: 24)
RouteArc(progress: 1 - min(Double(days), 60) / 60, color: Trek.ink(scheme)).frame(height: roomy ? 34 : 24)
}
} else {
EmptyTrip(text: snapshot?.labels.noTrip ?? "", family: family)
@@ -70,6 +64,66 @@ struct TodayContent: View {
}
}
extension TodayContent {
enum HeaderWeather { case full, chip, hidden }
/// The day's badge, the trip and the day, and the weather as much as fits.
func header(_ trip: WidgetSnapshot.Trip, _ today: WidgetSnapshot.Today, total: Int, weather: HeaderWeather) -> some View {
HStack(alignment: .center, spacing: 10) {
DayBadge(index: today.index ?? 1, total: total)
VStack(alignment: .leading, spacing: 1) {
Text(trip.title).trekEyebrow(scheme)
Text(today.dayLabel)
.font(.system(size: family == .medium ? 16 : 19, weight: .heavy))
.foregroundStyle(Trek.ink(scheme))
.lineLimit(1)
}
Spacer(minLength: 0)
if let forecast = snapshot?.weather {
switch weather {
case .full: PaperWeather(weather: forecast)
case .chip: WeatherChip(weather: forecast)
case .hidden: EmptyView()
}
}
}
}
/// The iPad's extra large: the day, its progress and the weather beside
/// the day's timeline, and the next two bookings across the foot.
func wide(
_ snapshot: WidgetSnapshot, _ trip: WidgetSnapshot.Trip, _ today: WidgetSnapshot.Today,
total: Int, done: Int, timed: Int, nowMinutes: Int
) -> some View {
VStack(alignment: .leading, spacing: 0) {
HStack(alignment: .top, spacing: 22) {
VStack(alignment: .leading, spacing: 0) {
header(trip, today, total: total, weather: .hidden)
DayProgress(done: done, total: timed, scheme: scheme, label: snapshot.labels.allDone)
.padding(.top, 12)
if let weather = snapshot.weather {
PaperWeather(weather: weather).padding(.top, 16)
}
}
.frame(width: 250)
StopList(
stops: today.stops, nowMinutes: nowMinutes, limit: 6,
nothing: snapshot.labels.nothingPlanned, nowLabel: snapshot.labels.now
)
.frame(maxWidth: .infinity, alignment: .topLeading)
}
Spacer(minLength: 10)
if let bookings = snapshot.bookings, !bookings.isEmpty {
HStack(spacing: 12) {
ForEach(Array(bookings.prefix(2).enumerated()), id: \.offset) { _, booking in
NextBookingStrip(booking: booking, labels: snapshot.labels, now: now, onPhoto: false)
}
}
}
}
}
}
/// Today's number in a dark tile with the trip's length under it.
struct DayBadge: View {
let index: Int
@@ -142,3 +196,64 @@ struct DayProgress: View {
}
}
}
/// The travel day as its Live Activity shows it on the lock screen (iPhone and
/// iPad): trip and day on top, the next stop large, the day's stops as
/// segments. Plain values rather than the activity's attributes, so the
/// preview renderer can draw it at every width the lock screen gives it.
struct TripDayBanner: View {
let tripTitle: String
let dayLabel: String
let nextName: String
let nextTime: String?
let done: Int
let total: Int
var body: some View {
HStack(alignment: .center, spacing: 14) {
ZStack {
Circle().stroke(Color.white.opacity(0.18), lineWidth: 5)
Circle()
.trim(from: 0, to: total > 0 ? max(0.02, Double(done) / Double(total)) : 0)
.stroke(
AngularGradient(colors: [Trek.confirmed.opacity(0.7), Trek.confirmed], center: .center),
style: StrokeStyle(lineWidth: 5, lineCap: .round)
)
.rotationEffect(.degrees(-90))
Image(systemName: "mappin.and.ellipse").font(.system(size: 17, weight: .bold)).foregroundStyle(.white)
}
.frame(width: 52, height: 52)
VStack(alignment: .leading, spacing: 3) {
HStack {
Text(tripTitle)
.font(.system(size: 11, weight: .bold))
.tracking(0.5)
.textCase(.uppercase)
.foregroundStyle(.white.opacity(0.65))
.lineLimit(1)
Spacer(minLength: 4)
Text(dayLabel).font(.system(size: 11, weight: .bold)).foregroundStyle(.white.opacity(0.65)).lineLimit(1)
}
HStack(alignment: .firstTextBaseline) {
Text(nextName).font(.system(size: 18, weight: .heavy)).foregroundStyle(.white).lineLimit(1).minimumScaleFactor(0.8)
Spacer(minLength: 4)
if let nextTime {
Text(nextTime).font(Trek.number(18, .heavy)).foregroundStyle(.white)
}
}
DayStopProgress(done: done, total: total).padding(.top, 3)
}
}
.padding(16)
}
}
/// The day's timed stops as segments, those behind filled (the Live Activity's bar).
struct DayStopProgress: View {
let done: Int
let total: Int
var body: some View {
DaySegments(index: done + 1, total: max(total, 1), color: Trek.confirmed, track: Color.white.opacity(0.2))
}
}
+36 -2
View File
@@ -4,16 +4,22 @@ import Foundation
/// so the views also render outside a widget (the preview renderer in
/// client/ios/WidgetPreviews draws every size to a PNG on the CI's Mac).
enum TrekFamily: String, CaseIterable {
case small, medium, large, circular, rectangular, inline
case small, medium, large, extraLarge, circular, rectangular, inline
var isAccessory: Bool { self == .circular || self == .rectangular || self == .inline }
/// The point size of the family on a 6.1" iPhone, for the renderer.
/// A home-screen widget as tall as a large one: the large and the iPad's
/// extra large, which have room for a list with a foot under it.
var isTall: Bool { self == .large || self == .extraLarge }
/// The point size of the family on a 6.1" iPhone, for the renderer. The
/// extra large exists on an iPad only; its size is that of an 11" iPad.
var previewSize: CGSize {
switch self {
case .small: return CGSize(width: 170, height: 170)
case .medium: return CGSize(width: 364, height: 170)
case .large: return CGSize(width: 364, height: 382)
case .extraLarge: return CGSize(width: 715.5, height: 342)
case .circular: return CGSize(width: 76, height: 76)
case .rectangular: return CGSize(width: 172, height: 76)
case .inline: return CGSize(width: 250, height: 26)
@@ -21,6 +27,34 @@ enum TrekFamily: String, CaseIterable {
}
}
/// The iPads the renderer draws the home-screen widgets for, with the sizes
/// iPadOS gives each family there (Human Interface Guidelines, Widgets). An
/// iPad mini's small widget is smaller than an iPhone's, a 13" iPad's medium
/// and large are wider.
enum TrekPad: String, CaseIterable {
case ipad13, ipad11, ipadMini
/// The small widget's side, the medium's and large's width, the extra large's width.
private var metrics: (small: CGFloat, wide: CGFloat, extraWide: CGFloat) {
switch self {
case .ipad13: return (170, 378.5, 795)
case .ipad11: return (155, 342, 715.5)
case .ipadMini: return (141, 305.5, 634.5)
}
}
func size(_ family: TrekFamily) -> CGSize {
let m = metrics
switch family {
case .small: return CGSize(width: m.small, height: m.small)
case .medium: return CGSize(width: m.wide, height: m.small)
case .large: return CGSize(width: m.wide, height: m.wide)
case .extraLarge: return CGSize(width: m.extraWide, height: m.wide)
default: return family.previewSize
}
}
}
extension WidgetSnapshot {
/// What the widget gallery shows before the app ever wrote a snapshot, and
/// what the renderer draws: a trip to Kyoto with a full day, bookings and to-dos.
+134 -24
View File
@@ -85,6 +85,53 @@ struct TripContent: View {
}
.frame(width: 96, height: 96)
}
case .extraLarge:
// The iPad's wide widget: the trip and its countdown on the left, the
// bookings ahead in a column on the right.
HStack(alignment: .top, spacing: 20) {
VStack(alignment: .leading, spacing: 0) {
HStack(alignment: .top) {
Text(snapshot.labels.nextTrip).trekEyebrowOnPhoto()
Spacer(minLength: 0)
if let weather = snapshot.weather { WeatherChip(weather: weather, onPhoto: true) }
}
Spacer(minLength: 0)
HStack(alignment: .bottom, spacing: 12) {
VStack(alignment: .leading, spacing: 2) {
Text(trip.title)
.font(.system(size: 28, weight: .heavy))
.foregroundStyle(.white)
.lineLimit(2)
.minimumScaleFactor(0.75)
if let place = trip.place {
Label(place, systemImage: "location.fill")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(.white.opacity(0.85))
.lineLimit(1)
}
}
Spacer(minLength: 0)
countdown(days, snapshot.labels, size: 54)
}
RouteArc(progress: progress, color: .white)
.frame(height: 34)
.padding(.top, 10)
.padding(.bottom, 12)
TripFacts(snapshot: snapshot, onPhoto: true)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
if let bookings = snapshot.bookings, !bookings.isEmpty {
VStack(alignment: .leading, spacing: 8) {
Text(snapshot.labels.bookings ?? "").trekEyebrowOnPhoto()
ForEach(Array(bookings.prefix(4).enumerated()), id: \.offset) { _, booking in
NextBookingStrip(booking: booking, labels: snapshot.labels, now: now)
}
Spacer(minLength: 0)
}
.frame(width: 290)
.frame(maxHeight: .infinity, alignment: .top)
}
}
default:
VStack(alignment: .leading, spacing: 0) {
HStack(alignment: .top) {
@@ -154,10 +201,17 @@ struct TripContent: View {
switch family {
case .small:
VStack(alignment: .leading, spacing: 0) {
HStack(alignment: .top) {
Text(today.dayLabel).trekEyebrowOnPhoto()
Spacer(minLength: 0)
if let weather = snapshot.weather { WeatherChip(weather: weather, onPhoto: true, compact: true) }
// An iPad mini's small widget has no room for both: the day stays, the weather gives way.
ViewThatFits(in: .horizontal) {
HStack(alignment: .top) {
Text(today.dayLabel).trekEyebrowOnPhoto()
Spacer(minLength: 0)
if let weather = snapshot.weather { WeatherChip(weather: weather, onPhoto: true, compact: true) }
}
HStack(alignment: .top) {
Text(today.dayLabel).trekEyebrowOnPhoto()
Spacer(minLength: 0)
}
}
DaySegments(index: index, total: total, color: .white, track: .white.opacity(0.25)).padding(.top, 7)
Spacer(minLength: 0)
@@ -175,31 +229,87 @@ struct TripContent: View {
Text(trip.title).font(.system(size: 11, weight: .medium)).foregroundStyle(.white.opacity(0.7)).lineLimit(1).padding(.top, 2)
}
case .medium:
HStack(spacing: 14) {
VStack(alignment: .leading, spacing: 0) {
Text(trip.title).trekEyebrowOnPhoto()
Text(today.dayLabel)
.font(.system(size: 21, weight: .heavy))
.foregroundStyle(.white)
.lineLimit(1)
.minimumScaleFactor(0.8)
.padding(.top, 3)
DaySegments(index: index, total: total, color: .white, track: .white.opacity(0.25)).padding(.top, 8)
Spacer(minLength: 0)
if let weather = snapshot.weather {
HStack(spacing: 6) {
WeatherChip(weather: weather, onPhoto: true)
if let high = weather.high, let low = weather.low {
Text("\(high)° / \(low)°").font(Trek.number(11.5, .semibold)).foregroundStyle(.white.opacity(0.8))
// 176 pt for the stops on an iPhone and an 11" or 13" iPad; an iPad
// mini's medium widget is narrower, so the panel gives up some width
// and the weather keeps to its chip, and the day still reads whole.
// The line is drawn by the iPad mini alone: its medium widget is
// 305.5 pt wide, 273.5 inside the content margins, while the smallest
// iPhone's (a 4.7" SE) is 321 pt, 289 inside, and keeps the full layout.
GeometryReader { geo in
let narrow = geo.size.width < 281
HStack(spacing: 14) {
VStack(alignment: .leading, spacing: 0) {
Text(trip.title).trekEyebrowOnPhoto()
Text(today.dayLabel)
.font(.system(size: 21, weight: .heavy))
.foregroundStyle(.white)
.lineLimit(1)
.minimumScaleFactor(0.8)
.padding(.top, 3)
DaySegments(index: index, total: total, color: .white, track: .white.opacity(0.25)).padding(.top, 8)
Spacer(minLength: 0)
if let weather = snapshot.weather {
HStack(spacing: 6) {
WeatherChip(weather: weather, onPhoto: true, compact: narrow)
if !narrow, let high = weather.high, let low = weather.low {
Text("\(high)° / \(low)°").font(Trek.number(11.5, .semibold)).foregroundStyle(.white.opacity(0.8))
}
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
GlassPanel {
StopList(stops: today.stops, nowMinutes: nowMinutes, limit: 3, onPhoto: true, nothing: snapshot.labels.nothingPlanned, nowLabel: snapshot.labels.now)
}
.frame(width: narrow ? 150 : 176)
}
.frame(maxWidth: .infinity, alignment: .leading)
GlassPanel {
StopList(stops: today.stops, nowMinutes: nowMinutes, limit: 3, onPhoto: true, nothing: snapshot.labels.nothingPlanned, nowLabel: snapshot.labels.now)
.frame(width: geo.size.width, height: geo.size.height)
}
case .extraLarge:
// The iPad's wide widget: the day and the next stop on the left, the
// day's stops and the next booking on the right, each column
// anchored at its top and its foot.
HStack(alignment: .top, spacing: 20) {
VStack(alignment: .leading, spacing: 0) {
HStack(alignment: .top) {
VStack(alignment: .leading, spacing: 3) {
Text(trip.title).trekEyebrowOnPhoto()
Text(today.dayLabel)
.font(.system(size: 26, weight: .heavy))
.foregroundStyle(.white)
.lineLimit(1)
.minimumScaleFactor(0.8)
}
Spacer(minLength: 0)
if let weather = snapshot.weather { BigWeather(weather: weather) }
}
DaySegments(index: index, total: total, color: .white, track: .white.opacity(0.25)).padding(.top, 10)
Spacer(minLength: 8)
if let next {
Text(next.time ?? snapshot.labels.today)
.font(Trek.number(34, .heavy))
.foregroundStyle(.white)
Text(next.name)
.font(.system(size: 17, weight: .bold))
.foregroundStyle(.white)
.lineLimit(2)
.minimumScaleFactor(0.85)
} else {
Text(snapshot.labels.nothingPlanned).font(.system(size: 17, weight: .bold)).foregroundStyle(.white).lineLimit(2)
}
}
.frame(width: 176)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
VStack(alignment: .leading, spacing: 10) {
GlassPanel {
StopList(stops: today.stops, nowMinutes: nowMinutes, limit: 6, onPhoto: true, nothing: snapshot.labels.nothingPlanned, nowLabel: snapshot.labels.now)
}
Spacer(minLength: 0)
if let booking = snapshot.bookings?.first {
NextBookingStrip(booking: booking, labels: snapshot.labels, now: now)
}
}
.frame(width: 300)
.frame(maxHeight: .infinity, alignment: .top)
}
default:
VStack(alignment: .leading, spacing: 0) {
@@ -7,7 +7,11 @@ import WidgetKit
struct TripDayLiveActivity: Widget {
var body: some WidgetConfiguration {
ActivityConfiguration(for: TripDayAttributes.self) { context in
TripDayBanner(attributes: context.attributes, state: context.state)
TripDayBanner(
tripTitle: context.attributes.tripTitle, dayLabel: context.state.dayLabel,
nextName: context.state.nextName, nextTime: context.state.nextTime,
done: context.state.done, total: context.state.total
)
.activityBackgroundTint(Color(hex: 0x0C0C0F).opacity(0.92))
.activitySystemActionForegroundColor(.white)
.widgetURL(WidgetSnapshot.link(context.attributes.path))
@@ -31,7 +35,7 @@ struct TripDayLiveActivity: Widget {
DynamicIslandExpandedRegion(.bottom) {
VStack(alignment: .leading, spacing: 8) {
Text(state.nextName).font(.system(size: 17, weight: .heavy)).lineLimit(1)
StopProgress(done: state.done, total: state.total)
DayStopProgress(done: state.done, total: state.total)
}
.padding(.horizontal, 4)
}
@@ -55,58 +59,3 @@ struct TripDayLiveActivity: Widget {
}
}
}
/// The lock-screen banner: trip and day on top, the next stop large, the
/// day's stops as segments.
private struct TripDayBanner: View {
let attributes: TripDayAttributes
let state: TripDayAttributes.ContentState
var body: some View {
HStack(alignment: .center, spacing: 14) {
ZStack {
Circle().stroke(Color.white.opacity(0.18), lineWidth: 5)
Circle()
.trim(from: 0, to: state.total > 0 ? max(0.02, Double(state.done) / Double(state.total)) : 0)
.stroke(
AngularGradient(colors: [Trek.confirmed.opacity(0.7), Trek.confirmed], center: .center),
style: StrokeStyle(lineWidth: 5, lineCap: .round)
)
.rotationEffect(.degrees(-90))
Image(systemName: "mappin.and.ellipse").font(.system(size: 17, weight: .bold)).foregroundStyle(.white)
}
.frame(width: 52, height: 52)
VStack(alignment: .leading, spacing: 3) {
HStack {
Text(attributes.tripTitle)
.font(.system(size: 11, weight: .bold))
.tracking(0.5)
.textCase(.uppercase)
.foregroundStyle(.white.opacity(0.65))
.lineLimit(1)
Spacer(minLength: 4)
Text(state.dayLabel).font(.system(size: 11, weight: .bold)).foregroundStyle(.white.opacity(0.65)).lineLimit(1)
}
HStack(alignment: .firstTextBaseline) {
Text(state.nextName).font(.system(size: 18, weight: .heavy)).foregroundStyle(.white).lineLimit(1)
Spacer(minLength: 4)
if let time = state.nextTime {
Text(time).font(Trek.number(18, .heavy)).foregroundStyle(.white)
}
}
StopProgress(done: state.done, total: state.total).padding(.top, 3)
}
}
.padding(16)
}
}
/// The day's timed stops as segments, those behind filled.
private struct StopProgress: View {
let done: Int
let total: Int
var body: some View {
DaySegments(index: done + 1, total: max(total, 1), color: Trek.confirmed, track: Color.white.opacity(0.2))
}
}
@@ -51,7 +51,23 @@ struct Render {
}
let lock = LockScreen(snapshots: states.prefix(3).map(\.1), now: now).environment(\.colorScheme, scheme)
try write(lock, to: out.appendingPathComponent("lock-\(schemeName).png"))
// The iPad: every home-screen size of each widget as that iPad gives
// them, the extra large included, per kind in one strip.
for pad in TrekPad.allCases {
for (stateName, snapshot) in states where ["upcoming", "coverrunning", "empty"].contains(stateName) {
for kind in WidgetKind.allCases {
let strip = PadStrip(kind: kind, pad: pad, snapshot: snapshot, now: now)
.environment(\.colorScheme, scheme)
.environment(\.trekStaticPreview, true)
try write(strip, to: out.appendingPathComponent("pad-\(pad.rawValue)-\(kind.rawValue)-\(stateName)-\(schemeName).png"), scale: 2)
}
}
}
let padLock = PadLockScreen(snapshot: states[1].1, now: now).environment(\.colorScheme, scheme)
try write(padLock, to: out.appendingPathComponent("pad-lock-\(schemeName).png"), scale: 2)
}
try write(LiveActivityWidths(), to: out.appendingPathComponent("liveactivity-widths.png"))
let args = Array(CommandLine.arguments.dropFirst())
try Mockups.render(to: out, iconPath: args.count > 1 ? args[1] : nil, now: now)
print("wrote \(try FileManager.default.contentsOfDirectory(atPath: out.path).count) images to \(out.path)")
@@ -77,9 +93,9 @@ struct Render {
}
@MainActor
static func write<V: View>(_ view: V, to url: URL) throws {
static func write<V: View>(_ view: V, to url: URL, scale: CGFloat = 3) throws {
let renderer = ImageRenderer(content: view)
renderer.scale = 3
renderer.scale = scale
guard let image = renderer.nsImage,
let tiff = image.tiffRepresentation,
let bitmap = NSBitmapImageRep(data: tiff),
@@ -95,9 +111,9 @@ enum WidgetKind: String, CaseIterable {
var families: [TrekFamily] {
switch self {
case .trip: return [.small, .medium, .large, .circular, .rectangular, .inline]
case .today: return [.medium, .large]
case .bookings: return [.small, .medium, .large, .rectangular, .inline]
case .trip: return [.small, .medium, .large, .extraLarge, .circular, .rectangular, .inline]
case .today: return [.medium, .large, .extraLarge]
case .bookings: return [.small, .medium, .large, .extraLarge, .rectangular, .inline]
case .todos: return [.small, .medium, .large, .circular, .rectangular]
case .packing: return [.small, .medium, .large, .circular, .rectangular]
case .atlas: return [.small, .medium, .large, .circular, .rectangular, .inline]
@@ -113,9 +129,11 @@ struct WidgetFrame: View {
let family: TrekFamily
let snapshot: WidgetSnapshot
let now: Date
/// The device's size for the family, when it is not the 6.1" iPhone's.
var size: CGSize? = nil
var body: some View {
let size = family.previewSize
let size = self.size ?? family.previewSize
if family.isAccessory {
// As the lock screen draws them: white on the wallpaper, no box of their own
// (the circular one gets the system's soft disc).
@@ -270,3 +288,98 @@ struct NewWidgets: View {
)
}
}
/// One widget kind in every home-screen size an iPad gives it, side by side on
/// that iPad's wallpaper, the extra large last.
struct PadStrip: View {
let kind: WidgetKind
let pad: TrekPad
let snapshot: WidgetSnapshot
let now: Date
@Environment(\.colorScheme) private var scheme
var body: some View {
HStack(alignment: .top, spacing: 24) {
ForEach(kind.families.filter { !$0.isAccessory }, id: \.self) { family in
WidgetFrame(kind: kind, family: family, snapshot: snapshot, now: now, size: pad.size(family))
}
}
.padding(28)
.background(
LinearGradient(
colors: scheme == .dark ? [Color(hex: 0x1D2440), Color(hex: 0x0B0F1E)] : [Color(hex: 0xC9D8F0), Color(hex: 0xF3E3D3)],
startPoint: .topLeading, endPoint: .bottomTrailing
)
)
}
}
/// An 11" iPad's lock screen on its side: the clock with the accessories under
/// it on the left, as iPadOS places them, and the travel day's Live Activity.
struct PadLockScreen: View {
let snapshot: WidgetSnapshot
let now: Date
var body: some View {
HStack(alignment: .top, spacing: 0) {
VStack(alignment: .leading, spacing: 14) {
WidgetFrame(kind: .trip, family: .inline, snapshot: snapshot, now: now)
Text(now, format: .dateTime.hour().minute())
.font(.system(size: 96, weight: .semibold, design: .rounded))
.foregroundStyle(.white)
HStack(spacing: 10) {
WidgetFrame(kind: .trip, family: .circular, snapshot: snapshot, now: now)
WidgetFrame(kind: .todos, family: .circular, snapshot: snapshot, now: now)
WidgetFrame(kind: .packing, family: .circular, snapshot: snapshot, now: now)
}
WidgetFrame(kind: .trip, family: .rectangular, snapshot: snapshot, now: now)
WidgetFrame(kind: .bookings, family: .rectangular, snapshot: snapshot, now: now)
WidgetFrame(kind: .todos, family: .rectangular, snapshot: snapshot, now: now)
}
.padding(.leading, 64)
.padding(.top, 70)
Spacer(minLength: 0)
}
.frame(width: 1194, height: 834, alignment: .topLeading)
.overlay(alignment: .bottom) {
LiveActivityCard(width: 500).padding(.bottom, 40)
}
.background(
LinearGradient(colors: [Color(hex: 0x2B3A67), Color(hex: 0x6B4A6E), Color(hex: 0x1C1726)], startPoint: .top, endPoint: .bottom)
)
}
}
/// The travel day's Live Activity as the lock screen tints it.
struct LiveActivityCard: View {
let width: CGFloat
var body: some View {
TripDayBanner(
tripTitle: "Autumn in Kyoto", dayLabel: "Day 3 of 9",
nextName: "Kiyomizu-dera", nextTime: "13:30", done: 2, total: 5
)
.frame(width: width)
.background(RoundedRectangle(cornerRadius: 24, style: .continuous).fill(Color(hex: 0x0C0C0F).opacity(0.92)))
}
}
/// The Live Activity at the widths an iPhone's and an iPad's lock screen give
/// it, and with a long German day and stop, to see it hold at each.
struct LiveActivityWidths: View {
var body: some View {
VStack(alignment: .leading, spacing: 18) {
ForEach([364, 500, 640] as [CGFloat], id: \.self) { width in
LiveActivityCard(width: width)
}
TripDayBanner(
tripTitle: "Herbst in Kyoto und Nara", dayLabel: "Tag 3 von 9",
nextName: "Kiyomizu-dera und Sannenzaka-Spaziergang", nextTime: "13:30", done: 2, total: 5
)
.frame(width: 364)
.background(RoundedRectangle(cornerRadius: 24, style: .continuous).fill(Color(hex: 0x0C0C0F).opacity(0.92)))
}
.padding(28)
.background(Color(hex: 0x1E2A3A))
}
}
+6 -1
View File
@@ -1,4 +1,4 @@
import { SUPPORTED_LANGUAGE_CODES } from '@trek/shared/i18n'
import { SUPPORTED_LANGUAGE_CODES, isRtlLanguage } from '@trek/shared/i18n'
import type { TranslationStrings } from '@trek/shared/i18n'
// Only the native strings of every locale, not the whole UI: this page ships
@@ -32,3 +32,8 @@ export function translator(code: string) {
: key
}
}
/** Arabic and Hebrew run right to left. */
export function directionOf(code: string): 'rtl' | 'ltr' {
return isRtlLanguage(code) ? 'rtl' : 'ltr'
}
+99 -52
View File
@@ -9,7 +9,7 @@ import { fakeIapClient, getIapClient, productIdFor, type Purchase } from '../src
import { CLOUD_TERMS_URL, CLOUD_URL, PRIVACY_URL, SERVER_DOCS_URL, STORE_TERMS_URL } from '../src/native/links';
import { probeDemo, probeServer, type ProbeFailure, type ProbeGet } from '../src/native/serverAddress';
import { TrekShell } from '../src/native/trekShell';
import { pickLocale, translator } from './i18n';
import { directionOf, pickLocale, translator } from './i18n';
import './shell.css';
/**
@@ -24,7 +24,7 @@ const locale = pickLocale(navigator.languages?.length ? navigator.languages : [n
const onPlay = Capacitor.getPlatform() === 'android';
const t = translator(locale);
document.documentElement.lang = locale;
document.documentElement.dir = locale === 'ar' ? 'rtl' : 'ltr';
document.documentElement.dir = directionOf(locale);
const root = document.getElementById('shell')!;
const PROBE_TIMEOUT_MS = 8000;
@@ -128,6 +128,11 @@ function show(screen: HTMLElement, opts: { back?: boolean } = {}): void {
if (opts.back) screen.classList.add('screen-back');
const previous = current;
if (previous) {
// The leaving screen is lifted out of the flow where it stands, so it fades
// from its own place rather than jumping to the top of a centred layout.
previous.style.top = `${previous.offsetTop}px`;
previous.style.left = `${previous.offsetLeft}px`;
previous.style.width = `${previous.offsetWidth}px`;
previous.classList.remove('screen-enter');
previous.classList.add('screen-leave');
if (opts.back) previous.classList.add('screen-back');
@@ -144,6 +149,15 @@ function externalLink(text: string, href: string): HTMLAnchorElement {
function footLinks(...extra: Node[]): HTMLElement {
return el('div', { className: 'foot-links' }, ...extra, externalLink(t('native.privacy'), PRIVACY_URL));
}
/**
* A screen's two halves: what it is (brand, title, explanation) and what to do
* on it (the choices, the form). Stacked in one column they read as before; on
* a phone or a foldable on its side the screen is too short for one column, so
* the halves stand side by side there (shell.css, `.col`).
*/
function columns(about: Node[], act: Node[]): HTMLElement[] {
return [el('div', { className: 'col col-about' }, ...about), el('div', { className: 'col col-act' }, ...act)];
}
function topbar(onBack: () => void): HTMLElement {
const back = el('button', { type: 'button', className: 'back' }, icon('M15 18l-6-6 6-6', 18), t('native.back'));
back.addEventListener('click', () => {
@@ -202,13 +216,14 @@ function showWelcome(opts: { back?: boolean } = {}): void {
el(
'div',
{},
hero,
el('h1', { className: 'title brand-title' }, t('native.welcome.headline')),
el('p', { className: 'sub' }, t('native.welcome.sub')),
cloud,
connect,
demo,
footLinks(externalLink(t('native.connect.whatIsServer'), SERVER_DOCS_URL))
...columns(
[
hero,
el('h1', { className: 'title brand-title' }, t('native.welcome.headline')),
el('p', { className: 'sub' }, t('native.welcome.sub')),
],
[cloud, connect, demo, footLinks(externalLink(t('native.connect.whatIsServer'), SERVER_DOCS_URL))]
)
),
opts
);
@@ -225,6 +240,8 @@ function showConnect(prefill = '', opts: { back?: boolean } = {}): void {
});
input.setAttribute('inputmode', 'url');
input.setAttribute('autocomplete', 'url');
// An address reads left to right in every language, Arabic too.
input.dir = 'ltr';
// The on-screen keyboard's return key submits the address; it reads "Go", not a newline.
input.setAttribute('enterkeyhint', 'go');
input.setAttribute('aria-label', t('native.connect.title'));
@@ -266,11 +283,14 @@ function showConnect(prefill = '', opts: { back?: boolean } = {}): void {
el(
'div',
{},
topbar(() => showWelcome({ back: true })),
el('h1', { className: 'title-sm' }, t('native.connect.title')),
el('p', { className: 'sub' }, t('native.connect.hint')),
form,
footLinks(externalLink(t('native.connect.whatIsServer'), SERVER_DOCS_URL))
...columns(
[
topbar(() => showWelcome({ back: true })),
el('h1', { className: 'title-sm' }, t('native.connect.title')),
el('p', { className: 'sub' }, t('native.connect.hint')),
],
[form, footLinks(externalLink(t('native.connect.whatIsServer'), SERVER_DOCS_URL))]
)
),
opts
);
@@ -288,7 +308,13 @@ function feature(path: string, text: string): HTMLElement {
}
function showCloud(opts: { back?: boolean } = {}): void {
const cta = el('button', { type: 'button', className: 'btn' }, t('native.cloud.cta'), icon(I.chevron, 20, 'chev'));
// The label centred, the chevron at the far edge: it points on, not at the word.
const cta = el(
'button',
{ type: 'button', className: 'btn btn-go' },
el('span', { className: 'btn-label' }, t('native.cloud.cta')),
icon(I.chevron, 20, 'chev')
);
cta.addEventListener('click', () => {
void tap();
showPlans();
@@ -304,25 +330,32 @@ function showCloud(opts: { back?: boolean } = {}): void {
el(
'div',
{},
topbar(() => showWelcome({ back: true })),
el(
'div',
{ className: 'cloud-wordmark', role: 'img', ariaLabel: t('native.cloud.title') },
el('img', { src: logoWhite, alt: '', className: 'on-dark' }),
el('img', { src: logoBlack, alt: '', className: 'on-light' }),
el('span', { className: 'cloud-tag' }, 'cloud')
),
el('p', { className: 'sub' }, t('native.cloud.sub')),
el(
'div',
{ className: 'features' },
feature(I.globe, t('native.cloud.f1')),
feature(I.bolt, t('native.cloud.f2')),
feature(I.shield, t('native.cloud.f3'))
),
cta,
have,
footLinks()
...columns(
[
topbar(() => showWelcome({ back: true })),
// The lockup is a logo: "trek" then "cloud" in every language, never mirrored.
el(
'div',
{ className: 'cloud-wordmark', role: 'img', ariaLabel: t('native.cloud.title'), dir: 'ltr' },
el('img', { src: logoWhite, alt: '', className: 'on-dark' }),
el('img', { src: logoBlack, alt: '', className: 'on-light' }),
el('span', { className: 'cloud-tag' }, 'cloud')
),
el('p', { className: 'sub' }, t('native.cloud.sub')),
],
[
el(
'div',
{ className: 'features' },
feature(I.globe, t('native.cloud.f1')),
feature(I.bolt, t('native.cloud.f2')),
feature(I.shield, t('native.cloud.f3'))
),
cta,
have,
footLinks(),
]
)
),
opts
);
@@ -427,7 +460,9 @@ function planCard(plan: CloudPlan, interval: 'month' | 'year', offer: Offer): HT
{ className: 'plan-top' },
el('span', { className: 'plan-name' }, plan.name),
// No price yet (orders not open): the plan shows without one rather than with an empty one.
...(label ? [el('span', { className: 'plan-price' }, label, el('small', {}, per))] : [])
// The price is figures and a currency sign: one left-to-right run, so Arabic
// keeps "5,99 €" whole, with the period standing apart beside it.
...(label ? [el('span', { className: 'plan-price' }, el('bdi', { dir: 'ltr' }, label), el('small', {}, per))] : [])
),
el(
'div',
@@ -578,12 +613,15 @@ function showPlans(opts: { back?: boolean } = {}): void {
el(
'div',
{},
topbar(() => showCloud({ back: true })),
el('h1', { className: 'title-sm' }, t('native.plans.title')),
el('p', { className: 'sub' }, t('native.plans.sub')),
seg,
content,
footer
...columns(
[
topbar(() => showCloud({ back: true })),
el('h1', { className: 'title-sm' }, t('native.plans.title')),
el('p', { className: 'sub' }, t('native.plans.sub')),
seg,
],
[content, footer]
)
),
opts
);
@@ -661,9 +699,10 @@ function showClaim(
slugInput.setAttribute('autocomplete', 'off');
slugInput.setAttribute('maxlength', '40');
slugInput.setAttribute('aria-label', t('native.claim.addressLabel'));
// "name.liketrek.com" is one address, left to right in every language.
const slugField = el(
'div',
{ className: 'field slug-field' },
{ className: 'field slug-field', dir: 'ltr' },
slugInput,
el('span', { className: 'slug-suffix' }, cloudSuffix())
);
@@ -680,6 +719,8 @@ function showClaim(
emailInput.setAttribute('autocomplete', 'email');
emailInput.setAttribute('enterkeyhint', 'go');
emailInput.setAttribute('aria-label', t('native.claim.emailLabel'));
emailInput.dir = 'ltr';
emailInput.classList.add('ltr-value');
const emailField = el('div', { className: 'field' }, emailInput);
const submit = el('button', { type: 'submit', className: 'btn' }, t('native.claim.subscribe', { price }));
@@ -805,10 +846,14 @@ function showClaim(
el(
'div',
{},
topbar(() => showPlans({ back: true })),
el('h1', { className: 'title-sm' }, t('native.claim.title')),
el('p', { className: 'sub' }, t('native.claim.sub')),
form
...columns(
[
topbar(() => showPlans({ back: true })),
el('h1', { className: 'title-sm' }, t('native.claim.title')),
el('p', { className: 'sub' }, t('native.claim.sub')),
],
[form]
)
),
opts
);
@@ -933,12 +978,14 @@ function showUnreachable(server: string): void {
el(
'div',
{},
el('img', { src: logoUrl, alt: '', className: 'shell-logo' }),
el('h1', { className: 'title-sm' }, t('native.offline.title')),
el('p', { className: 'sub' }, t('native.offline.hint', { server: new URL(server).host })),
retry,
change,
footLinks()
...columns(
[
el('img', { src: logoUrl, alt: '', className: 'shell-logo' }),
el('h1', { className: 'title-sm' }, t('native.offline.title')),
el('p', { className: 'sub' }, t('native.offline.hint', { server: new URL(server).host })),
],
[retry, change, footLinks()]
)
)
);
}
+283 -24
View File
@@ -38,6 +38,45 @@
font-display: swap;
src: url('./fonts/museomoderno-latin-600.woff2') format('woff2');
}
/*
* Poppins and MuseoModerno have no Cyrillic, so Russian and Ukrainian fell back
* to the system's plain sans. Each family gets a Cyrillic-only stand-in under its
* own name, picked for the same build: Montserrat for the geometric text face,
* Comfortaa for the rounded headline. The unicode-range keeps Latin on TREK's
* own faces and loads these only when a Cyrillic letter is on screen.
*/
@font-face {
font-family: 'Poppins';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url('./fonts/montserrat-cyrillic-500.woff2') format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
@font-face {
font-family: 'Poppins';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url('./fonts/montserrat-cyrillic-600.woff2') format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
@font-face {
font-family: 'Poppins';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('./fonts/montserrat-cyrillic-700.woff2') format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
@font-face {
font-family: 'MuseoModerno';
font-style: normal;
font-weight: 600 700;
font-display: swap;
src: url('./fonts/comfortaa-cyrillic-700.woff2') format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* Italic, subset to the letters of "cloud" — the wordmark's badge, nothing else. */
@font-face {
font-family: 'MuseoModerno';
@@ -71,8 +110,13 @@
--radius: 16px;
--ease: cubic-bezier(0.22, 1, 0.36, 1);
--ease-in: cubic-bezier(0.4, 0, 1, 1);
/* 1 left to right, -1 in Arabic: a screen that comes next slides in from the trailing side. */
--flow: 1;
color-scheme: dark;
}
:root[dir='rtl'] {
--flow: -1;
}
@media (prefers-color-scheme: light) {
:root {
@@ -167,6 +211,18 @@ body::after {
pointer-events: none;
z-index: 0;
}
/* In Arabic the photo holds the leading (right) side and the grid the other. */
:root[dir='rtl'] body::after {
background-position:
0 0,
right 28%;
-webkit-mask-image: linear-gradient(270deg, #000 0%, #000 40%, transparent 80%);
mask-image: linear-gradient(270deg, #000 0%, #000 40%, transparent 80%);
}
:root[dir='rtl'] body::before {
-webkit-mask-image: radial-gradient(135% 95% at 0% 0%, #000 34%, transparent 80%);
mask-image: radial-gradient(135% 95% at 0% 0%, #000 34%, transparent 80%);
}
.shell {
position: relative;
@@ -175,11 +231,12 @@ body::after {
min-height: 100dvh;
display: flex;
flex-direction: column;
justify-content: center;
max-width: 440px;
margin: 0 auto;
padding: calc(env(safe-area-inset-top) + 28px) 24px calc(env(safe-area-inset-bottom) + 24px);
overflow: hidden;
/* Clip only the sideways slide: a screen taller than the window scrolls the
page instead (and a sticky column needs an ancestor that does not scroll). */
overflow-x: clip;
}
/* ── Screen transitions ──────────────────────────────────────────────────── */
@@ -187,14 +244,22 @@ body::after {
display: flex;
flex-direction: column;
gap: 14px;
/* Centred while it fits; taller than the window it starts at the top and the
page scrolls, rather than overflowing both edges as justify-content would. */
margin-block: auto;
}
/* A screen's two halves (main.ts, columns()): one column unless the layout asks. */
.col {
display: contents;
}
.screen-enter {
animation: screenIn 0.5s var(--ease) both;
}
/* Lifted out where it stood: main.ts sets its top, left and width. */
.screen-leave {
animation: screenOut 0.28s var(--ease-in) both;
position: absolute;
inset: calc(env(safe-area-inset-top) + 28px) 24px calc(env(safe-area-inset-bottom) + 24px);
margin: 0;
}
.screen-back.screen-enter {
animation-name: screenInBack;
@@ -205,7 +270,7 @@ body::after {
@keyframes screenIn {
from {
opacity: 0;
transform: translateX(26px);
transform: translateX(calc(26px * var(--flow)));
}
to {
opacity: 1;
@@ -215,13 +280,13 @@ body::after {
@keyframes screenOut {
to {
opacity: 0;
transform: translateX(-22px);
transform: translateX(calc(-22px * var(--flow)));
}
}
@keyframes screenInBack {
from {
opacity: 0;
transform: translateX(-26px);
transform: translateX(calc(-26px * var(--flow)));
}
to {
opacity: 1;
@@ -231,14 +296,40 @@ body::after {
@keyframes screenOutBack {
to {
opacity: 0;
transform: translateX(22px);
transform: translateX(calc(22px * var(--flow)));
}
}
/* Children stagger in, so a screen assembles rather than snaps. */
.screen-enter > * {
/* Children stagger in, so a screen assembles rather than snaps. Inside the two
halves the explanation comes first and the choices follow. */
.screen-enter > :not(.col),
.screen-enter > .col > * {
animation: riseIn 0.55s var(--ease) both;
}
.screen-enter > .col > :nth-child(1) {
animation-delay: 0.02s;
}
.screen-enter > .col > :nth-child(2) {
animation-delay: 0.07s;
}
.screen-enter > .col > :nth-child(3) {
animation-delay: 0.12s;
}
.screen-enter > .col > :nth-child(4) {
animation-delay: 0.17s;
}
.screen-enter > .col-act > :nth-child(1) {
animation-delay: 0.15s;
}
.screen-enter > .col-act > :nth-child(2) {
animation-delay: 0.2s;
}
.screen-enter > .col-act > :nth-child(3) {
animation-delay: 0.25s;
}
.screen-enter > .col-act > :nth-child(4) {
animation-delay: 0.3s;
}
.screen-enter > *:nth-child(1) {
animation-delay: 0.02s;
}
@@ -364,6 +455,7 @@ body::after {
margin: 0;
/* Honour the newline in the headline string (e.g. "Deine Reisen.\nDein Plan."). */
white-space: pre-line;
text-wrap: balance;
}
/* The welcome headline as the brand says it: MuseoModerno, lower case. */
.brand-title {
@@ -380,6 +472,8 @@ body::after {
display: inline-flex;
align-items: center;
gap: 9px;
/* Left to right inside (dir="ltr"), yet on the screen's start edge in Arabic. */
align-self: flex-start;
}
.cloud-wordmark img {
display: block;
@@ -414,6 +508,7 @@ body::after {
font-weight: 700;
letter-spacing: -0.025em;
margin: 0;
text-wrap: balance;
}
.sub {
color: var(--muted);
@@ -421,6 +516,8 @@ body::after {
line-height: 1.55;
font-size: 0.9rem;
overflow-wrap: anywhere;
/* No lone last word ("AI.") on a line of its own. */
text-wrap: balance;
}
/* ── Buttons ─────────────────────────────────────────────────────────────── */
@@ -428,9 +525,10 @@ body::after {
font: inherit;
font-weight: 600;
font-size: 1.02rem;
padding: 16px 18px;
padding: 16px 22px;
border: 1px solid transparent;
border-radius: var(--radius);
/* A pill, as the app's buttons are. */
border-radius: 999px;
background: var(--accent);
color: var(--accent-ink);
cursor: pointer;
@@ -455,6 +553,11 @@ body::after {
.btn:disabled {
opacity: 0.55;
}
/* A two-line choice (welcome) is a card rather than a button: the app's card radius. */
.btn:has(.btn-row) {
border-radius: 22px;
padding-inline: 18px;
}
.btn-ghost {
background: var(--surface);
color: var(--ink);
@@ -471,6 +574,8 @@ body::after {
flex-direction: column;
align-items: flex-start;
gap: 2px;
/* A subtitle that wraps keeps to the start edge under its title. */
text-align: start;
}
.btn-row b {
font-weight: 650;
@@ -481,9 +586,20 @@ body::after {
opacity: 0.7;
}
.btn .chev {
margin-left: auto;
flex: none;
margin-inline-start: auto;
opacity: 0.5;
}
/* A centred label with the chevron at the far edge, the label optically centred. */
.btn-go .btn-label {
flex: 1;
padding-inline-start: 20px;
}
/* Chevrons point along the reading direction. */
:root[dir='rtl'] .chev,
:root[dir='rtl'] .back svg {
transform: scaleX(-1);
}
.linkbtn {
font: inherit;
@@ -507,7 +623,7 @@ body::after {
margin-top: 2px;
padding: 9px 18px;
min-height: 40px;
border-radius: var(--radius);
border-radius: 999px;
border: 1px solid var(--edge);
background: transparent;
color: var(--muted);
@@ -565,8 +681,8 @@ a.linkbtn {
width: 100%;
font: inherit;
font-size: 1rem;
padding: 13px 16px;
border-radius: var(--radius);
padding: 13px 18px;
border-radius: 999px;
border: 1px solid var(--edge);
background: var(--surface);
color: var(--ink);
@@ -707,7 +823,8 @@ a.linkbtn {
.topbar {
display: flex;
align-items: center;
margin: 0 0 2px -8px;
margin-block: 0 2px;
margin-inline-start: -8px;
}
.back {
font: inherit;
@@ -741,7 +858,7 @@ a.linkbtn {
padding: 4px;
background: var(--surface);
border: 1px solid var(--edge);
border-radius: 13px;
border-radius: 999px;
align-self: stretch;
}
.segmented button {
@@ -751,7 +868,7 @@ a.linkbtn {
font-size: 0.95rem;
padding: 9px;
border: 0;
border-radius: 9px;
border-radius: 999px;
background: none;
color: var(--muted);
cursor: pointer;
@@ -829,7 +946,7 @@ a.linkbtn {
font-weight: 500;
font-size: 0.8rem;
color: var(--muted);
margin-left: 7px;
margin-inline-start: 7px;
}
.plan-feats {
margin: 10px 0 14px;
@@ -902,8 +1019,8 @@ a.linkbtn {
.slug-field {
display: flex;
align-items: center;
padding-right: 16px;
border-radius: var(--radius);
padding-inline-end: 18px;
border-radius: 999px;
border: 1px solid var(--edge);
background: var(--surface);
box-shadow:
@@ -913,14 +1030,20 @@ a.linkbtn {
border-color 0.2s,
box-shadow 0.25s;
}
.slug-field .slug-input {
.slug-field .slug-input,
.slug-field .slug-input:focus {
flex: 1;
min-width: 0;
text-align: left;
text-align: start;
border: 0;
background: transparent;
/* The ring is the whole field's (focus-within below), not a second one inside it. */
box-shadow: none;
}
/* An email is typed left to right, but its Arabic placeholder reads from the right. */
:root[dir='rtl'] .field input.ltr-value:placeholder-shown {
direction: rtl;
}
.slug-field:focus-within {
border-color: var(--edge-strong);
box-shadow: 0 0 0 4px color-mix(in srgb, var(--ink) 9%, transparent);
@@ -934,7 +1057,7 @@ a.linkbtn {
flex: none;
color: var(--muted);
font-size: 0.95rem;
padding-left: 4px;
padding-inline-start: 4px;
user-select: none;
}
.hint {
@@ -976,3 +1099,139 @@ a.linkbtn {
border-color: var(--edge-strong);
border-top-color: var(--accent);
}
/* ── A large screen ──────────────────────────────────────────────────────────
On its side (an iPad, a foldable unfolded) the photo becomes a panel of its
own on the leading half, full height, and the column of choices stands
centred in the other half, the way a split sign-in page uses the width.
Upright, the photo band grows with the screen instead of stopping at a
phone's 440px. Logical sides, so Arabic mirrors it. */
@media (min-width: 840px) and (min-height: 600px) and (orientation: landscape) {
body::after,
:root[dir='rtl'] body::after {
inset: 16px auto 16px 16px;
inset-block: 16px;
inset-inline: 16px auto;
width: calc(50% - 24px);
height: auto;
border-radius: 28px;
background:
linear-gradient(180deg, transparent 50%, rgba(0, 0, 0, 0.32) 100%),
url('./img/kyoto.webp') 30% 45% / cover no-repeat;
-webkit-mask-image: none;
mask-image: none;
box-shadow: 0 30px 70px -30px rgba(0, 0, 0, 0.55);
}
body::before {
inset: 0;
inset-inline-start: 50%;
height: auto;
}
.shell {
max-width: none;
width: 50%;
margin-inline: 50% 0;
padding-inline: max(32px, calc(25vw - 220px));
}
}
/* With the whole half of a 13" iPad on its side, the plans stand two by two
rather than in a column to scroll through. */
@media (min-width: 1200px) and (min-height: 600px) and (orientation: landscape) {
.shell:has(.plans) {
padding-inline: max(32px, calc(25vw - 320px));
}
.plans {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
/* An iPad upright, a foldable unfolded, half of an iPad in Split View: the photo
spans the whole width as a band across the top and fades out only downwards,
and the choices stand under it in a wider column than a phone's, centred in
the room the band leaves, so the page reads as one composition rather than a
phone screen at the top of the glass. The plans take two columns. */
@media (min-width: 600px) and (min-height: 800px) and (orientation: portrait) {
body::before,
body::after {
height: 56vh;
}
body::after,
:root[dir='rtl'] body::after {
background-position:
0 0,
center 30%;
-webkit-mask-image: none;
mask-image: none;
}
.shell {
max-width: 560px;
padding-top: calc(env(safe-area-inset-top) + 28vh);
padding-bottom: calc(env(safe-area-inset-bottom) + 6vh);
}
.shell:has(.plans) {
max-width: 760px;
padding-top: calc(env(safe-area-inset-top) + 16vh);
}
.plans {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
/* ── A short screen on its side ──────────────────────────────────────────────
A phone, or a foldable folded, turned landscape: one column of choices is
taller than the glass, and the second choice or the main button ended up
below the edge. The two halves of a screen stand side by side instead, what
it is on the leading side and what to do on the other, top-aligned under the
status bar. The leading half stays put while a long list beside it (the
plans) scrolls, so Back is always in reach. */
@media (orientation: landscape) and (max-height: 500px) {
.shell {
max-width: 880px;
padding-top: calc(env(safe-area-inset-top) + 16px);
padding-bottom: calc(env(safe-area-inset-bottom) + 16px);
padding-inline: max(28px, env(safe-area-inset-left)) max(28px, env(safe-area-inset-right));
}
.screen:has(> .col) {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
column-gap: 40px;
align-items: center;
}
/* The plans run longer than the window: both halves start at the top. */
.screen:has(.plans) {
align-items: start;
}
.col {
display: flex;
flex-direction: column;
gap: 12px;
}
.col-about {
position: sticky;
top: calc(env(safe-area-inset-top) + 16px);
}
.hero-logo {
margin-bottom: 4px;
}
.hero-logo img {
height: 46px;
}
.title {
font-size: 1.75rem;
}
.btn {
min-height: 50px;
padding-block: 12px;
}
.features {
margin: 0;
}
.feature {
padding: 7px 0;
}
.foot-links {
margin-top: 0;
padding-top: 10px;
}
}
+2 -2
View File
@@ -1,7 +1,7 @@
{
"src": {
"@typescript-eslint/no-non-null-assertion": 375,
"@typescript-eslint/no-misused-promises": 847,
"@typescript-eslint/no-misused-promises": 845,
"no-console": 88,
"complexity": 218,
"@typescript-eslint/no-explicit-any": 144,
@@ -35,7 +35,7 @@
},
"tests": {
"@typescript-eslint/no-explicit-any": 489,
"@typescript-eslint/no-non-null-assertion": 1912,
"@typescript-eslint/no-non-null-assertion": 1911,
"@typescript-eslint/no-misused-promises": 22,
"@typescript-eslint/no-unused-vars": 51,
"@typescript-eslint/no-floating-promises": 33,
+15 -2
View File
@@ -282,11 +282,17 @@
"mobile": ["src/mobile/screens/trip/sheets/MCostSheet.tsx", "src/mobile/screens/trip/tabs/MCostsTab.tsx"]
},
{
"feature": "Costs/budget ledger: useCostsLedger, useSettlementForm, useExpenseLinks",
"hooks": ["src/components/Budget/useCostsLedger.ts", "src/components/Budget/useSettlementForm.ts", "src/components/Budget/useExpenseLinks.ts"],
"feature": "Costs/budget ledger: useCostsLedger, useExpenseLinks",
"hooks": ["src/components/Budget/useCostsLedger.ts", "src/components/Budget/useExpenseLinks.ts"],
"desktop": ["src/components/Budget/CostsPanel.tsx"],
"mobile": ["src/mobile/screens/trip/tabs/MCostsTab.tsx"]
},
{
"feature": "Costs/budget settle-up payment: useSettlementForm",
"hooks": ["src/components/Budget/useSettlementForm.ts"],
"desktop": ["src/components/Budget/CostsPanel.tsx"],
"mobile": ["src/mobile/screens/trip/tabs/MCostsPayment.tsx"]
},
{
"feature": "Create/edit trip form",
"hooks": ["src/components/Trips/tripFormModel.ts", "src/components/Trips/useTripCoverPicker.ts"],
@@ -1002,11 +1008,13 @@
"src/mobile/screens/collections/MCollInvites.tsx": "Markup only; the invites and accept and decline come through props from useCollections via MCollections.",
"src/mobile/screens/collections/MCollLabelsSheet.tsx": "Phone label sheet; create, update, delete and assign come through props from useCollections.",
"src/mobile/screens/collections/MCollLinksEditor.tsx": "Markup only; link rows and the change handler come through props from the collection sheets.",
"src/mobile/screens/collections/MCollListNav.tsx": "Markup only; the collections' lists as the header dropdown or as the column beside the places on a wide window, with the lists and the selection handed in through props from useCollections.",
"src/mobile/screens/collections/MCollListPickerSheet.tsx": "Phone list picker sheet; the move or copy action comes through onPick from useCollections.",
"src/mobile/screens/collections/MCollSheetKit.tsx": "Phone only sheet UI primitives for the collections screens (header, footer, pill, eyebrow).",
"src/mobile/screens/collections/collectionsMobileModel.ts": "Visual constants only the phone collections screens use (category and status specs, swatches, cover gradient).",
"src/mobile/screens/dashboard/MUserMenu.tsx": "Phone avatar menu with a three state theme cycle the desktop navbar does not have.",
"src/mobile/screens/journey/MDawarichDaySheet.tsx": "Markup only; the phone frame the Dawarich day sheets (route and stays) render their shared review in.",
"src/mobile/screens/journey/MJourneyFeed.tsx": "Markup only; the journey's entries as a feed beside the map on a wide window, with the entries, the active one and the tap handed in through props from useJourneyDetail and useJourneyCarouselSync.",
"src/mobile/screens/settings/MAppLockCard.tsx": "Phone only: the app lock exists only in the native app, so there is no desktop setting to share with.",
"src/mobile/screens/settings/MConfirmSheet.tsx": "Phone only bottom sheet confirm dialog used across the mobile screens.",
"src/mobile/screens/settings/MDawarichConnectionSection.tsx": "Already shares hooks/useDawarichConnection with components/Settings/DawarichConnectionSection.tsx.",
@@ -1018,6 +1026,8 @@
"src/mobile/screens/settings/MSettingsUi.tsx": "Phone only settings UI primitives (card, row, input, button, segments, hint).",
"src/mobile/screens/settings/MTileCacheUsage.tsx": "Phone only: the native tile cache size and clear action, which the browser build has no counterpart for.",
"src/mobile/screens/settings/MWebPushCard.tsx": "Already shares hooks/useWebPush with components/Settings/WebPushCard.tsx.",
"src/mobile/screens/trip/MTripRail.tsx": "Phone shell only; the trip's sections on the leading edge of a wide window in place of the dock, with the tabs and handlers MTripShell hands in.",
"src/mobile/screens/trip/MTripShellParts.tsx": "Markup only; small pieces of MTripShell's top controls (segment pill, compact toggle, document sync button), with the state and handlers MTripShell hands in.",
"src/mobile/screens/trip/dockTabs.ts": "Phone only helper that picks which trip tabs get a seat in the mobile dock.",
"src/mobile/screens/trip/lib/dayNotes.ts": "Phone only helper that splits a leading time off a day note for the plan timeline.",
"src/mobile/screens/trip/map/MMapArea.tsx": "Phone map stage framing; already shares usePoiExplore and useMergedMapPois with the desktop trip planner.",
@@ -1053,10 +1063,13 @@
"src/mobile/screens/trip/sheets/PlTimeFields.tsx": "Phone time fields that already share timeCollisions with the desktop PlaceFormModal.",
"src/mobile/screens/trip/sheets/tourSheetOpen.ts": "Phone only context for MTourDetailShell: whether the tour sheet is open and the name it carries.",
"src/mobile/screens/trip/tabs/MCollabTab.tsx": "Phone collab sub tab router that reuses the desktop CollabLinks component.",
"src/mobile/screens/trip/tabs/MFilesBoard.tsx": "Markup only; the trip's files as tiles and the picked file's preview on a wide window, with the files and their actions handed in through props from MFilesTab.",
"src/mobile/screens/trip/tabs/MListsTab.tsx": "Phone router between the packing and to-do bodies, with no logic of its own.",
"src/mobile/screens/trip/tabs/MReceiptScanButton.tsx": "Already shares useReceiptScan and ReceiptScanModal with the desktop CostsPanel.",
"src/mobile/screens/trip/tabs/MReservationCardParts.tsx": "Markup only: the title, actions, date, files and delete sheet parts both phone booking tabs draw.",
"src/mobile/screens/trip/tabs/MTodoSidebar.tsx": "Markup only; the to-do filters as a pane beside the tasks on a wide window, with the filter state and the counts handed in through props from useTodoView.",
"src/mobile/screens/trip/tabs/MTripTabPanel.tsx": "Phone tab router that mounts the per tab screens and plugin frames, with no logic of its own.",
"src/mobile/screens/trip/tabs/collabPanes.ts": "Phone only; decides whether the collab tab shows the chat beside a board on a wide window, a layout question of the touch shell that the desktop planner does not have.",
"src/mobile/screens/trip/tabs/listsModel.ts": "Priority colour and label constants only the phone to-do list and task sheet use.",
"src/mobile/screens/trip/tabs/tabChrome.tsx": "Phone tab chrome primitives (scroller, section headers, fields, traveler chips, plugin slots).",
"src/mobile/screens/trip/tabs/tabModel.ts": "Phone tab prop type and status colour tokens only the phone tabs use.",
+1 -1
View File
@@ -2,7 +2,7 @@
"admin.ts": 112,
"atlas.ts": 9,
"backup.ts": 10,
"budget.ts": 36,
"budget.ts": 35,
"categories.ts": 1,
"collab.ts": 9,
"collection.ts": 35,
-5
View File
@@ -79,15 +79,10 @@
"hooks/useAnchoredPosition.ts": 3,
"hooks/useElementRect.ts": 3,
"mobile/components/MSegmented.tsx": 1,
"mobile/components/MSheet.tsx": 1,
"mobile/screens/dashboard/MDashboard.tsx": 1,
"mobile/screens/journey/MJourneyDetail.tsx": 3,
"mobile/screens/journey/MJourneyEntrySheet.tsx": 1,
"mobile/screens/trip/MTripShell.tsx": 3,
"mobile/screens/trip/map/MMapArea.tsx": 3,
"mobile/screens/trip/tabs/MCollabPolls.tsx": 1,
"mobile/screens/trip/tabs/MFileLightbox.tsx": 2,
"mobile/screens/vacay/MVacay.tsx": 1,
"mobile/screens/vacay/MVacayMonth.tsx": 1,
"pages/AtlasPage.tsx": 2,
"pages/JourneyDetailPage.tsx": 1,
+2 -2
View File
@@ -5,7 +5,7 @@
"src/components/Admin/AdminPluginsPanel.tsx": 1266,
"src/components/Budget/CostsPanel.test.tsx": 2730,
"src/components/Budget/CostsPanel.tsx": 1798,
"src/components/Map/MapView.tsx": 1214,
"src/components/Map/MapView.tsx": 1201,
"src/components/Map/MapViewGL.test.tsx": 2919,
"src/components/Map/MapViewGL.tsx": 2127,
"src/components/Planner/DayDetailPanel.test.tsx": 2333,
@@ -20,7 +20,7 @@
"src/pages/JourneyDetailPage.test.tsx": 4028,
"src/pages/LoginPage.tsx": 1347,
"src/pages/TripPlannerPage.tsx": 1128,
"src/pages/atlas/useAtlas.ts": 1210,
"src/pages/atlas/useAtlas.ts": 1207,
"src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx": 3518,
"src/pages/tripPlanner/useTripPlanner.test.tsx": 3270,
"src/styles/studio.css": 1918
+7 -7
View File
@@ -40,7 +40,7 @@
"components/Journey/JourneyDetailPageProviderPicker.tsx": 163,
"components/Journey/JourneyDetailPageScrollTrigger.tsx": 4,
"components/Journey/JourneyDetailPageVerdictSection.tsx": 51,
"components/Journey/JourneyEntryCover.tsx": 15,
"components/Journey/JourneyEntryCover.tsx": 13,
"components/Journey/JourneyMap.tsx": 3,
"components/Journey/JourneyMapGL.tsx": 5,
"components/Journey/MobileEntryView.tsx": 94,
@@ -146,7 +146,7 @@
"mobile/components/MStorageTestResult.tsx": 3,
"mobile/components/MToastHost.tsx": 2,
"mobile/components/MUserPickerList.tsx": 1,
"mobile/screens/admin/MAdmin.tsx": 9,
"mobile/screens/admin/MAdmin.tsx": 8,
"mobile/screens/admin/MAdminAddonManager.tsx": 9,
"mobile/screens/admin/MAdminAuditLogPanel.tsx": 15,
"mobile/screens/admin/MAdminBackupPanel.tsx": 8,
@@ -181,9 +181,9 @@
"mobile/screens/collections/MCollShareSheet.tsx": 14,
"mobile/screens/collections/MCollSheetKit.tsx": 5,
"mobile/screens/collections/MCollTripPickerSheet.tsx": 4,
"mobile/screens/collections/MCollections.tsx": 18,
"mobile/screens/collections/MCollections.tsx": 14,
"mobile/screens/dashboard/MDashWidgets.tsx": 22,
"mobile/screens/dashboard/MDashboard.tsx": 31,
"mobile/screens/dashboard/MDashboard.tsx": 30,
"mobile/screens/dashboard/MNewTripSheet.tsx": 12,
"mobile/screens/dashboard/MUserMenu.tsx": 7,
"mobile/screens/journey/MJourney.tsx": 10,
@@ -202,7 +202,7 @@
"mobile/screens/settings/MPhotoProvidersSection.tsx": 2,
"mobile/screens/settings/MScopeGroupPicker.tsx": 5,
"mobile/screens/settings/MSetPickerSheet.tsx": 1,
"mobile/screens/settings/MSettings.tsx": 2,
"mobile/screens/settings/MSettings.tsx": 1,
"mobile/screens/settings/MSettingsAbout.tsx": 5,
"mobile/screens/settings/MSettingsAccount.tsx": 20,
"mobile/screens/settings/MSettingsAppearance.tsx": 12,
@@ -214,7 +214,7 @@
"mobile/screens/settings/MSettingsUi.tsx": 11,
"mobile/screens/settings/MWebPushCard.tsx": 1,
"mobile/screens/trip/MTripLoadingSplash.tsx": 2,
"mobile/screens/trip/MTripShell.tsx": 9,
"mobile/screens/trip/MTripShell.tsx": 7,
"mobile/screens/trip/places/MPlacesBrowser.tsx": 10,
"mobile/screens/trip/places/MPlacesBulkCategorySheet.tsx": 2,
"mobile/screens/trip/places/MPlacesFilterControls.tsx": 1,
@@ -262,7 +262,7 @@
"mobile/screens/trip/tabs/MCollabNotes.tsx": 19,
"mobile/screens/trip/tabs/MCollabPolls.tsx": 18,
"mobile/screens/trip/tabs/MCollabTab.tsx": 1,
"mobile/screens/trip/tabs/MCostsTab.tsx": 71,
"mobile/screens/trip/tabs/MCostsTab.tsx": 70,
"mobile/screens/trip/tabs/MDocSyncSheet.tsx": 43,
"mobile/screens/trip/tabs/MFileLightbox.tsx": 12,
"mobile/screens/trip/tabs/MFileLinkSheet.tsx": 2,
@@ -1,8 +1,8 @@
// FE-COLLAB-CHATDATA-001 to FE-COLLAB-CHATDATA-016: the trip chat logic behind the desktop
// FE-COLLAB-CHATDATA-001 to FE-COLLAB-CHATDATA-018: the trip chat logic behind the desktop
// Collab panel (quiet older-page errors, scroll on remote delete, upload progress) and the
// phone's chat tab (toasted older-page errors, sending gated on the edit permission).
import { act, renderHook, waitFor } from '@testing-library/react';
import type { ChangeEvent } from 'react';
import { act, fireEvent, render, renderHook, screen, waitFor } from '@testing-library/react';
import { createElement, type ChangeEvent } from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { collabApi } from '../../api/client';
@@ -285,3 +285,80 @@ describe('useCollabChatData', () => {
expect(toast.error).toHaveBeenCalledWith('common.error');
});
});
describe('useCollabChatData on a box that changes size', () => {
// The observer reports when the test says the list's box has changed.
let report: () => void = () => {};
let observed: Element[] = [];
class Observer {
constructor(callback: ResizeObserverCallback) {
report = () => callback([], this as unknown as ResizeObserver);
}
observe(el: Element) {
observed.push(el);
}
unobserve() {}
disconnect() {}
}
/**
* The list as both views draw it: only once the messages are in, so at mount there
* is no element yet. Its sizes are the test's to set, since jsdom lays nothing out.
*/
function ChatList({ scrollHeight }: { scrollHeight: () => number }) {
const chat = useCollabChatData({
tripId: 1,
t,
toast: { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() },
});
const setList = (el: HTMLDivElement | null) => {
if (el && !Object.getOwnPropertyDescriptor(el, 'scrollHeight')) {
let scrollTop = 0;
Object.defineProperty(el, 'scrollHeight', { configurable: true, get: scrollHeight });
Object.defineProperty(el, 'clientHeight', { configurable: true, get: () => 400 });
Object.defineProperty(el, 'scrollTop', {
configurable: true,
get: () => scrollTop,
set: (v: number) => {
scrollTop = v;
},
});
el.scrollTo = () => {};
}
(chat.scrollRef as { current: HTMLDivElement | null }).current = el;
};
if (chat.loading || chat.messages.length === 0) return createElement('p', null, 'loading');
return createElement('div', { 'data-testid': 'list', ref: setList, onScroll: chat.checkAtBottom });
}
async function mountList(height: number) {
let scrollHeight = height;
render(createElement(ChatList, { scrollHeight: () => scrollHeight }));
const list = await screen.findByTestId('list');
return { list, grow: (to: number) => (scrollHeight = to) };
}
beforeEach(() => {
observed = [];
});
afterEach(() => vi.unstubAllGlobals());
it('FE-COLLAB-CHATDATA-017: a list at its newest message stays there when a rotation or a fold resizes it', async () => {
vi.stubGlobal('ResizeObserver', Observer);
const { list, grow } = await mountList(400);
expect(observed).toContain(list);
grow(900);
report();
expect(list.scrollTop).toBe(900);
});
it('FE-COLLAB-CHATDATA-018: a list read further up keeps its place', async () => {
vi.stubGlobal('ResizeObserver', Observer);
const { list, grow } = await mountList(2000);
list.scrollTop = 200;
fireEvent.scroll(list);
grow(2600);
report();
expect(list.scrollTop).toBe(200);
});
});
@@ -77,6 +77,22 @@ export function useCollabChatData({
isAtBottom.current = el.scrollHeight - el.scrollTop - el.clientHeight < 48;
}, []);
// A list that sat at its newest message stays there when its box changes size (a
// rotation, a fold, a pane opening beside it): the messages wrap anew and the list
// grows, which left the newest ones below the composer. Both views draw the list
// only once there are messages to show, so the observer attaches when it appears,
// not at mount, when there is nothing yet to observe.
const listShown = !loading && messages.length > 0;
useEffect(() => {
const el = scrollRef.current;
if (!listShown || !el || typeof ResizeObserver === 'undefined') return;
const observer = new ResizeObserver(() => {
if (isAtBottom.current) el.scrollTop = el.scrollHeight;
});
observer.observe(el);
return () => observer.disconnect();
}, [listShown]);
useEffect(() => {
let cancelled = false;
setLoading(true);
@@ -77,8 +77,9 @@ describe('JournalBody', () => {
const { container } = render(<JournalBody text={'# Big\n\n### Small'} />);
expect(container.querySelector('h1')).not.toBeInTheDocument();
expect(container.querySelector('h3')).not.toBeInTheDocument();
expect(screen.getByText('Big').tagName).toBe('P');
expect(screen.getByText('Small').tagName).toBe('P');
// Each paragraph's text sits in an isolate (<bdi>) so it keeps its own direction.
expect(screen.getByText('Big').parentElement?.tagName).toBe('P');
expect(screen.getByText('Small').parentElement?.tagName).toBe('P');
});
it('FE-COMP-JOURNALBODY-012: keeps a setext underline as text instead of a heading', () => {
@@ -15,13 +15,14 @@ export default function JournalBody({ text, dark }: Props) {
lineHeight: 1.6,
color: 'inherit',
}}>
{/* Each paragraph in its own direction (bdi): a Latin story in the Arabic UI keeps its full stop at its end. */}
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkBreaks]}
components={{
h1: ({ children }) => <p style={{ margin: '0 0 6px' }}>{children}</p>,
h2: ({ children }) => <p style={{ margin: '0 0 6px' }}>{children}</p>,
h3: ({ children }) => <p style={{ margin: '0 0 6px' }}>{children}</p>,
p: ({ children }) => <p style={{ margin: '0 0 6px' }}>{children}</p>,
h1: ({ children }) => <p style={{ margin: '0 0 6px' }}><bdi>{children}</bdi></p>,
h2: ({ children }) => <p style={{ margin: '0 0 6px' }}><bdi>{children}</bdi></p>,
h3: ({ children }) => <p style={{ margin: '0 0 6px' }}><bdi>{children}</bdi></p>,
p: ({ children }) => <p style={{ margin: '0 0 6px' }}><bdi>{children}</bdi></p>,
blockquote: ({ children }) => (
<blockquote style={{
borderInlineStart: `3px solid var(--journal-accent)`,
@@ -1,4 +1,4 @@
// FE-JRN-COVER-001 to FE-JRN-COVER-010
// FE-JRN-COVER-001 to FE-JRN-COVER-011
import { describe, it, expect, vi } from 'vitest'
import userEvent from '@testing-library/user-event'
@@ -133,4 +133,25 @@ describe('JourneyEntryCover', () => {
expect(container.querySelector('img')).toHaveAttribute('src', '/api/photos/7/thumbnail')
expect(container.querySelector('svg.lucide-play')).toBeNull()
})
it('FE-JRN-COVER-011: only the phone draws a card without a photo in the deep day colour, opaque even as a suggestion', () => {
const entry = buildEntry({ photos: [], type: 'skeleton' })
const phone = render(<JourneyEntryCover entry={entry} dayColor="#6366f1" isActive={false} onClick={() => {}} tone="mobile" />)
const phoneCard = phone.getByRole('button')
// d9, the deep wash (jsdom spells the colour out as rgba)
expect(phoneCard.style.backgroundImage).toMatch(/rgba\(99, 102, 241, 0\.85/)
expect(phoneCard.className).not.toContain('opacity-90')
expect(phone.container.querySelector('svg.lucide-map-pin')?.getAttribute('class')).toContain('-mt-6')
phone.unmount()
// The desktop and shared timelines keep their faint wash and the day-coloured pin.
const desk = render(<JourneyEntryCover entry={entry} dayColor="#6366f1" isActive={false} onClick={() => {}} />)
const deskCard = desk.getByRole('button')
// 2e, the faint one
expect(deskCard.style.backgroundImage).toMatch(/rgba\(99, 102, 241, 0\.18/)
expect(deskCard.className).toContain('opacity-90')
const pin = desk.container.querySelector('svg.lucide-map-pin') as SVGElement
expect(pin.getAttribute('class')).not.toContain('-mt-6')
expect(pin.style.color).not.toBe('')
})
})
@@ -62,6 +62,12 @@ interface Props {
/** The public journey page serves photos through a share token. */
photoUrlFor?: (photoId: number) => string
tone?: 'app' | 'mobile'
/**
* large: the card a wide window shows (an iPad, a foldable unfolded), with
* the room for a two-line name and the whole place under it. compact: a phone
* on its side, where a regular card left the map a strip 30px tall.
*/
size?: 'compact' | 'regular' | 'large'
}
export default function JourneyEntryCover({
@@ -73,7 +79,10 @@ export default function JourneyEntryCover({
showWeather = true,
photoUrlFor,
tone = 'app',
size = 'regular',
}: Props) {
const large = size === 'large'
const box = large ? 'h-[196px] w-[176px]' : size === 'compact' ? 'h-[112px] w-[124px]' : 'h-[152px] w-[136px]'
const { t, locale } = useTranslation()
const isSuggestion = entry.type === 'skeleton'
@@ -90,7 +99,12 @@ export default function JourneyEntryCover({
const mood = showMood ? moodMeta(entry.mood) : undefined
const weather = showWeather ? weatherMeta(entry.weather) : undefined
const emptyGround = tone === 'mobile' ? 'bg-[color:var(--m-sheetop)]' : 'bg-white dark:bg-zinc-800'
const mobile = tone === 'mobile'
const emptyGround = mobile ? 'bg-[color:var(--m-sheetop)]' : 'bg-white dark:bg-zinc-800'
// The mark of a card without a photo: white and raised clear of the name on the
// phone's deep day colour, the day's own colour on the faint wash elsewhere.
const emptyIcon = mobile ? '-mt-6 opacity-80' : 'opacity-60'
const emptyIconStyle = mobile ? undefined : { color: dayColor }
// One ground for every corner mark. Dark rather than a white veil: these sit on
// whatever photograph the traveller took, and a bright sky swallowed the white one.
const badge = 'rounded-full bg-black/45 px-[7px] py-[2px] text-[10px] font-bold whitespace-nowrap text-white backdrop-blur-[2px]'
@@ -108,9 +122,11 @@ export default function JourneyEntryCover({
// growing its box moved every card after it once a swipe had landed, and the
// snap then pulled the strip again, which read as a jolt (carousels keep the
// room for it with their gap and top padding).
className={`relative h-[152px] w-[136px] flex-none origin-bottom overflow-hidden rounded-[18px] text-start transition-[transform,box-shadow] duration-200 ease-[cubic-bezier(.2,.8,.2,1)] motion-reduce:transition-none ${
// White on the root: every mark and line on the card is white, over a photo or
// over the day's colour alike.
className={`relative text-white ${box} flex-none origin-bottom overflow-hidden rounded-[18px] text-start transition-[transform,box-shadow] duration-200 ease-[cubic-bezier(.2,.8,.2,1)] motion-reduce:transition-none ${
isActive ? 'z-[1] scale-[1.18] shadow-[0_18px_40px_-16px_rgba(0,0,0,.55)]' : 'scale-100 shadow-[0_10px_24px_-14px_rgba(0,0,0,.5)]'
} ${src ? '' : emptyGround} ${isSuggestion ? 'opacity-90' : ''}`}
} ${src ? '' : emptyGround} ${isSuggestion && !mobile ? 'opacity-90' : ''}`}
// backgroundImage, not background: the shorthand would drop the opaque colour
// the class supplies and leave the map showing through a card that is only a
// faint wash of the day's colour.
@@ -122,7 +138,14 @@ export default function JourneyEntryCover({
// ring lost its top edge to that clip.
style={{
boxShadow: `inset 0 0 0 ${isActive ? 2.5 : 1.5}px ${dayColor}${isActive ? '' : 'b3'}`,
...(src ? null : { backgroundImage: `linear-gradient(160deg, ${dayColor}2e, ${dayColor}0d)` }),
// Without a photograph the phone's card is the day's colour, deep enough to
// carry the white name like a photo does, rather than a grey that read as
// empty. Its ground stays opaque (no dimming for a suggestion either): it
// floats over the map, whose labels read through anything less. The desktop
// and shared timelines keep their faint wash of the day.
...(src ? null : { backgroundImage: mobile
? `linear-gradient(160deg, ${dayColor}d9, ${dayColor}8c 55%, ${dayColor}59)`
: `linear-gradient(160deg, ${dayColor}2e, ${dayColor}0d)` }),
}}
>
{src ? (
@@ -130,8 +153,8 @@ export default function JourneyEntryCover({
) : (
<span className="absolute inset-0 flex items-center justify-center">
{posterless
? <Play size={24} strokeWidth={1.8} fill="currentColor" style={{ color: dayColor }} className="ml-0.5 opacity-60" />
: <MapPin size={24} strokeWidth={1.8} style={{ color: dayColor }} className="opacity-60" />}
? <Play size={24} strokeWidth={1.8} fill="currentColor" style={emptyIconStyle} className={`ml-0.5 ${emptyIcon}`} />
: <MapPin size={24} strokeWidth={1.8} style={emptyIconStyle} className={emptyIcon} />}
</span>
)}
@@ -155,7 +178,7 @@ export default function JourneyEntryCover({
italic, dimmed title carries the rest. */}
{isSuggestion && (
<span
className={`absolute top-2 ${entry.country_code ? 'start-[30px]' : 'start-2'} ${badgeDot} text-white`}
className={`absolute top-2 ${entry.country_code ? 'start-[30px]' : 'start-2'} ${badgeDot}`}
title={t('journey.entry.suggestion')}
aria-label={t('journey.entry.suggestion')}
>
@@ -172,7 +195,7 @@ export default function JourneyEntryCover({
</span>
)}
{weather && (
<span className={`${badgeDot} text-white`}>
<span className={`${badgeDot}`}>
<weather.icon size={11} strokeWidth={2.4} />
</span>
)}
@@ -181,11 +204,11 @@ export default function JourneyEntryCover({
{/* Bottom: the name gets the room the thumbnail layout never had. */}
<span className="absolute inset-x-0 bottom-0 flex flex-col gap-[1px] px-[10px] pb-[10px]">
<span className={`line-clamp-2 text-[13px] leading-[1.25] font-bold text-white drop-shadow-[0_1px_3px_rgba(0,0,0,.6)] ${isSuggestion ? 'italic' : ''}`}>
<span className={`line-clamp-2 text-[13px] leading-[1.25] font-bold drop-shadow-[0_1px_3px_rgba(0,0,0,.6)] ${isSuggestion ? 'italic' : ''}`}>
{title}
</span>
{place && (
<span className="truncate text-[10.5px] leading-[1.3] font-medium text-white/80 drop-shadow-[0_1px_3px_rgba(0,0,0,.6)]">
<span className={`${large ? 'line-clamp-2' : 'truncate'} text-[10.5px] leading-[1.3] font-medium text-white/80 drop-shadow-[0_1px_3px_rgba(0,0,0,.6)]`}>
{place}
</span>
)}
@@ -1,4 +1,4 @@
// FE-COMP-JOURNEYMAP-001 to FE-COMP-JOURNEYMAP-062
// FE-COMP-JOURNEYMAP-001 to FE-COMP-JOURNEYMAP-063
vi.mock('../../api/websocket', () => ({
connect: vi.fn(),
@@ -592,6 +592,28 @@ describe('JourneyMap', () => {
}
});
it('FE-COMP-JOURNEYMAP-063: paddingStart keeps the journey clear of a rail on the leading side, either way round', () => {
const origRAF = globalThis.requestAnimationFrame;
globalThis.requestAnimationFrame = (cb: FrameRequestCallback) => { cb(0); return 0; };
try {
const { unmount } = render(<JourneyMap checkins={[]} entries={entriesWithCoords} paddingBottom={200} paddingStart={100} />);
expect(mockedMap().fitBounds).toHaveBeenLastCalledWith(
expect.anything(),
expect.objectContaining({ paddingTopLeft: [150, 50], paddingBottomRight: [50, 200] }),
);
unmount();
document.documentElement.dir = 'rtl';
render(<JourneyMap checkins={[]} entries={entriesWithCoords} paddingBottom={200} paddingStart={100} />);
expect(mockedMap().fitBounds).toHaveBeenLastCalledWith(
expect.anything(),
expect.objectContaining({ paddingTopLeft: [50, 50], paddingBottomRight: [150, 200] }),
);
} finally {
document.documentElement.dir = '';
globalThis.requestAnimationFrame = origRAF;
}
});
it('FE-COMP-JOURNEYMAP-026: dark mode picks the dark basemap', async () => {
render(<JourneyMap checkins={[]} entries={entriesWithCoords} dark />);
// The default basemap is a vector style now, so it arrives as a style option
+22 -6
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef, useImperativeHandle, useCallback, type CSSProperties, type Ref, type RefObject } from 'react'
import { useEffect, useLayoutEffect, useRef, useImperativeHandle, useCallback, type CSSProperties, type Ref, type RefObject } from 'react'
import { clusterPhotos, photoMarkerHtml, type MapPhoto } from './journeyPhotoLayer'
import L from 'leaflet'
import { useSettingsStore } from '../../store/settingsStore'
@@ -76,6 +76,12 @@ interface Props {
*/
onMarkerCardClick?: (entryId: string) => void
paddingBottom?: number
/**
* How much of the map's leading edge a bar laid over it covers (the touch
* shell's rail), in px: framing keeps the journey clear of it. Read when the
* map frames, so a change alone does not rebuild the map.
*/
paddingStart?: number
/** CARTO key from the share payload: the public journey has no settings store to read. */
cartoApiKey?: string
/**
@@ -281,13 +287,23 @@ function markCoords(marks: JourneyMapMark[] | undefined): L.LatLngTuple[] {
return (marks ?? []).map(mark => [mark.lat, mark.lng] as L.LatLngTuple)
}
function fitOptions(paddingBottom: number | undefined): L.FitBoundsOptions {
return { paddingTopLeft: [50, 50], paddingBottomRight: [50, paddingBottom || 50], maxZoom: 16 }
function fitOptions(paddingBottom: number | undefined, paddingStart = 0): L.FitBoundsOptions {
const rtl = typeof document !== 'undefined' && document.documentElement.dir === 'rtl'
return {
paddingTopLeft: [50 + (rtl ? 0 : paddingStart), 50],
paddingBottomRight: [50 + (rtl ? paddingStart : 0), paddingBottom || 50],
maxZoom: 16,
}
}
function JourneyMap(
{ entries, photos, onPhotoClick, trail, tracks, height = 220, dark, activeMarkerId, onMarkerClick, fullScreen, paddingBottom, cartoApiKey, onMarkerCardClick, layerVisibility = EVERY_LAYER, marks, onMarksPick, plannedEntryIds = NO_PLANNED, ref }: Props,
{ entries, photos, onPhotoClick, trail, tracks, height = 220, dark, activeMarkerId, onMarkerClick, fullScreen, paddingBottom, paddingStart = 0, cartoApiKey, onMarkerCardClick, layerVisibility = EVERY_LAYER, marks, onMarksPick, plannedEntryIds = NO_PLANNED, ref }: Props,
) {
// The leading inset is read when the map frames, not a reason to rebuild it.
const paddingStartRef = useRef(paddingStart)
useLayoutEffect(() => {
paddingStartRef.current = paddingStart
})
// Read through a ref: the handler is fixed per surface, and putting it in the
// marker effect's deps would rebuild every marker for nothing.
const onMarkerCardClickRef = useRef(onMarkerCardClick)
@@ -536,7 +552,7 @@ function JourneyMap(
const pointCoords = [...allCoords, ...markCoords(marksRef.current)]
const fitCoords = pointCoords.length > 0 ? pointCoords : trackCoords
marksFramedRef.current = (marksRef.current?.length ?? 0) > 0
if (fitCoords.length > 0) map.fitBounds(L.latLngBounds(fitCoords), fitOptions(paddingBottom))
if (fitCoords.length > 0) map.fitBounds(L.latLngBounds(fitCoords), fitOptions(paddingBottom, paddingStartRef.current))
else map.setView([30, 0], 2)
} catch {}
})
@@ -652,7 +668,7 @@ function JourneyMap(
const coords = markCoords(marks)
if (!marksFramedRef.current && coords.length > 0) {
marksFramedRef.current = true
if (!readerMovedRef.current) map.fitBounds(L.latLngBounds([...entryCoordsRef.current, ...coords]), fitOptions(paddingBottom))
if (!readerMovedRef.current) map.fitBounds(L.latLngBounds([...entryCoordsRef.current, ...coords]), fitOptions(paddingBottom, paddingStartRef.current))
}
draw()
@@ -39,6 +39,12 @@ interface Props {
onMarkerClick?: (id: string, type?: string) => void
fullScreen?: boolean
paddingBottom?: number
/**
* How much of the map's leading edge a bar laid over it covers (the touch
* shell's rail), in px: framing keeps the journey clear of it. Read when the
* map frames, so a change alone does not rebuild the map.
*/
paddingStart?: number
/** Open the entry a marker card names when the card is tapped, both renderers. */
onMarkerCardClick?: (entryId: string) => void
/** Open an entry's photos from the marker card's thumbnail strip. GL renderer only. */
@@ -317,6 +317,14 @@ describe('JourneyMapGL', () => {
expect(gl.map.addLayer).toHaveBeenCalledWith(expect.objectContaining({ id: 'journey-route-line' }))
})
it('FE-COMP-JMAPGL-049: paddingStart widens the fit on the side of the rail', () => {
withToken()
render(<JourneyMapGL checkins={[]} entries={entries} paddingBottom={200} paddingStart={100} fullScreen />)
expect(gl.map.fitBounds).toHaveBeenCalledWith(gl.boundsList[0], expect.objectContaining({
padding: { top: 50, bottom: 200, left: 150, right: 50 },
}))
})
it('FE-COMP-JMAPGL-010: the camera fits entries and trail with the requested bottom padding', () => {
withToken()
render(
+18 -6
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef, useImperativeHandle, useCallback, type Ref } from 'react'
import { useEffect, useLayoutEffect, useRef, useImperativeHandle, useCallback, type Ref } from 'react'
import type mapboxgl from 'mapbox-gl'
import { useSettingsStore } from '../../store/settingsStore'
import { isStandardFamily, supportsCustom3d, wantsTerrain, addCustom3dBuildings, addTerrainAndSky } from '../Map/mapboxSetup'
@@ -57,6 +57,12 @@ interface Props {
photos?: MapPhoto[]
onPhotoClick?: (photoIds: string[]) => void
paddingBottom?: number
/**
* How much of the map's leading edge a bar laid over it covers (the touch
* shell's rail), in px: framing keeps the journey clear of it. Read when the
* map frames, so a change alone does not rebuild the map.
*/
paddingStart?: number
/** See the Leaflet twin (JRN-04): hidden layers go off the map in place. */
layerVisibility?: JourneyMapLayerVisibility
marks?: JourneyMapMark[]
@@ -185,9 +191,10 @@ function markBounds(gl: Props['gl'], marks: JourneyMapMark[], points: [number, n
return bounds
}
function fitOptions(paddingBottom: number | undefined, tilted: boolean | undefined) {
function fitOptions(paddingBottom: number | undefined, tilted: boolean | undefined, paddingStart = 0) {
const rtl = typeof document !== 'undefined' && document.documentElement.dir === 'rtl'
return {
padding: { top: 50, bottom: paddingBottom || 50, left: 50, right: 50 },
padding: { top: 50, bottom: paddingBottom || 50, left: 50 + (rtl ? 0 : paddingStart), right: 50 + (rtl ? paddingStart : 0) },
maxZoom: 16,
pitch: tilted ? 45 : 0,
duration: 0,
@@ -195,8 +202,13 @@ function fitOptions(paddingBottom: number | undefined, tilted: boolean | undefin
}
function JourneyMapGL(
{ entries, trail, tracks, height = 220, dark, activeMarkerId, onMarkerClick, fullScreen, paddingBottom, glProvider = 'mapbox-gl', gl, onMarkerCardClick, onMarkerPhotoClick, photos, onPhotoClick, layerVisibility = EVERY_LAYER, marks, onMarksPick, plannedEntryIds = NO_PLANNED, ref }: Props,
{ entries, trail, tracks, height = 220, dark, activeMarkerId, onMarkerClick, fullScreen, paddingBottom, paddingStart = 0, glProvider = 'mapbox-gl', gl, onMarkerCardClick, onMarkerPhotoClick, photos, onPhotoClick, layerVisibility = EVERY_LAYER, marks, onMarksPick, plannedEntryIds = NO_PLANNED, ref }: Props,
) {
// The leading inset is read when the map frames, not a reason to rebuild it.
const paddingStartRef = useRef(paddingStart)
useLayoutEffect(() => {
paddingStartRef.current = paddingStart
})
const onMarkerCardClickRef = useRef(onMarkerCardClick)
onMarkerCardClickRef.current = onMarkerCardClick
const onMarkerPhotoClickRef = useRef(onMarkerPhotoClick)
@@ -526,7 +538,7 @@ function JourneyMapGL(
known.forEach(mark => bounds.extend([mark.lng, mark.lat]))
if (hasPoints || known.length > 0) {
try {
map.fitBounds(bounds, fitOptions(paddingBottom, enableMapbox3d && fullScreen))
map.fitBounds(bounds, fitOptions(paddingBottom, enableMapbox3d && fullScreen, paddingStartRef.current))
} catch { /* empty bounds */ }
}
})
@@ -631,7 +643,7 @@ function JourneyMapGL(
marksFramedRef.current = true
if (!readerMovedRef.current) {
try {
map.fitBounds(markBounds(gl, marks, entryPointsRef.current), fitOptions(paddingBottom, enableMapbox3d && fullScreen))
map.fitBounds(markBounds(gl, marks, entryPointsRef.current), fitOptions(paddingBottom, enableMapbox3d && fullScreen, paddingStartRef.current))
} catch { /* empty bounds */ }
}
}
@@ -1,4 +1,4 @@
// FE-JRN-CAROUSEL-001 to FE-JRN-CAROUSEL-009: the card carousel over the journey map,
// FE-JRN-CAROUSEL-001 to FE-JRN-CAROUSEL-010: the card carousel over the journey map,
// behind both the phone journey screen and the narrow desktop and public timeline.
import { act, renderHook } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
@@ -184,4 +184,33 @@ describe('useJourneyCarouselSync', () => {
settleAt(0);
expect(hook.result.current.activeIndex).toBe(0);
});
it('FE-JRN-CAROUSEL-010: a strip that comes back (the feed on a wide window gave way to it) is tracked again', () => {
const carousel = document.createElement('div');
carousel.getBoundingClientRect = () => ({ left: 0 }) as DOMRect;
Object.defineProperty(carousel, 'clientWidth', { value: 500 });
let strip: HTMLDivElement | null = null;
const hook = renderHook(
(p: Options) => {
const sync = useJourneyCarouselSync(p);
(sync.carouselRef as { current: HTMLDivElement | null }).current = strip;
return sync;
},
{ initialProps: { entries: ENTRIES, mapEntries: [], onOpenEntry: vi.fn(), layoutKey: true } }
);
ENTRIES.forEach((_, i) => {
const node = document.createElement('div');
node.getBoundingClientRect = () => ({ left: i * 200, width: 100 }) as DOMRect;
hook.result.current.cardRefs.current.set(i, node);
});
// The strip mounts with the layout's turn, with the same entries as before.
strip = carousel;
hook.rerender({ entries: ENTRIES, mapEntries: [], onOpenEntry: vi.fn(), layoutKey: false });
carousel.dispatchEvent(new Event('scroll'));
act(() => {
vi.advanceTimersByTime(90);
});
expect(hook.result.current.activeIndex).toBe(1);
});
});
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from 'react';
import type { JourneyMapHandle } from './JourneyMap';
import { journeyDays } from './journeyCard';
@@ -17,6 +17,11 @@ export interface JourneyCarouselSyncOptions<E extends CarouselEntry> {
onOpenEntry: (entry: E) => void;
/** A tap on another card also moves the map to it (the phone screen). */
syncMapOnTap?: boolean;
/**
* Changes whenever the strip comes or goes (the phone screen shows a feed in its
* place on a wide window), so its scroll tracking attaches to the strip on show.
*/
layoutKey?: unknown;
}
/**
@@ -55,6 +60,56 @@ function nearestCardIndex(el: HTMLDivElement | null, cards: Map<number, HTMLDivE
*/
const SETTLE_MS = 90;
interface StripScrollOptions {
carouselRef: RefObject<HTMLDivElement | null>;
count: number;
nearestCard: () => number | null;
pickNearestCard: () => void;
onLive: (index: number | null) => void;
layoutKey: unknown;
}
/**
* Follows the strip's scrolling: the card under the centre while it moves, the
* active card once it settles. Every state update waits for the strip to come
* to rest, because updating the active card mid swipe resizes the cards and
* reflows the layout on every frame. Attaches again whenever `layoutKey`
* changes, since the strip may only now be on screen.
*/
function useStripScroll({ carouselRef, count, nearestCard, pickNearestCard, onLive, layoutKey }: StripScrollOptions) {
useEffect(() => {
const el = carouselRef.current;
if (!el || count === 0) return;
let settleTimer: number | null = null;
let frame: number | null = null;
const onScroll = () => {
if (frame == null) {
frame = window.requestAnimationFrame(() => {
frame = null;
onLive(nearestCard());
});
}
if (settleTimer != null) window.clearTimeout(settleTimer);
settleTimer = window.setTimeout(pickNearestCard, SETTLE_MS);
};
// Where the browser says when the snap has landed, the card grows right then
// instead of a beat later; the timer stays for the ones that do not.
const onScrollEnd = () => {
if (settleTimer != null) window.clearTimeout(settleTimer);
settleTimer = null;
pickNearestCard();
};
el.addEventListener('scroll', onScroll, { passive: true });
el.addEventListener('scrollend', onScrollEnd, { passive: true });
return () => {
el.removeEventListener('scroll', onScroll);
el.removeEventListener('scrollend', onScrollEnd);
if (settleTimer != null) window.clearTimeout(settleTimer);
if (frame != null) window.cancelAnimationFrame(frame);
};
}, [carouselRef, count, pickNearestCard, nearestCard, onLive, layoutKey]);
}
/**
* The card carousel over the journey map, behind both the phone journey screen and the
* narrow desktop and public timeline: the active card is the one nearest the centre
@@ -66,6 +121,7 @@ export function useJourneyCarouselSync<E extends CarouselEntry>({
mapEntries,
onOpenEntry,
syncMapOnTap = false,
layoutKey,
}: JourneyCarouselSyncOptions<E>) {
const mapRef = useRef<JourneyMapHandle>(null);
const carouselRef = useRef<HTMLDivElement>(null);
@@ -106,39 +162,7 @@ export function useJourneyCarouselSync<E extends CarouselEntry>({
});
}, [nearestCard, syncMapToCard]);
// Defer every state update until scrolling settles: updating the active card
// mid swipe resizes the cards and reflows the layout on every frame.
useEffect(() => {
const el = carouselRef.current;
if (!el || entries.length === 0) return;
let settleTimer: number | null = null;
let frame: number | null = null;
const onScroll = () => {
if (frame == null) {
frame = window.requestAnimationFrame(() => {
frame = null;
setLiveIndex(nearestCard());
});
}
if (settleTimer != null) window.clearTimeout(settleTimer);
settleTimer = window.setTimeout(pickNearestCard, SETTLE_MS);
};
// Where the browser says when the snap has landed, the card grows right then
// instead of a beat later; the timer stays for the ones that do not.
const onScrollEnd = () => {
if (settleTimer != null) window.clearTimeout(settleTimer);
settleTimer = null;
pickNearestCard();
};
el.addEventListener('scroll', onScroll, { passive: true });
el.addEventListener('scrollend', onScrollEnd, { passive: true });
return () => {
el.removeEventListener('scroll', onScroll);
el.removeEventListener('scrollend', onScrollEnd);
if (settleTimer != null) window.clearTimeout(settleTimer);
if (frame != null) window.cancelAnimationFrame(frame);
};
}, [entries.length, pickNearestCard, nearestCard]);
useStripScroll({ carouselRef, count: entries.length, nearestCard, pickNearestCard, onLive: setLiveIndex, layoutKey });
const scrollCardIntoCenter = useCallback((idx: number) => {
cardRefs.current.get(idx)?.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' });
+1 -1
View File
@@ -123,7 +123,7 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onSettings
paddingTop: 'env(safe-area-inset-top, 0px)',
height: 'var(--nav-h)',
transition: 'background 240ms cubic-bezier(0.23,1,0.32,1), backdrop-filter 240ms cubic-bezier(0.23,1,0.32,1), box-shadow 240ms cubic-bezier(0.23,1,0.32,1)',
}} className="hidden md:flex items-center px-4 gap-4 fixed top-0 inset-x-0 z-[200]">
}} data-desktop-navbar="" className="hidden md:flex items-center px-4 gap-4 fixed top-0 inset-x-0 z-[200]">
{/* Left side. flex-1 basis-0, matching the action cluster on the right, so
the tab pill between them sits in the middle of the bar rather than
being centred on top of both (#1983). Both columns keep at least their
+16 -11
View File
@@ -31,18 +31,10 @@ export function useCreateAction(phone: boolean): CreateAction {
const inCollection = useMatch('/collections/:id');
if (inTrip) {
// The "+" is context-aware per active tab: Bookings → reservation,
// Transports → transport, Costs → expense. Tabs without a create modal
// (lists / files / collab) fall through to adding a place. #1349
const id = inTrip.params.id;
const id = inTrip.params.id ?? '';
const tripTab = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem(`trip-tab-${id}`) : null;
if (tripTab === 'finanzplan')
return { label: t('costs.addExpense'), run: () => navigate(`/trips/${id}?create=expense`) };
if (tripTab === 'buchungen')
return { label: t('reservations.addManual'), run: () => navigate(`/trips/${id}?create=reservation`) };
if (tripTab === 'transports')
return { label: t('transport.addManual'), run: () => navigate(`/trips/${id}?create=transport`) };
return { label: t('places.addPlace'), run: () => navigate(`/trips/${id}?create=place`) };
const intent = tripCreateIntent(id, tripTab);
return { label: t(intent.labelKey), run: () => navigate(intent.to) };
}
if (inJourney) {
// Context-aware per tab, like the trip's "+": the Gallery holds photos, so
@@ -69,6 +61,19 @@ export function useCreateAction(phone: boolean): CreateAction {
return { label: t('dashboard.newTrip'), run: () => navigate('/dashboard?create=1') };
}
/**
* What the "+" creates inside a trip, by its active tab: Bookings a reservation,
* Transports a transport, Costs an expense. Tabs without a create modal (lists,
* files, collab) fall through to adding a place (#1349). Pure, so a caller that
* knows the tab itself (the trip's own ⌘N, MTripShell) asks with that one.
*/
export function tripCreateIntent(tripId: string, tab: string | null): { labelKey: string; to: string } {
if (tab === 'finanzplan') return { labelKey: 'costs.addExpense', to: `/trips/${tripId}?create=expense` };
if (tab === 'buchungen') return { labelKey: 'reservations.addManual', to: `/trips/${tripId}?create=reservation` };
if (tab === 'transports') return { labelKey: 'transport.addManual', to: `/trips/${tripId}?create=transport` };
return { labelKey: 'places.addPlace', to: `/trips/${tripId}?create=place` };
}
/** Whether a nav item is the current route: the dashboard only on its exact path, the rest by prefix. */
export function isNavItemActive(pathname: string, to: string): boolean {
return to === '/dashboard' ? pathname === '/dashboard' : pathname.startsWith(to);
@@ -0,0 +1,110 @@
import { render } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
// FE-MAP-RESIZE-001 onwards
const map = vi.hoisted(() => ({
container: {} as HTMLElement,
invalidateSize: vi.fn(),
getContainer: vi.fn(),
getCenter: vi.fn(() => ({ lat: 35, lng: 135.7 })),
getZoom: vi.fn(() => 12),
getSize: vi.fn(() => ({ x: 1376, y: 1032 })),
setView: vi.fn(),
whenReady: vi.fn((fn: () => void) => fn()),
on: vi.fn(),
off: vi.fn(),
}));
vi.mock('react-leaflet', () => ({ useMap: () => map }));
vi.mock('./lngFrameContext', () => ({ useLngFrame: () => null }));
import { computeMapViewport, TILE_SIZE_RASTER } from '../../utils/mapViewport';
import ContainerResizeWatcher, { MapCameraSnapshot, OpeningFrameFit } from './ContainerResizeWatcher';
import type { OpeningFrame } from './openingView';
const realObserver = globalThis.ResizeObserver;
describe('ContainerResizeWatcher', () => {
afterEach(() => {
globalThis.ResizeObserver = realObserver;
vi.clearAllMocks();
});
it('FE-MAP-RESIZE-001: the map measures itself again whenever its box changes size', () => {
const element = document.createElement('div');
map.getContainer.mockReturnValue(element);
let fire: () => void = () => {};
const observe = vi.fn();
const disconnect = vi.fn();
globalThis.ResizeObserver = class {
constructor(cb: () => void) {
fire = cb;
}
observe = observe;
unobserve = vi.fn();
disconnect = disconnect;
} as unknown as typeof ResizeObserver;
const { unmount } = render(<ContainerResizeWatcher />);
expect(observe).toHaveBeenCalledWith(element);
fire();
expect(map.invalidateSize).toHaveBeenCalledWith();
unmount();
expect(disconnect).toHaveBeenCalled();
});
it('FE-MAP-RESIZE-002: the camera snapshot reports where the map looks and stops on unmount', () => {
const onChange = vi.fn();
const { unmount } = render(<MapCameraSnapshot onChange={onChange} />);
expect(onChange).toHaveBeenCalledWith([35, 135.7], 12);
expect(map.on).toHaveBeenCalledWith('moveend zoomend', expect.any(Function));
unmount();
expect(map.off).toHaveBeenCalledWith('moveend zoomend', expect.any(Function));
});
});
describe('OpeningFrameFit', () => {
// A day across Kyoto from Arashiyama in the west to Ginkaku-ji in the east, wider than tall.
const points = [
{ lat: 35.0094, lng: 135.6668 },
{ lat: 35.005, lng: 135.7649 },
{ lat: 35.0037, lng: 135.7788 },
{ lat: 35.027, lng: 135.7982 },
];
const padding = { top: 20, right: 40, bottom: 20, left: 40 };
const windowFrame = (): OpeningFrame => {
const view = computeMapViewport(points, { tileSize: TILE_SIZE_RASTER, padding, width: 1376, height: 1032 });
if (!view) throw new Error('the day has places to frame');
return { points, padding, wrapsWorld: false, center: view.center, zoom: view.zoom, width: 1376, height: 1032 };
};
afterEach(() => vi.clearAllMocks());
it('FE-MAP-RESIZE-004: a map beside the plan, narrower than the window, frames the day again for its own size', () => {
const frame = windowFrame();
map.getSize.mockReturnValue({ x: 824, y: 1032 });
map.getZoom.mockReturnValue(frame.zoom);
map.getCenter.mockReturnValue({ lat: frame.center[0], lng: frame.center[1] });
render(<OpeningFrameFit frame={frame} />);
expect(map.setView).toHaveBeenCalledOnce();
const [, zoom, options] = map.setView.mock.calls[0];
// Further out than the window's frame, so the whole day fits the narrower map.
expect(zoom).toBeLessThan(frame.zoom);
expect(options).toEqual({ animate: false });
});
it('FE-MAP-RESIZE-005: a map the size of the window, or one that opened at a kept camera, stays where it is', () => {
const frame = windowFrame();
map.getSize.mockReturnValue({ x: 1376, y: 1000 });
map.getZoom.mockReturnValue(frame.zoom);
map.getCenter.mockReturnValue({ lat: frame.center[0], lng: frame.center[1] });
const { unmount } = render(<OpeningFrameFit frame={frame} />);
expect(map.setView).not.toHaveBeenCalled();
unmount();
map.getSize.mockReturnValue({ x: 824, y: 1032 });
map.getZoom.mockReturnValue(9);
render(<OpeningFrameFit frame={frame} />);
expect(map.setView).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,81 @@
import { useEffect, useRef } from 'react';
import { useMap } from 'react-leaflet';
import { frameLng } from '../../utils/lngFrame';
import { computeMapViewport, TILE_SIZE_RASTER } from '../../utils/mapViewport';
import { useLngFrame } from './lngFrameContext';
import type { OpeningFrame } from './openingView';
/**
* Leaflet measures its container on window resizes only. The touch shell on a
* wide window resizes the map without one: the trip's plan pane opens and
* closes beside it, and a foldable folds. Without a fresh measure the map kept
* its old size, with grey bands or tiles cut off at the old edge.
*/
export default function ContainerResizeWatcher() {
const map = useMap();
useEffect(() => {
if (typeof ResizeObserver === 'undefined') return;
// Leaflet's default keeps the centre where it was, so what the map framed stays
// in the middle of the room it now has.
const observer = new ResizeObserver(() => map.invalidateSize());
observer.observe(map.getContainer());
return () => observer.disconnect();
}, [map]);
return null;
}
/** Reports where the map looks after every move, so a remount can come back to it. */
export function MapCameraSnapshot({ onChange }: { onChange: (center: [number, number], zoom: number) => void }) {
const map = useMap();
useEffect(() => {
const save = () => {
const center = map.getCenter();
onChange([center.lat, center.lng], map.getZoom());
};
map.whenReady(save);
map.on('moveend zoomend', save);
return () => {
map.off('moveend zoomend', save);
};
}, [map, onChange]);
return null;
}
/**
* The map opens framed on the trip's places, a frame worked out before the
* map exists and so for the window's size. Where the map is much smaller
* than the window (the plan beside it on an iPad or an unfolded foldable),
* that frame is too close and the day runs off the map. Once the map knows
* its size, the frame is worked out again for it. A map that opened anywhere
* else (a camera kept from before a remount) is left where it is.
*/
export function OpeningFrameFit({ frame }: { frame: OpeningFrame }) {
const map = useMap();
const lngFrame = useLngFrame();
const done = useRef(false);
useEffect(() => {
if (done.current) return;
done.current = true;
const { x, y } = map.getSize();
if (x <= 0 || y <= 0) return;
if (Math.abs(x - frame.width) <= frame.width * 0.1 && Math.abs(y - frame.height) <= frame.height * 0.1) return;
const at = map.getCenter();
const opened =
Math.abs(map.getZoom() - frame.zoom) < 0.01 &&
Math.abs(at.lat - frame.center[0]) < 1e-6 &&
Math.abs(at.lng - frame.center[1]) < 1e-6;
if (!opened) return;
const view = computeMapViewport(frame.points, {
tileSize: TILE_SIZE_RASTER,
padding: frame.padding,
wrapsWorld: frame.wrapsWorld,
width: x,
height: y,
});
// A whole zoom level, as Leaflet snaps to them, and the one that fits rather than the nearest.
if (view)
map.setView([view.center[0], frameLng(lngFrame, view.center[1])], Math.floor(view.zoom), { animate: false });
}, [map, lngFrame, frame]);
return null;
}
+52 -1
View File
@@ -4,6 +4,7 @@ import { render, screen } from '../../../tests/helpers/render'
import { act, fireEvent, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { resetAllStores } from '../../../tests/helpers/store'
import { setSizeClass } from '../../../tests/helpers/sizeClass'
import { buildPlace, buildReservation } from '../../../tests/helpers/factories'
import { AMAP_ROAD, AMAP_SATELLITE, MAP_MAX_ZOOM, OPENTOPOMAP_TILE_URL } from '../../constants/mapDefaults'
import { useAuthStore } from '../../store/authStore'
@@ -16,6 +17,8 @@ import { renderIconMarkup } from '../../utils/iconMarkup'
const mapMock = vi.hoisted(() => ({
getContainer: vi.fn(() => document.createElement('div')),
getCenter: vi.fn(() => ({ lat: 0, lng: 0 })),
// The map fills the window here, so the opening frame stands (OpeningFrameFit).
getSize: vi.fn(() => ({ x: window.innerWidth, y: window.innerHeight })),
panTo: vi.fn(),
setView: vi.fn(),
fitBounds: vi.fn(),
@@ -204,6 +207,21 @@ vi.mock('../../services/photoService', () => ({
getAllThumbs: vi.fn(() => ({})),
}))
// The app shows the touch shell at every size; a test can say it does (undefined: as in a browser).
const shell = vi.hoisted(() => ({ phone: undefined as boolean | undefined }))
vi.mock('../../mobile/useIsPhone', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../mobile/useIsPhone')>()
// The real hook runs every time, so a test that changes the answer never changes the
// number of hooks a mounted map has (React throws on that).
return {
...actual,
useIsPhone: () => {
const real = actual.useIsPhone()
return shell.phone ?? real
},
}
})
vi.mock('./gcj02Crs', () => ({
crsForBasemap: (gcj02: boolean) => gcj02 ? { code: 'TREK:GCJ02' } : undefined,
}))
@@ -227,6 +245,7 @@ const ORIGINAL_WIDTH = window.innerWidth
afterEach(() => {
vi.clearAllMocks()
shell.phone = undefined
resetAllStores()
clusterMock.markers.clear()
clusterMock.asked.length = 0
@@ -1296,7 +1315,8 @@ describe('MapView selection panning (#921)', () => {
afterEach(() => {
delete projectable.project
delete projectable.unproject
delete projectable.getSize
// Back to the map that fills the window (the mock's own size).
projectable.getSize = vi.fn(() => ({ x: window.innerWidth, y: window.innerHeight }))
})
it('FE-COMP-MAPVIEW-090: pans the pin back out once the card measured taller than the allowance', () => {
@@ -1574,6 +1594,37 @@ describe('MapView bounds fitting', () => {
expect(mapMock.fitBounds.mock.calls[2][1]).toMatchObject({ paddingTopLeft: [40, 20], paddingBottomRight: [40, 20] })
})
it('FE-COMP-MAPVIEW-094: beside the pane the touch shell opens at the whole zoom level that fits; the desktop keeps its own opening', () => {
// A day spread so that its exact fit lies between two levels.
const places = [buildMapPlace({ id: 1, lat: 34.9671, lng: 135.7727 }), buildMapPlace({ id: 2, lat: 35.005, lng: 135.7649 })]
shell.phone = true
setSizeClass('wide')
const { unmount } = render(<MapView places={places} />)
const paned = Number(screen.getByTestId('map-container').getAttribute('data-zoom'))
expect(Number.isInteger(paned)).toBe(true)
unmount()
// The desktop planner hands Leaflet the fitted level, which it rounds to the nearest
// as it always has, and fits no second time.
shell.phone = false
setSizeClass('compact')
mapMock.setView.mockClear()
render(<MapView places={places} />)
const desk = Number(screen.getByTestId('map-container').getAttribute('data-zoom'))
expect(Number.isInteger(desk)).toBe(false)
expect(mapMock.setView).not.toHaveBeenCalled()
})
it('FE-COMP-MAPVIEW-093: on an iPad the touch shell frames a day clear of its floating toolbar and corner buttons', () => {
shell.phone = true
setSizeClass('wide')
const places = [buildMapPlace({ id: 1, lat: 35, lng: 135.7 }), buildMapPlace({ id: 2, lat: 35.04, lng: 135.78 })]
const { rerender } = render(<MapView places={places} fitKey={1} />)
rerender(<MapView places={places} fitKey={2} />)
const opts = mapMock.fitBounds.mock.calls[mapMock.fitBounds.mock.calls.length - 1]?.[1]
expect(opts).toMatchObject({ paddingTopLeft: [40, 84], paddingBottomRight: [40, 56] })
})
it('FE-COMP-MAPVIEW-079: the caller padding is read by value, so only new numbers refit the points on screen', () => {
const leg: [number, number][] = [[48.1, 2.1], [48.3, 2.4]]
const { rerender } = render(
+21 -33
View File
@@ -2,6 +2,8 @@ import { useEffect, useRef, useState, useMemo, useCallback, memo, type Component
import DOM from 'react-dom'
import { categoryIconSvg } from './categoryIconSvg'
import { MapContainer, TileLayer, Marker, Polyline, CircleMarker, Circle, useMap, Tooltip } from 'react-leaflet'
import ContainerResizeWatcher, { MapCameraSnapshot, OpeningFrameFit } from './ContainerResizeWatcher'
import { openingView } from './openingView'
import MarkerClusterGroup from 'react-leaflet-cluster'
import { makeMarkerDraggable } from './markerDrag'
import { CLUSTER_OPTIONS, createClusterIcon, revealInCluster, type ClusterGroupLike } from './markerCluster'
@@ -73,7 +75,7 @@ import { useSettingsStore } from '../../store/settingsStore'
import { TourMapLayersLeaflet } from './RasterOverlayLeaflet'
import { MapLayerSwitcher, TourMapLayerSwitcher, MAP_LAYER_SWITCHER_INSET, type TourBaseLayer } from './MapLayerSwitcher'
import { MapLockPill } from './MapLockPill'
import { computeMapViewport, TILE_SIZE_RASTER, type ViewportPadding } from '../../utils/mapViewport'
import type { ViewportPadding } from '../../utils/mapViewport'
import { parseRenderableRouteGeometry } from '../../utils/routeGeometry'
import type { MapViewProps } from './mapViewContract'
@@ -244,20 +246,6 @@ function CameraHoverGuard({ movingRef, onMoveStart, onZoom }: { movingRef: { cur
return null
}
function MapCameraSnapshot({ onChange }: { onChange: (center: [number, number], zoom: number) => void }) {
const map = useMap()
useEffect(() => {
const save = () => {
const center = map.getCenter()
onChange([center.lat, center.lng], map.getZoom())
}
map.whenReady(save)
map.on('moveend zoomend', save)
return () => { map.off('moveend zoomend', save) }
}, [map, onChange])
return null
}
interface MapControllerProps {
center: [number, number]
zoom: number
@@ -421,6 +409,7 @@ import { markerPhotoHtml, placePhotoFull, placePhotoUrl } from './placePhoto'
import { useGeolocation } from '../../hooks/useGeolocation'
import LocationButton from './LocationButton'
import { useIsPhone } from '../../mobile/useIsPhone'
import { useIsWide } from '../../hooks/sizeClass'
// Live-location rendering inside the Leaflet map. Subscribes via the
// shared useGeolocation hook so the Leaflet and Mapbox variants behave
@@ -683,40 +672,37 @@ export const MapView = memo(function MapView({
), [reservations, visibleConnectionIds, showTransitRoutes, selectedDayId, days, scopeConnectionsToDay])
// Real road geometry for car/bus/taxi/bicycle bookings (straight line until it loads/if it fails).
const transportRoutes = useTransportRoutes(visibleReservations)
// Dynamic padding: account for sidebars + bottom inspector + day detail panel
// The chrome overlaying the map (side panels, day detail). Kept as a plain box so both the
// Leaflet fit options and the opening-camera maths can read the same numbers.
// Followed rather than read once: a phone turned sideways and back crosses
// the breakpoint twice, and the padding here and the locate button further
// down used to stay with whatever the map saw when it mounted.
const isMobile = useIsPhone()
// The touch shell on an iPad or an unfolded foldable floats its toolbar over the
// top of the map and its buttons in the corners, and its pins stand above their
// spot: a day framed to the phone's thin margin ran under the toolbar.
const isWide = useIsWide()
const paddingBox = useMemo((): ViewportPadding => {
if (isMobile) return { top: 20, right: 40, bottom: 20, left: 40 }
if (isMobile) {
const margin = { top: 20, right: 40, bottom: 20, left: 40 }
return isWide ? { ...margin, top: 84, bottom: 56 } : margin
}
return {
top: 60,
right: rightWidth + 40,
bottom: bottomChromePadding({ hasInspector, inspectorHeight, hasDayDetail }),
left: leftWidth + 40,
}
}, [isMobile, leftWidth, rightWidth, hasInspector, inspectorHeight, hasDayDetail])
}, [isMobile, isWide, leftWidth, rightWidth, hasInspector, inspectorHeight, hasDayDetail])
const paddingOpts = useMemo(() => leafletPadding(paddingBox), [paddingBox])
// Open framed on the places rather than on the caller's default, so a trip in Japan shows
// Japan straight away instead of the world view followed by a flight across the planet.
// The initializer runs once, at mount — exactly when this should be decided; afterwards the
// camera belongs to the user. `framed` is false when no place has coordinates (a new trip),
// and then the caller's center/zoom stands.
const [initialView] = useState(() => {
const framed = computeMapViewport(dayPlaces.length > 0 ? dayPlaces : places, {
tileSize: TILE_SIZE_RASTER,
padding: paddingBox,
// With a longitude frame the trip lies on one copy, so it can be framed the short way.
wrapsWorld: lngFrame !== null,
})
const opening: [number, number] | undefined = framed ? [framed.center[0], frameLng(lngFrame, framed.center[1])] : undefined
return { center: opening ?? center, zoom: framed?.zoom ?? zoom, framed: framed !== null }
})
// Open framed on the places, not on the caller's default: a trip in Japan shows Japan at once instead
// of the world and a flight across it. Decided once, at mount; afterwards the camera is the user's.
// `framed` is false when no place has coordinates (a new trip), and then the caller's center/zoom stands.
const [initialView] = useState(() => openingView(dayPlaces.length > 0 ? dayPlaces : places, {
padding: paddingBox, lngFrame, center, zoom, paned: isMobile && isWide,
}))
const cameraSnapshotRef = useRef<{ center: [number, number]; zoom: number } | null>(null)
const saveCameraSnapshot = useCallback((cameraCenter: [number, number], cameraZoom: number) => {
cameraSnapshotRef.current = { center: cameraCenter, zoom: cameraZoom }
@@ -952,6 +938,8 @@ export const MapView = memo(function MapView({
{/* First, so a fit or a camera request in the same commit starts from the trip's world copy. */}
<LngFrameFollower />
<MapController center={center} zoom={zoom} />
<ContainerResizeWatcher />
{initialView.refit && <OpeningFrameFit frame={initialView.refit} />}
{hasViewBaseLayer && <MapCameraSnapshot onChange={saveCameraSnapshot} />}
<BoundsController places={dayPlaces.length > 0 ? dayPlaces : places} routeCoords={dayPlaces.length > 0 ? routeCoords : []} fitKey={fitKey} paddingOpts={paddingOpts} framedOnMount={initialView.framed} focusPoints={focusPoints} focusKey={focusKey} fitPadding={fitPadding} follow={followSelection} />
<SelectionController follow={followSelection} places={places} selectedPlaceId={selectedPlaceId} dayPlaces={dayPlaces} selectedPlace={selectedPlace} paddingBox={paddingBox} />
+53
View File
@@ -0,0 +1,53 @@
import { frameLng, type LngFrame } from '../../utils/lngFrame';
import { computeMapViewport, TILE_SIZE_RASTER, type GeoPointish, type ViewportPadding } from '../../utils/mapViewport';
/**
* Where MapView opens: framed on the places when they have coordinates, else
* at the caller's centre and zoom.
*
* In the touch shell on a wide window (`paned`), where the map is a part of
* the window beside the trip's pane: Leaflet snaps to whole zoom levels and
* rounds to the nearest, so 14.7 became 15 and the day ran off the map; the
* whole level below always fits. `refit` keeps what the frame was worked out
* for (the window), so OpeningFrameFit can work it out again for the map's
* own size once it has one. Everywhere else (the desktop planner, a phone) the
* map opens as it always has: Leaflet's nearest level, and no second fit.
*/
export function openingView(
points: readonly GeoPointish[],
options: {
padding: ViewportPadding;
lngFrame: LngFrame | null;
center: [number, number];
zoom: number;
paned?: boolean;
}
): { center: [number, number]; zoom: number; framed: boolean; refit: OpeningFrame | null } {
// With a longitude frame the trip lies on one copy, so it can be framed the short way.
const wrapsWorld = options.lngFrame !== null;
const fitted = computeMapViewport(points, { tileSize: TILE_SIZE_RASTER, padding: options.padding, wrapsWorld });
if (!fitted) return { center: options.center, zoom: options.zoom, framed: false, refit: null };
const center: [number, number] = [fitted.center[0], frameLng(options.lngFrame, fitted.center[1])];
if (!options.paned) return { center, zoom: fitted.zoom, framed: true, refit: null };
const zoom = Math.floor(fitted.zoom);
const width = typeof window !== 'undefined' && window.innerWidth > 0 ? window.innerWidth : 1024;
const height = typeof window !== 'undefined' && window.innerHeight > 0 ? window.innerHeight : 768;
return {
center,
zoom,
framed: true,
refit: { points, padding: options.padding, wrapsWorld, center, zoom, width, height },
};
}
/** The opening frame as MapView worked it out before the map existed: for the window. */
export interface OpeningFrame {
points: readonly GeoPointish[];
padding: ViewportPadding;
wrapsWorld: boolean;
center: [number, number];
zoom: number;
/** The size it was worked out for: the window's. */
width: number;
height: number;
}
@@ -3,7 +3,7 @@ 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'
import { KIND_LOOK, createdAt, kindOf } from './notificationModel'
import { KIND_LOOK, createdAt, kindOf, localizedParams } from './notificationModel'
interface NotificationItemProps {
notification: InAppNotification
@@ -55,7 +55,7 @@ export default function InAppNotificationItem({ notification, onClose }: Notific
useNotificationItemActions(notification, onClose)
const titleText = t(notification.title_key, notification.title_params)
const bodyText = t(notification.text_key, notification.text_params)
const bodyText = t(notification.text_key, localizedParams(notification.text_params, locale))
const unread = !notification.is_read
const answered = notification.response !== null
const { positive_text_key: yesKey, negative_text_key: noKey, navigate_text_key: goKey } = notification
@@ -1,5 +1,5 @@
/**
* FE-NOTIF-MODEL-001 to FE-NOTIF-MODEL-007
* FE-NOTIF-MODEL-001 to FE-NOTIF-MODEL-009
*
* 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.
@@ -9,7 +9,7 @@ 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';
import { KIND_LOOK, createdAt, dayLabel, groupByDay, kindOf, localizedParams } from './notificationModel';
const note = (id: number, created_at: string, title_key = 'notif.trip_invite.title'): InAppNotification => ({
id,
@@ -89,3 +89,21 @@ describe('NotificationDayList', () => {
expect(screen.getAllByRole('region')).toHaveLength(2);
});
});
describe('localizedParams', () => {
const NOW = new Date(2026, 10, 7, 12);
it('FE-NOTIF-MODEL-008: a due day sent as YYYY-MM-DD reads as the short date of the language', () => {
const params = { todo: 'Exchange the JR Pass', trip: 'Kyoto', due: '2026-11-05' };
expect(localizedParams(params, 'de', NOW)).toEqual({ todo: 'Exchange the JR Pass', trip: 'Kyoto', due: '5. Nov.' });
expect(localizedParams(params, 'en-US', NOW).due).toBe('Nov 5');
expect(localizedParams(params, 'ru', NOW).due).toBe('5 нояб.');
// Another year names it.
expect(localizedParams({ due: '2027-01-02' }, 'en-US', NOW).due).toBe('Jan 2, 2027');
});
it('FE-NOTIF-MODEL-009: anything that is not a bare date passes through, and no params stay none', () => {
expect(localizedParams({ trip: '2026-11-05 at noon', n: '3' }, 'de', NOW)).toEqual({ trip: '2026-11-05 at noon', n: '3' });
expect(localizedParams(undefined, 'de', NOW)).toEqual({});
});
});
@@ -80,6 +80,34 @@ export function createdAt(value: string): Date {
const dayStart = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
const BARE_DAY = /^(\d{4})-(\d{2})-(\d{2})$/;
/**
* The parameters of a notification's text as the reader's language writes
* them. A day the server sends (a todo's due date) arrives as YYYY-MM-DD, which
* would stand as "2026-11-05" in a German or Arabic sentence; it becomes the
* short date of the language ("5. Nov.", "٥ نوفمبر"), with the year only when
* it is not this one. Everything else passes through as it came.
*/
export function localizedParams(
params: Record<string, string> | null | undefined,
locale: string,
now: Date = new Date()
): Record<string, string> {
const out: Record<string, string> = { ...params };
for (const [key, value] of Object.entries(out)) {
const day = BARE_DAY.exec(value);
if (!day) continue;
const date = new Date(Number(day[1]), Number(day[2]) - 1, Number(day[3]));
out[key] = new Intl.DateTimeFormat(locale, {
day: 'numeric',
month: 'short',
...(date.getFullYear() === now.getFullYear() ? {} : { year: 'numeric' }),
}).format(date);
}
return out;
}
/**
* 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
@@ -112,6 +112,46 @@ describe('reservation form fields', () => {
});
});
it('FE-PLANNER-RESFORM-017: a hotel booking without its own times shows the ones its stay keeps', () => {
// The stay's check-in and check-out live on the accommodation; a booking that
// never had them written into its own metadata showed the "15:00" hint as if
// it were the saved time.
const acc = {
id: 9,
place_id: 4,
start_day_id: 2,
end_day_id: 3,
check_in: '15:00',
check_in_end: '22:00',
check_out: '11:00',
} as Accommodation;
const res = buildReservation({
type: 'hotel',
accommodation_id: 9,
metadata: JSON.stringify({ price: '2184' }),
} as Partial<Reservation>);
expect(reservationFieldsFrom(res, [acc], [], false)).toMatchObject({
meta_check_in_time: '15:00',
meta_check_out_time: '11:00',
});
expect(reservationFieldsFrom(res, [acc], [], true)).toMatchObject({ meta_check_in_end_time: '22:00' });
// The booking's own times still win.
const own = buildReservation({
type: 'hotel',
accommodation_id: 9,
metadata: JSON.stringify({ check_in_time: '14:00', check_out_time: '10:00' }),
} as Partial<Reservation>);
expect(reservationFieldsFrom(own, [acc], [], false)).toMatchObject({
meta_check_in_time: '14:00',
meta_check_out_time: '10:00',
});
// Nothing anywhere stays empty, so the hint shows as a hint.
expect(reservationFieldsFrom(buildReservation({ type: 'hotel' }), [], [], false)).toMatchObject({
meta_check_in_time: '',
meta_check_out_time: '',
});
});
it('FE-PLANNER-RESFORM-004: an import under review resolves its stay days and keeps its source details', () => {
const pf = {
title: 'Inn',
@@ -97,9 +97,13 @@ export function reservationFieldsFrom(
url: res.url || '',
place_id: res.place_id || '',
accommodation_id: res.accommodation_id || '',
meta_check_in_time: meta.check_in_time || '',
...(withCheckInEnd ? { meta_check_in_end_time: meta.check_in_end_time || '' } : {}),
meta_check_out_time: meta.check_out_time || '',
// The stay's own times when the booking carries none: a hotel booked before
// the times were written on it, or whose stay was edited from the plan,
// keeps them on the accommodation, and the empty field showed the
// "15:00" hint as if it were the saved check-in.
meta_check_in_time: meta.check_in_time || acc?.check_in || '',
...(withCheckInEnd ? { meta_check_in_end_time: meta.check_in_end_time || acc?.check_in_end || '' } : {}),
meta_check_out_time: meta.check_out_time || acc?.check_out || '',
hotel_place_id: acc?.place_id || '',
hotel_start_day: acc?.start_day_id || '',
hotel_end_day: acc?.end_day_id || '',
+1 -1
View File
@@ -18,7 +18,7 @@ import { useIsPhone } from '../../mobile/useIsPhone'
* category, addressed by name, so a category a user called "all" or "done"
* can have its own bucket instead of the built-in one.
*/
type TodoActiveFilter = { kind: 'smart'; id: TodoSmartFilter } | { kind: 'category'; name: string }
export type TodoActiveFilter = { kind: 'smart'; id: TodoSmartFilter } | { kind: 'category'; name: string }
type TodoSort = 'priority' | 'due' | null
@@ -41,4 +41,23 @@ describe('MarkdownText', () => {
expect(wrapper.className).toContain('truncate')
expect(wrapper.className).toContain('text-m-muted')
})
it('FE-MDTEXT-007: a clamped row takes its direction from its own text, so a Latin address in Arabic is cut at its end', () => {
const { container } = render(<MarkdownText clamp>{'68 Fukakusa Yabunouchicho, Fushimi Ward, Kyoto'}</MarkdownText>)
const wrapper = container.firstElementChild as HTMLElement
expect(wrapper).toHaveAttribute('dir', 'auto')
expect(wrapper).toHaveClass('truncate')
// No isolate inside: dir="auto" would not see the text through a <bdi>.
expect(wrapper.querySelector('bdi')).toBeNull()
})
it('FE-MDTEXT-008: in a full view each paragraph runs in its own direction, inside its block', () => {
const text = ["Kyoto's kitchen: pickles and matcha everything.", 'Second paragraph?'].join('\n\n')
const { container } = render(<MarkdownText>{text}</MarkdownText>)
expect(container.firstElementChild).not.toHaveAttribute('dir')
const paragraphs = container.querySelectorAll('p')
expect(paragraphs).toHaveLength(2)
paragraphs.forEach(p => expect(p.firstElementChild?.tagName).toBe('BDI'))
expect(paragraphs[1].textContent).toBe('Second paragraph?')
})
})
@@ -3,6 +3,7 @@ import type { Components } from 'react-markdown'
import remarkGfm from 'remark-gfm'
import remarkBreaks from 'remark-breaks'
import { markdownLinkComponents } from './markdownLink'
import { isolatedTextBlocks } from './markdownSanitize'
/**
* A clamped caption lives inside a tappable row, so an anchor there would eat
@@ -12,6 +13,8 @@ const clampedComponents: Components = {
a: ({ children }) => <>{children}</>,
}
const fullComponents: Components = { ...isolatedTextBlocks, ...markdownLinkComponents }
/**
* A description or note, rendered (#2337). The field is Markdown wherever it is
* written — the editors ship a formatting bar — so printing it raw anywhere
@@ -20,6 +23,9 @@ const clampedComponents: Components = {
* `clamp` is the list-row variant: one line high, matching what the desktop
* sidebar rows do with the same field. The height cap is inline because the row
* itself sets a font size, and a Tailwind `leading-*` would race with it.
* The clamped line takes its direction from its own text (dir="auto" on the
* row, which a <bdi> inside would hide from it), so a Latin address in an Arabic
* row loses its end at the cut, not its beginning.
*/
export default function MarkdownText({ children, className = '', clamp = false }: {
children: string
@@ -28,12 +34,13 @@ export default function MarkdownText({ children, className = '', clamp = false }
}) {
return (
<div
dir={clamp ? 'auto' : undefined}
className={`collab-note-md ${clamp ? 'truncate ' : ''}${className}`}
style={clamp ? { maxHeight: '1.2em', lineHeight: 1.2 } : undefined}
>
<Markdown
remarkPlugins={[remarkGfm, remarkBreaks]}
components={clamp ? clampedComponents : markdownLinkComponents}
components={clamp ? clampedComponents : fullComponents}
>
{children}
</Markdown>
@@ -70,13 +70,23 @@ describe('Stars', () => {
const filled = () => Array.from(container.querySelectorAll('span > span')).filter(o => (o as HTMLElement).style.width).length
expect(filled()).toBe(1)
fireEvent.mouseEnter(screen.getByRole('radio', { name: '4' }))
fireEvent.pointerEnter(screen.getByRole('radio', { name: '4' }), { pointerType: 'mouse' })
expect(filled()).toBe(4)
fireEvent.mouseLeave(container.querySelector('[role="radiogroup"]')!)
fireEvent.pointerLeave(container.querySelector('[role="radiogroup"]')!, { pointerType: 'mouse' })
expect(filled()).toBe(1)
})
it('FE-W4STAR-016: a finger previews nothing, so a tap cannot leave the stars filled', () => {
// A tap fires an enter with no leave after it: the preview used to stick,
// and an unrated place showed five full stars beside "Not rated yet".
const { container } = render(<Stars value={0} onRate={() => {}} />)
const filled = () => Array.from(container.querySelectorAll('span > span')).filter(o => (o as HTMLElement).style.width).length
fireEvent.pointerEnter(screen.getByRole('radio', { name: '5' }), { pointerType: 'touch' })
expect(filled()).toBe(0)
})
it('FE-W4STAR-008: focus previews and blur clears the preview', () => {
const { container } = render(<Stars value={0} onRate={() => {}} />)
const filled = () => Array.from(container.querySelectorAll('span > span')).filter(o => (o as HTMLElement).style.width).length
+6 -2
View File
@@ -29,7 +29,7 @@ export function Stars({ value, size = 15, onRate, myRating = null, ariaLabel }:
role={onRate ? 'radiogroup' : undefined}
aria-label={ariaLabel}
style={{ display: 'inline-flex', gap: 2, cursor: onRate ? 'pointer' : 'default' }}
onMouseLeave={() => setHover(null)}
onPointerLeave={() => setHover(null)}
>
{[1, 2, 3, 4, 5].map(i => {
// Fractional fill for averages: full below, empty above, clipped between.
@@ -52,7 +52,11 @@ export function Stars({ value, size = 15, onRate, myRating = null, ariaLabel }:
role="radio"
aria-checked={myRating === i}
aria-label={`${i}`}
onMouseEnter={() => setHover(i)}
// A preview for a pointer that hovers (a mouse, a trackpad, a pen held
// above the screen). A finger has no hover: the enter it fires on a
// tap has no leave after it, so the preview stuck and an unrated
// place showed five full stars beside "Not rated yet".
onPointerEnter={e => { if (e.pointerType !== 'touch') setHover(i) }}
onFocus={() => setHover(i)}
onBlur={() => setHover(null)}
onClick={() => onRate(myRating === i ? null : i)}
@@ -31,6 +31,35 @@ export const sanitizedMarkdownPlugins: NonNullable<Options['rehypePlugins']> = [
[rehypeSanitize, schema],
]
/**
* A paragraph or heading of user text runs in its own direction: a Latin note
* in the Arabic UI keeps its question mark and full stop at its end (and an
* Arabic one in an English UI), while the block keeps the alignment of the page
* around it. The isolate is inline, so the block itself stays as it was.
*/
export const isolatedTextBlocks: Components = {
p: ({ children }) => (
<p>
<bdi>{children}</bdi>
</p>
),
h1: ({ children }) => (
<h1>
<bdi>{children}</bdi>
</h1>
),
h2: ({ children }) => (
<h2>
<bdi>{children}</bdi>
</h2>
),
h3: ({ children }) => (
<h3>
<bdi>{children}</bdi>
</h3>
),
}
/**
* Links as in markdownLinkComponents (#1629): own tab plus `rel` protection.
* The exception is a footnote or heading link, which points inside the text
@@ -38,6 +67,7 @@ export const sanitizedMarkdownPlugins: NonNullable<Options['rehypePlugins']> = [
* somewhere else.
*/
export const sanitizedMarkdownComponents: Components = {
...isolatedTextBlocks,
a: ({ children, href, ...rest }) =>
href?.startsWith('#') ? (
<a {...rest} href={href}>
+151
View File
@@ -0,0 +1,151 @@
import { useSyncExternalStore } from 'react';
import { isNativeApp } from '../native/platform';
/**
* The touch shell's size classes, the web's answer to UIKit's compact and
* regular widths and to Android's window size classes. They are read off the
* window's CSS size, which is the same in the iOS and the Android app (a CSS
* pixel is a point there and a dp here), so both apps lay out alike.
*
* compact every phone, upright or on its side, and the narrow Split View and
* Stage Manager windows. The shell as it has always been: the dock at
* the bottom, sheets rising from below, one column.
* wide from 600pt across and taller than a phone on its side: an iPad in
* any orientation or window above a third of the screen, a foldable
* unfolded. A navigation rail on the leading edge takes the dock's
* place, sheets become centred form sheets, lists get columns.
* split from 840pt across, Android's expanded width: room for two panes, so
* the trip's plan sits next to its map and settings show their
* sections beside the page. An Android foldable unfolded on its side
* (about 841dp) has it, an 11" iPad upright (834pt) does not.
*
* The height bound keeps a phone on its side compact: no phone is taller than
* 500pt in landscape, no tablet or unfolded foldable is shorter.
*
* Only the app lays out for a large screen. In a browser the touch shell is
* the phone's, below 768px (mobile/useIsPhone.ts), and stays compact there: an
* iPad mini upright in Safari or a narrowed desktop window keeps the dock it
* always had.
*
* The class is one value in JavaScript, and the stylesheet follows it rather
* than asking media queries of its own: MobileShell writes it to the shell's
* root as data-size, and the `wide:` and `split:` variants (tailwind.config.js)
* and the shell's variables (mobile.css) key on that attribute. So the hooks
* and the CSS never disagree, and the guard against the soft keyboard below
* holds for both.
*/
export type SizeClass = 'compact' | 'wide' | 'split';
const WIDE_MIN_WIDTH = 600;
const SPLIT_MIN_WIDTH = 840;
const MIN_HEIGHT = 501;
/** The class of a window of this size, in CSS pixels. */
export function sizeClassOf(width: number, height: number): SizeClass {
if (width < WIDE_MIN_WIDTH || height < MIN_HEIGHT) return 'compact';
return width >= SPLIT_MIN_WIDTH ? 'split' : 'wide';
}
/** Input types that bring up no soft keyboard. */
const NO_KEYBOARD = new Set([
'button',
'checkbox',
'color',
'file',
'hidden',
'image',
'radio',
'range',
'reset',
'submit',
]);
/** Whether the focused element is one the soft keyboard comes up for. */
function typing(): boolean {
const el = document.activeElement;
if (!(el instanceof HTMLElement)) return false;
if (el.isContentEditable || el.tagName === 'TEXTAREA') return true;
return el instanceof HTMLInputElement && !NO_KEYBOARD.has(el.type);
}
let seen: { width: number; height: number } | null = null;
let pinned: SizeClass | null = null;
let listening = false;
const listeners = new Set<() => void>();
/**
* Takes in the window's size, except a height lost to the soft keyboard.
*
* Android resizes the WebView when the keyboard comes up (adjustResize), so a
* tablet on its side keeps about 400px of height while a field has focus. Read
* as it is, that made the class compact for as long as one typed: the rail
* turned into the dock, form sheets into bottom sheets, and a pane that
* remounted on the way took the focused field with it, which closed the
* keyboard, which brought the class back, in a loop. A window that only grew
* shorter, at the same width, while a field has focus is the keyboard, not a
* new window: the height seen before stays. The keyboard going down, a turn or
* any other resize is taken as it comes.
*/
function measure(): boolean {
const next = { width: window.innerWidth, height: window.innerHeight };
const prev = seen;
if (prev && next.width === prev.width && next.height < prev.height && typing()) return false;
seen = next;
return !prev || prev.width !== next.width || prev.height !== next.height;
}
function follow(): void {
if (listening || typeof window === 'undefined') return;
listening = true;
window.addEventListener('resize', () => {
if (measure()) for (const listener of listeners) listener();
});
}
function read(): SizeClass {
if (pinned) return pinned;
if (typeof window === 'undefined' || !isNativeApp()) return 'compact';
follow();
if (!seen) measure();
return seen ? sizeClassOf(seen.width, seen.height) : 'compact';
}
function subscribe(listener: () => void): () => void {
follow();
listeners.add(listener);
return () => listeners.delete(listener);
}
const serverClass = (): SizeClass => 'compact';
/** Whether the touch shell is laid out for a wide window right now, outside React. */
export function isWideViewport(): boolean {
return read() !== 'compact';
}
/** The size class. Follows rotation, folding and resizing, not the soft keyboard. */
export function useSizeClass(): SizeClass {
return useSyncExternalStore(subscribe, read, serverClass);
}
/** True while the window is in the wide size class (split included). */
export function useIsWide(): boolean {
return useSizeClass() !== 'compact';
}
/** True while the window has room for two panes side by side. */
export function useIsSplit(): boolean {
return useSizeClass() === 'split';
}
/** Test helper: pins the size class, or with null hands it back to the window. */
export function _pinSizeClass(next: SizeClass | null): void {
pinned = next;
for (const listener of listeners) listener();
}
/** Test helper: forgets the window size seen so far. */
export function _resetSizeClass(): void {
pinned = null;
seen = null;
}
+30
View File
@@ -0,0 +1,30 @@
import { type RefObject, useLayoutEffect } from 'react';
/**
* Grows a textarea with what is written in it, so its last line is never
* sliced through the glyphs at the bottom edge the way a fixed `rows` height
* cuts a fourth line. `rows` stays the least it shows. It measures again when
* the text changes and when the field's width does (a rotation, a fold), since
* both decide how many lines the text wraps to.
*/
export function useAutoGrow(ref: RefObject<HTMLTextAreaElement | null>, value: string): void {
useLayoutEffect(() => {
const el = ref.current;
if (!el) return;
let width = -1;
const fit = () => {
width = el.clientWidth;
el.style.height = 'auto';
// scrollHeight leaves the border out; the box is border-box.
const border = el.offsetHeight - el.clientHeight;
el.style.height = `${el.scrollHeight + border}px`;
};
fit();
if (typeof ResizeObserver === 'undefined') return;
const observer = new ResizeObserver(() => {
if (el.clientWidth !== width) fit();
});
observer.observe(el);
return () => observer.disconnect();
}, [ref, value]);
}
+6 -2
View File
@@ -1307,12 +1307,16 @@ img[alt="TREK"] {
.dark .bg-slate-100 { background-color: #27272a !important; }
.dark .bg-slate-900 { background-color: #e4e4e7 !important; color: #09090b !important; }
.dark .hover\:bg-slate-700:hover { background-color: #d4d4d8 !important; color: #09090b !important; }
.dark input, .dark textarea, .dark select {
/* Not inside the touch shell (.m-root): its fields state their own fill, a
transparent input inside a rounded wrapper above all, and this rule outweighs
every utility class, so it painted a dark box inside each of them. The
:where() keeps the desktop's weight exactly as it was. */
.dark input:not(:where(.m-root *)), .dark textarea:not(:where(.m-root *)), .dark select:not(:where(.m-root *)) {
background-color: #1c1c21;
color: #f4f4f5;
border-color: #27272a;
}
.dark input::placeholder, .dark textarea::placeholder { color: #71717a; }
.dark input:not(:where(.m-root *))::placeholder, .dark textarea:not(:where(.m-root *))::placeholder { color: #71717a; }
.dark .bg-emerald-50 { background-color: rgba(16,185,129,0.15) !important; }
.dark .bg-red-50 { background-color: rgba(239,68,68,0.15) !important; }
+1
View File
@@ -25,6 +25,7 @@ import '@fontsource/geist-sans/600.css'
// several .leaflet-* styles.
import 'leaflet/dist/leaflet.css'
import './index.css'
import './styles/rtl.css'
import './styles/ferratas.css'
import './styles/paddle.css'
import './styles/walkRoute.css'
+33 -4
View File
@@ -1,12 +1,17 @@
import { ReactNode, useRef } from 'react'
import { CSSProperties, ReactNode, useRef } from 'react'
import { useMatch } from 'react-router'
import BottomNav from '../components/Layout/BottomNav'
import ErrorBoundary from '../components/shared/ErrorBoundary'
import MBottomNav from './components/MBottomNav'
import MNavRail from './components/MNavRail'
import { useSizeClass } from '../hooks/sizeClass'
import MKeyboardHost from './keyboard/MKeyboardHost'
import MShellShortcuts from './keyboard/MShellShortcuts'
import MToastHost from './components/MToastHost'
import MEdgeBack from './components/MEdgeBack'
import { useNativeCapabilities } from '../native/capabilities'
import { isNativeIos } from '../native/platform'
import { useWindowFold } from '../native/windowFold'
import './mobile.css'
interface MobileShellProps {
@@ -39,6 +44,17 @@ export default function MobileShell({ isPhone, children }: MobileShellProps) {
const { caps } = useNativeCapabilities()
const pageRef = useRef<HTMLDivElement>(null)
const edgeBack = isNativeIos() && caps.features.includes('edgeBack')
// On a wide window (an iPad, a foldable unfolded) the rail on the leading edge
// replaces the dock, and the page column starts after it. The class goes on the
// root as data-size: the wide:/split: variants and mobile.css key on it.
const sizeClass = useSizeClass()
const wide = sizeClass !== 'compact'
// A foldable's fold, as the Android app reports it: two-pane screens end their
// leading pane there (mobile.css, data-fold).
const fold = useWindowFold()
const foldStyle = fold
? ({ '--m-fold-start': `${fold.start}px`, '--m-fold-end': `${fold.end}px` } as CSSProperties)
: undefined
if (!isPhone) {
return (
@@ -52,7 +68,7 @@ export default function MobileShell({ isPhone, children }: MobileShellProps) {
return (
// min-h-svh, not min-h-dvh: dvh grows while Safari's toolbar retracts, so a
// dvh page length would chase the animation it just triggered.
<div className="m-root flex min-h-svh flex-col text-m-ink">
<div className="m-root m-shell flex min-h-svh flex-col text-m-ink" data-size={sizeClass} data-fold={fold ? 'vertical' : undefined} style={foldStyle}>
{/* Background and screen gradient live on their own viewport-sized layer.
The shell root is as tall as the document now, and --m-scr is a radial
gradient measured against its element box, so on it the light cone would
@@ -66,10 +82,23 @@ export default function MobileShell({ isPhone, children }: MobileShellProps) {
layer to be visible, body must not paint over it, which index.css handles
for this breakpoint. */}
<div aria-hidden className="pointer-events-none fixed inset-0 -z-10 bg-[color:var(--m-bg)] bg-[image:var(--m-scr)]" />
<div ref={pageRef} className="flex-1">{children}</div>
{/* The rail comes before the page in the document, as it does on screen,
so a keyboard (Full Keyboard Access on an iPad) reaches the navigation
first. It is fixed, so where it sits in the DOM moves nothing. */}
{!inTripPlanner && wide && (
<ErrorBoundary boundaryId="chrome:m-nav-rail" fallback={null}><MNavRail /></ErrorBoundary>
)}
<div ref={pageRef} className="flex-1 wide:ps-[var(--m-rail-w)]">{children}</div>
<MEdgeBack target={pageRef} enabled={edgeBack} />
{!inTripPlanner && <ErrorBoundary boundaryId="chrome:m-bottom-nav" fallback={null}><MBottomNav /></ErrorBoundary>}
{!inTripPlanner && !wide && (
<ErrorBoundary boundaryId="chrome:m-bottom-nav" fallback={null}><MBottomNav /></ErrorBoundary>
)}
<ErrorBoundary boundaryId="chrome:m-toast" fallback={null}><MToastHost /></ErrorBoundary>
{/* A hardware keyboard (an iPad's, mostly) gets the shortcuts an iPad app has. */}
<ErrorBoundary boundaryId="chrome:m-keyboard" fallback={null}>
<MKeyboardHost />
<MShellShortcuts />
</ErrorBoundary>
<div id="m-sheet-root" />
</div>
)
+13 -3
View File
@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react'
import { useNavigate, useLocation } from 'react-router'
import { useNavigate, useLocation, matchPath } from 'react-router'
import { useSettingsStore } from '../../store/settingsStore'
import { useTranslation } from '../../i18n'
import { ChevronRight, MoreHorizontal, Plus, Search, Sparkles, Upload } from 'lucide-react'
@@ -9,6 +9,7 @@ import { useNavItems, splitMobileNav } from '../../components/Layout/navItems'
import { isNavItemActive, useCreateAction } from '../../components/Layout/useCreateAction'
import MFab from './MFab'
import { useAssistantHomeLauncher } from '../../components/Assistant/useAssistantHome'
import MDockScrim from './MDockScrim'
interface NavItem { to: string; label: string; icon: LucideIcon }
@@ -72,6 +73,14 @@ export default function MBottomNav() {
`flex h-[42px] w-[42px] flex-none items-center justify-center rounded-full ${
active ? 'bg-m-act text-m-actfg' : 'text-m-muted'
}`
// More stands for whichever of its screens is on show, so it is marked as a
// ring: a filled circle beside the filled "+" read as two of the same action.
const moreCls = (active: boolean) =>
`flex h-[42px] w-[42px] flex-none items-center justify-center rounded-full ${
active ? 'bg-[color:var(--m-ic)] text-m-ink ring-[1.5px] ring-inset ring-[color:var(--m-act)]' : 'text-m-muted'
}`
// A map runs to the screen's edge on purpose; everything else fades out under the dock.
const overMap = location.pathname.startsWith('/atlas') || matchPath('/journey/:id', location.pathname) !== null
const renderItem = ({ to, label, icon: Icon }: NavItem) => {
const active = isActive(to)
@@ -120,7 +129,8 @@ export default function MBottomNav() {
</div>
)}
<nav className="m-dock-vt fixed inset-x-4 z-40 flex h-[62px] items-center rounded-[31px] border border-[color:var(--m-gbr)] bg-[color:var(--m-glass)] px-3 shadow-[0_16px_44px_-14px_rgba(0,0,0,.35)] backdrop-blur-[30px] backdrop-saturate-[1.8] bottom-[calc(env(safe-area-inset-bottom,0px)+12px)]">
{!overMap && <MDockScrim />}
<nav className="m-dock-vt fixed inset-x-4 z-40 flex h-[62px] items-center rounded-[31px] border border-[color:var(--m-gbr)] bg-[color:var(--m-dock)] px-3 shadow-[0_16px_44px_-14px_rgba(0,0,0,.35)] backdrop-blur-[30px] backdrop-saturate-[1.4] bottom-[calc(env(safe-area-inset-bottom,0px)+12px)]">
<div className="flex min-w-0 flex-1 items-center justify-around">{left.map(renderItem)}</div>
{logoSlot ? (
@@ -151,7 +161,7 @@ export default function MBottomNav() {
aria-label={t('mobileNav.more')}
aria-expanded={moreOpen}
aria-current={moreActive ? 'page' : undefined}
className={circleCls(moreActive)}
className={moreCls(moreActive)}
>
<MoreHorizontal size={21} strokeWidth={1.9} />
</button>
@@ -0,0 +1,35 @@
import { useState, type ReactNode } from 'react';
import { edgeFadeClass, useEdgeFade } from './useEdgeFade';
/**
* A row of chips that scrolls sideways once it is wider than its room, and
* fades out at an edge with more behind it, the way the trip's day strip does,
* so a chip cut by the edge reads as "the row goes on" rather than as a word
* cut in half. It shrinks to its room in a row it shares (min-width 0), so a
* button beside it stays beside it instead of covering the chips.
*/
export default function MChipStrip({
children,
className = '',
label,
}: {
children: ReactNode;
/** Layout of the row itself: gap, alignment, growth. */
className?: string;
/** Names the group for a screen reader, where the chips are a set of filters. */
label?: string;
}) {
const [strip, setStrip] = useState<HTMLDivElement | null>(null);
const fade = useEdgeFade(strip);
return (
<div
ref={setStrip}
role={label ? 'group' : undefined}
aria-label={label}
className={`flex min-w-0 overflow-x-auto ${edgeFadeClass(fade)} ${className}`}
>
{children}
</div>
);
}
@@ -0,0 +1,21 @@
/**
* The screen's own background under a floating dock, fading out upwards. The
* dock floats 12px above the screen's foot, and in that gap scrolled content
* used to stay sharp: a pill, a name and an avatar peeking out under the bar.
* Now the page ends where the dock begins, the way an iOS bar's scroll edge
* does.
*
* The same layer as the shell's background (MobileShell), the colour and the
* screen gradient over the whole window, masked down to the strip at the foot,
* so the strip is exactly the colour behind it and shows no band. Above the
* dock's middle the mask is clear, so nothing that sits above the dock (a stats
* card, a carousel) is touched.
*/
export default function MDockScrim() {
return (
<div
aria-hidden="true"
className="m-dock-scrim pointer-events-none fixed inset-0 z-[39] bg-[color:var(--m-bg)] bg-[image:var(--m-scr)]"
/>
);
}
+17 -5
View File
@@ -3,19 +3,31 @@ import { ReactNode } from 'react'
interface MGlassBarProps {
/** Pins the bar below the status bar (default). Pass false to embed it in flow. */
floating?: boolean
/**
* The width the screen's content column stops at on a wide window, as the
* same class the content uses (`wide:max-w-[1280px]`). The floating bar then
* stands in that column with the content's own padding, so their edges line
* up at every width.
*/
column?: string
className?: string
children: ReactNode
}
/** Top bar glass pill: blur 26 / saturate 1.7 on --m-glass with --m-gbr border. */
export default function MGlassBar({ floating = true, className = '', children }: MGlassBarProps) {
return (
export default function MGlassBar({ floating = true, column = '', className = '', children }: MGlassBarProps) {
const bar = (
<div
className={`flex items-center gap-[11px] rounded-full border border-[color:var(--m-gbr)] bg-[color:var(--m-glass)] px-[14px] py-2 backdrop-blur-[26px] backdrop-saturate-[1.7] shadow-[0_14px_34px_-16px_rgba(0,0,0,.22)] ${
floating ? 'fixed inset-x-4 top-[var(--m-safe-top,12px)] z-30' : ''
} ${className}`}
className={`pointer-events-auto flex items-center gap-[11px] rounded-full border border-[color:var(--m-gbr)] bg-[color:var(--m-glass)] px-[14px] py-2 backdrop-blur-[26px] backdrop-saturate-[1.7] shadow-[0_14px_34px_-16px_rgba(0,0,0,.22)] ${className}`}
>
{children}
</div>
)
if (!floating) return bar
// The page column (m-span-x), padded and capped like the content under it.
return (
<div className="m-span-x pointer-events-none fixed top-[var(--m-safe-top,12px)] z-30">
<div className={`px-4 wide:mx-auto wide:px-6 ${column}`}>{bar}</div>
</div>
)
}
+159
View File
@@ -0,0 +1,159 @@
import type { LucideIcon } from 'lucide-react';
import { Plus, Search, Settings2, Shield, Sparkles, Upload } from 'lucide-react';
import { useLocation, useNavigate } from 'react-router';
import { useAssistantHomeLauncher } from '../../components/Assistant/useAssistantHome';
import { useNavItems } from '../../components/Layout/navItems';
import { isNavItemActive, useCreateAction } from '../../components/Layout/useCreateAction';
import { useTranslation } from '../../i18n';
import { useAuthStore } from '../../store/authStore';
import MFab from './MFab';
import { MRail, MRailButton, MRailSpacer } from './MRail';
import { useRailAction } from './railAction';
interface RailEntry {
id: string;
label: string;
icon: LucideIcon;
active: boolean;
go: () => void;
}
const FOCUS_RING =
'outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--m-act)] focus-visible:ring-offset-2 focus-visible:ring-offset-[color:var(--m-bg)]';
/**
* The touch shell's navigation on a wide window (hooks/sizeClass.ts): a rail
* on the leading edge, where an iPad app keeps its sidebar, in place of the
* phone's bottom dock.
*
* It has the room the dock lacks, so every destination sits in it at once,
* with its name under the icon: the dock's own items, the ones the phone keeps
* under More, the home assistant, and settings and administration at the foot,
* which the phone reaches through the avatar menu.
*
* Its head is always one 56px circle, so no entry moves when the page changes:
* the create action with the dock's context-aware meaning, the screen's own
* action where it has one (the vacation calendar's switch), or the dock's
* quiet logo where there is nothing to create (settings, administration).
*/
export default function MNavRail() {
const { t } = useTranslation();
const navigate = useNavigate();
const location = useLocation();
const create = useCreateAction(true);
const assistant = useAssistantHomeLauncher();
const isAdmin = useAuthStore((s) => s.user?.role === 'admin');
const navItems = useNavItems();
const screenAction = useRailAction();
const isActive = (to: string) => isNavItemActive(location.pathname, to);
const main: RailEntry[] = [
...navItems.map(({ id, to, label, icon }) => ({
id: to,
// A few names are too long for a rail in some languages; the rail has its own words for those.
label: id === 'collections' ? t('mobileNav.railCollections') : label,
icon,
active: isActive(to),
go: () => void navigate(to),
})),
...(assistant.available
? [{ id: 'assistant', label: t('assistant.launcher.tooltip'), icon: Sparkles, active: false, go: assistant.open }]
: []),
];
const foot: RailEntry[] = [
{
id: '/settings',
label: t('mobileNav.railSettings'),
icon: Settings2,
active: isActive('/settings'),
go: () => void navigate('/settings'),
},
...(isAdmin
? [
{
id: '/admin',
label: t('mobileNav.railAdmin'),
icon: Shield,
active: isActive('/admin'),
go: () => void navigate('/admin'),
},
]
: []),
];
const path = location.pathname;
const nothingToCreate = path.startsWith('/settings') || path.startsWith('/admin');
// /vacay draws its own action (the year/editor switch) into the head, as it
// does into the dock's centre on the phone; until it has, the slot stays empty.
const screenOwnsHead = path.startsWith('/vacay');
const searchFab = path.startsWith('/atlas');
let head;
if (nothingToCreate) {
head = (
<span
aria-hidden="true"
className="flex h-14 w-14 items-center justify-center rounded-full bg-[color:var(--m-ic)] opacity-70"
>
<img src="/icons/icon-dark.svg" alt="" className="block h-6 w-6 opacity-75 dark:hidden" />
<img src="/icons/icon-white.svg" alt="" className="hidden h-6 w-6 opacity-75 dark:block" />
</span>
);
} else if (screenOwnsHead) {
head = screenAction ? <ScreenActionButton /> : <span aria-hidden="true" className="h-14 w-14" />;
} else {
head = (
<MFab onClick={create.run} ariaLabel={create.label} className={FOCUS_RING}>
{searchFab ? (
<Search size={22} strokeWidth={2.4} />
) : create.upload ? (
<Upload size={21} strokeWidth={2.4} />
) : (
<Plus size={24} strokeWidth={2.4} />
)}
</MFab>
);
}
return (
<MRail label={t('mobileNav.navigation')} fixed head={head}>
{main.map((entry) => (
<RailEntryButton key={entry.id} {...entry} />
))}
<MRailSpacer />
<div aria-hidden="true" className="my-1 h-px w-8 flex-none bg-[color:var(--m-rowbr)]" />
{foot.map((entry) => (
<RailEntryButton key={entry.id} {...entry} />
))}
</MRail>
);
}
/** The screen's own action, drawn like the dock's circle: filled, or a ring while it is on. */
function ScreenActionButton() {
const action = useRailAction();
if (!action) return null;
const Icon = action.icon;
return (
<button
type="button"
onClick={action.run}
aria-label={action.label}
className={`flex h-14 w-14 flex-none items-center justify-center rounded-full transition-[background,color] duration-300 ease-in-out ${FOCUS_RING} ${
action.pressed
? 'border-2 border-[color:var(--m-act)] bg-[color:var(--m-sheetop)] text-m-ink'
: 'bg-m-act text-m-actfg shadow-[0_8px_20px_-6px_rgba(0,0,0,.4)]'
}`}
>
<Icon size={24} strokeWidth={2.3} />
</button>
);
}
function RailEntryButton({ label, icon: Icon, active, go }: RailEntry) {
return (
<MRailButton label={label} active={active} onClick={go}>
<Icon size={20} strokeWidth={active ? 2.1 : 1.9} />
</MRailButton>
);
}
@@ -13,7 +13,7 @@ export default function MPullIndicator({ state, label }: { state: PullState; lab
aria-hidden={!state.refreshing}
role={state.refreshing ? 'status' : undefined}
aria-label={state.refreshing ? label : undefined}
className="pointer-events-none fixed inset-x-0 z-30 flex justify-center transition-opacity duration-200"
className="m-span-x pointer-events-none fixed z-30 flex justify-center transition-opacity duration-200"
style={{ top: 'calc(var(--m-safe-top, 12px) + 70px)', opacity: visible ? Math.max(0.35, progress) : 0 }}
>
<span className="flex h-9 w-9 items-center justify-center rounded-full border border-[color:var(--m-gbr)] bg-[color:var(--m-glass)] text-m-ink shadow-sm backdrop-blur-xl">
+131
View File
@@ -0,0 +1,131 @@
import { useLayoutEffect, useRef, type ReactNode, type RefObject } from 'react';
/**
* The rail on the leading edge that takes the dock's place on a wide window
* (hooks/sizeClass.ts): the app's sections (MNavRail) or a trip's (MTripRail).
* It sits inside the strip --m-rail-w reserves, so the page column and every
* fixed bar start right after it.
*
* 80px wide, a navigation rail's width on Android, so a section's name in its
* own words has room. Its material is the sheets' near-opaque glass rather than
* the dock's: on a map a saturated country used to show through it in bands
* behind the names.
*
* `head` is the action at its top (the "+" and its stand-ins). It stays put
* while the entries under it scroll, which they do on a short window.
*/
export function MRail({
label,
fixed = false,
head,
children,
}: {
label: string;
fixed?: boolean;
head?: ReactNode;
children: ReactNode;
}) {
const listRef = useRef<HTMLDivElement>(null);
useEvenLines(listRef);
return (
<nav
aria-label={label}
className={`m-dock-vt ${fixed ? 'fixed' : 'absolute'} bottom-[calc(env(safe-area-inset-bottom,0px)+12px)] start-[calc(var(--m-rail-w)-88px)] top-[var(--m-safe-top,12px)] z-40 flex w-[80px] flex-col items-center rounded-[30px] border border-[color:var(--m-gbr)] bg-[color:var(--m-sheet)] py-3 shadow-[0_16px_44px_-14px_rgba(0,0,0,.35)] backdrop-blur-[30px]`}
>
{head && <div className="mb-2 flex flex-none justify-center">{head}</div>}
<div
ref={listRef}
className="flex min-h-0 w-full flex-1 flex-col items-center gap-1 overflow-y-auto px-1 [scrollbar-width:none]"
>
{children}
</div>
</nav>
);
}
/**
* Every entry of a rail takes the same height: two lines for its name as soon
* as one name needs them, one line otherwise. Names wrap where their language
* needs, never at a smaller size, and the rail's rhythm does not change from
* entry to entry or from language to language.
*
* What it settles on is written to the list's own style (--m-rail-lines), a
* fact about the laid-out names rather than React state. It is measured again
* whenever a name changes height (the user's text size, a font arriving).
*/
function useEvenLines(listRef: RefObject<HTMLDivElement | null>) {
useLayoutEffect(() => {
const list = listRef.current;
if (!list) return;
const names = () => Array.from(list.querySelectorAll<HTMLElement>('[data-rail-name]'));
const measure = () => {
list.style.setProperty('--m-rail-lines', '1');
const two = names().some((el) => {
const line = parseFloat(getComputedStyle(el).lineHeight) || 0;
return line > 0 && el.scrollHeight > line * 1.5;
});
list.style.setProperty('--m-rail-lines', two ? '2' : '1');
};
measure();
let cancelled = false;
void document.fonts?.ready.then(() => {
if (!cancelled) measure();
});
// A name that changes height (the text size setting) is measured again. The
// measure itself settles at once, so it cannot keep the observer going.
const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(measure);
for (const el of names()) observer?.observe(el);
return () => {
cancelled = true;
observer?.disconnect();
};
});
}
/** Pushes the entries after it to the foot of the rail. */
export function MRailSpacer() {
return <div className="flex-1" />;
}
interface MRailButtonProps {
label: string;
active: boolean;
onClick: () => void;
/** The icon, drawn at 20px. */
children: ReactNode;
}
/**
* One destination: the icon on the dock's 40px circle, filled while it is the
* current one, its name underneath at the caption size. The name is never cut
* and never shrunk: it wraps into a second, balanced line where its language
* needs one, and only a word longer than the rail is hyphenated.
*/
export function MRailButton({ label, active, onClick, children }: MRailButtonProps) {
return (
<button
type="button"
onClick={onClick}
title={label}
aria-current={active ? 'page' : undefined}
className="group flex w-full flex-none flex-col items-center gap-[3px] rounded-[18px] py-[5px] outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--m-act)]"
>
<span
aria-hidden="true"
className={`flex h-10 w-10 items-center justify-center rounded-full transition-colors duration-150 ${
active ? 'bg-m-act text-m-actfg' : 'text-m-muted group-hover:bg-[color:var(--m-ic)] group-hover:text-m-ink'
}`}
>
{children}
</span>
<span
data-rail-name=""
className={`line-clamp-2 min-h-[calc(1.2em*var(--m-rail-lines,1))] w-full text-center font-geist text-[length:calc(10px*var(--fs-scale-caption,1))] leading-[1.2] [hyphens:auto] [overflow-wrap:anywhere] [text-wrap:balance] ${
active ? 'font-bold text-m-ink' : 'font-semibold text-m-muted'
}`}
>
{label}
</span>
</button>
);
}
@@ -0,0 +1,102 @@
import type { LucideIcon } from 'lucide-react';
import { ChevronLeft } from 'lucide-react';
import type { ReactNode } from 'react';
export interface MSection {
id: string;
label: string;
icon: LucideIcon;
}
interface MSectionRowProps {
section: MSection;
active: boolean;
/** Marks the row as the page on show, for the list that stands beside the page. */
current?: boolean;
onPick: () => void;
}
/** One section of a settings-like screen, in its dropdown or in the list beside the page. */
export function MSectionRow({ section, active, current = false, onPick }: MSectionRowProps) {
return (
<button
type="button"
onClick={onPick}
aria-current={current ? 'page' : undefined}
className={`flex w-full items-center gap-[10px] rounded-[11px] p-[10px] text-start text-[length:calc(13px*var(--fs-scale-body,1))] font-semibold text-m-ink outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--m-act)] ${
active ? 'bg-[color:var(--m-ic)]' : 'hover:bg-[color:var(--m-ic)]'
}`}
>
<section.icon size={15} strokeWidth={2} className="flex-none" />
<span className="min-w-0 flex-1 truncate">{section.label}</span>
</button>
);
}
/**
* The frame of a settings-like screen (settings, administration), one rule for
* both: the page column on a phone, a centred column on a wide window, and from
* the split size class the list of sections beside a page that runs to the same
* width on both screens.
*/
export const SECTION_SCREEN =
'px-4 pb-[calc(var(--bottom-nav-h,84px)+16px)] pt-[var(--m-safe-top,12px)] wide:mx-auto wide:max-w-[1040px] wide:px-6 split:grid split:max-w-[1240px] split:grid-cols-[260px_minmax(0,1fr)] split:items-start split:gap-8';
interface MSectionNavProps {
title: string;
backLabel: string;
onBack: () => void;
sections: MSection[];
activeId: string;
onPick: (id: string) => void;
/**
* An action of the screen (admin: a new user), on a line of its own under the
* title, so a long title in a long language keeps the whole width.
*/
action?: ReactNode;
}
/**
* The sections of a settings-like screen (settings, administration) as a list
* of their own beside the page, the way Settings on an iPad shows them, once
* the window has room for two panes. It stays in view while the page scrolls.
* Narrower windows keep the dropdown in the screen's header.
*/
export default function MSectionNav({
title,
backLabel,
onBack,
sections,
activeId,
onPick,
action,
}: MSectionNavProps) {
return (
<nav
aria-label={title}
className="sticky top-[var(--m-safe-top,12px)] flex max-h-[calc(100dvh-var(--m-safe-top,12px)-16px)] flex-col gap-1 overflow-y-auto rounded-2xl border border-[color:var(--m-rowbr)] bg-[color:var(--m-sheet)] p-[6px] shadow-[0_16px_40px_-24px_rgba(0,0,0,.3)] [scrollbar-width:none]"
>
<div className="mb-1 flex items-center gap-2 p-[4px]">
<button
type="button"
aria-label={backLabel}
onClick={onBack}
className="flex h-[34px] w-[34px] flex-none items-center justify-center rounded-full text-m-ink outline-none hover:bg-[color:var(--m-ic)] focus-visible:ring-2 focus-visible:ring-[color:var(--m-act)]"
>
<ChevronLeft size={18} strokeWidth={2.2} className="rtl:-scale-x-100" />
</button>
<span className="min-w-0 flex-1 truncate text-subtitle font-bold text-m-ink">{title}</span>
</div>
{action && <div className="mb-1 px-[4px]">{action}</div>}
{sections.map((section) => (
<MSectionRow
key={section.id}
section={section}
active={section.id === activeId}
current={section.id === activeId}
onPick={() => onPick(section.id)}
/>
))}
</nav>
);
}
+162 -70
View File
@@ -17,11 +17,17 @@ interface MSheetProps {
/** card = centred floating card, bottom = docked panel above the nav, drawer = left drawer */
variant?: MSheetVariant
/**
* glass = translucent sheet (--m-sheet), bar-glass = lighter bar glass
* (--m-glass, default for the bottom variant), opaque = solid (--m-sheetop)
* @deprecated Every sheet stands on the one opaque surface now (--m-sheetop),
* whatever is asked for here; see SURFACE. Kept so the callers that still
* name a material compile.
*/
material?: MSheetMaterial
/** Transparent scrim (the "Mehr" sheet sits on the UI without dimming it) */
/**
* Transparent scrim (the "Mehr" sheet sits on the UI without dimming it). Not
* on a window as short as a phone on its side: there the sheet takes most of
* the height, and the strips of page left above and below it read as part of
* it unless they are dimmed.
*/
dimTransparent?: boolean
ariaLabel?: string
className?: string
@@ -32,35 +38,122 @@ interface MSheetProps {
* throwing away a state that differs from the one at the first touch.
*/
discardGuard?: unknown
/** Width as a centred form sheet on a wide window (default regular). */
wideWidth?: MSheetWidth
/** Where it stands on a wide window (default centre). */
wideAnchor?: MSheetWideAnchor
/**
* A form: FormSheetHeader, FormSheetBody and FormSheetFooter (PlSheetChrome)
* as its three children. On a window as short as a phone on its side it
* fills the height instead of floating as a card a third of the screen
* tall, and its actions move up beside the title (mobile.css, m-form-sheet),
* so the fields get every line in between.
*/
form?: boolean
}
const EXIT_MS = 300
const DISCARD_BUTTON = 'rounded-full bg-danger px-4 py-2 text-body font-semibold text-white' // theme-lint-disable: white on the danger fill, as ConfirmDialog draws it
const POSITION: Record<MSheetVariant, string> = {
card: 'absolute inset-x-[14px] top-1/2 -translate-y-1/2',
bottom: 'absolute inset-x-4 bottom-[calc(var(--bottom-nav-h,84px)+16px)]',
drawer: 'absolute left-0 top-0 bottom-0 w-[78%] max-w-[320px]',
/**
* How wide the sheet gets on a wide window, where it is a centred form sheet
* rather than a panel across the screen: compact for a short list of actions,
* regular for a form, large for a sheet that holds a page of its own.
*/
type MSheetWidth = 'compact' | 'regular' | 'large'
const WIDE_WIDTH: Record<MSheetWidth, string> = {
compact: 'wide:w-[min(320px,100%)]',
regular: 'wide:w-[min(540px,100%)]',
large: 'wide:w-[min(720px,100%)]',
}
/**
* Where a sheet stands on a wide window. centre: a form sheet. rail-foot: a
* popover beside the foot of the navigation rail, for a menu opened from there
* (the trip's More), the way iPadOS shows a menu next to the button that
* opened it rather than in the middle of the screen.
*/
type MSheetWideAnchor = 'center' | 'rail-foot'
/*
* The region a sheet may take: a box over the screen that ignores the pointer
* (a tap on it reaches the scrim) and lays the sheet out inside itself, so a
* tall sheet is held by the region's edges instead of running past them.
*
* On a phone the card stands between the status bar and the dock, and the
* docked sheet just above the dock; neither lies across the dock.
*/
const REGION: Record<MSheetVariant, string> = {
card: 'inset-x-[14px] top-[var(--m-safe-top,12px)] bottom-[calc(var(--bottom-nav-h,84px)+12px)] justify-center',
// On a phone on its side the gap it keeps at the top would leave it a strip
// of a few rows, so there it may rise nearly to the top.
bottom:
'inset-x-4 top-[calc(var(--m-safe-top,12px)+56px)] bottom-[calc(var(--bottom-nav-h,84px)+16px)] justify-end short:top-[calc(var(--m-safe-top,12px)+8px)]',
drawer: 'start-0 top-0 bottom-0 w-[78%] max-w-[320px]',
}
// On a wide window (hooks/sizeClass.ts) a docked or floating sheet becomes
// what iPadOS shows there: a form sheet over the dimmed page, centred in the
// content beside the navigation rail (never over the rail), as wide as its
// content needs and as tall as the window allows. The region starts after the
// rail on the leading edge, so it needs no physical side.
const WIDE_REGION: Record<MSheetWideAnchor, string> = {
center:
'wide:inset-0 wide:ps-[calc(var(--m-rail-w)+24px)] wide:pe-6 wide:pt-[calc(var(--m-safe-top,12px)+12px)] wide:pb-[calc(env(safe-area-inset-bottom,0px)+24px)] wide:items-center wide:justify-center',
'rail-foot':
'wide:inset-0 wide:ps-[calc(var(--m-rail-w)+6px)] wide:pt-[calc(var(--m-safe-top,12px)+12px)] wide:pb-[calc(env(safe-area-inset-bottom,0px)+12px)] wide:items-start wide:justify-end',
}
// A form on a window as short as a phone on its side: a sheet the window's
// full height, over the dock, as wide as a form reads well and no wider.
const SHORT_FORM_REGION =
'short:inset-x-4 short:top-[max(env(safe-area-inset-top,0px),8px)] short:bottom-0 short:items-center short:justify-stretch'
const SHORT_FORM_BOX = 'short:max-w-[640px] short:flex-1'
/** The region the sheet stands in, on a phone and on a wide window. */
function regionClass(variant: MSheetVariant, anchor: MSheetWideAnchor = 'center', form = false): string {
const base = 'pointer-events-none absolute flex flex-col'
if (variant === 'drawer') return `${base} ${REGION.drawer}`
return `${base} ${REGION[variant]} ${WIDE_REGION[anchor]}${form ? ` ${SHORT_FORM_REGION}` : ''}`
}
/** The sheet's own box inside the region: its width, and the arrow when it has one. */
function boxClass(variant: MSheetVariant, width: MSheetWidth = 'regular', form = false): string {
const base = 'pointer-events-auto relative flex min-h-0 w-full flex-col'
if (variant === 'drawer') return `${base} h-full`
return `${base} max-h-full ${WIDE_WIDTH[width]}${form ? ` ${SHORT_FORM_BOX}` : ''}`
}
/** The popover's arrow, pointing at the More button at the foot of the rail (wide windows only). */
function PopoverArrow({ variant, anchor }: { variant: MSheetVariant; anchor?: MSheetWideAnchor }) {
if (anchor !== 'rail-foot' || variant === 'drawer') return null
return (
<span
aria-hidden="true"
className="absolute -start-[10px] bottom-[33px] hidden h-[18px] w-[10px] bg-[color:var(--m-sheetop)] [clip-path:polygon(100%_0,0_50%,100%_100%)] rtl:-scale-x-100 wide:block"
/>
)
}
// The corner radius goes through --m-sheet-r, so mobile.css can keep it on the
// focused panel, where the app-wide focus ring would square it off to 4px.
const SHAPE: Record<MSheetVariant, string> = {
card: 'rounded-[28px] shadow-[0_30px_80px_-24px_rgba(0,0,0,.6)] max-h-[calc(100dvh-var(--m-safe-top,12px)-96px)]',
bottom: 'rounded-[26px] shadow-[0_-8px_40px_-14px_rgba(0,0,0,.45)] max-h-[calc(100dvh-var(--bottom-nav-h,84px)-var(--m-safe-top,12px)-72px)]',
drawer: 'h-full shadow-[0_0_60px_-10px_rgba(0,0,0,.5)]',
card: '[--m-sheet-r:28px] max-h-full shadow-[0_30px_80px_-24px_rgba(0,0,0,.6)]',
bottom:
'[--m-sheet-r:26px] max-h-full shadow-[0_-8px_40px_-14px_rgba(0,0,0,.45)] wide:[--m-sheet-r:28px] wide:shadow-[0_30px_80px_-24px_rgba(0,0,0,.6)]',
drawer: '[--m-sheet-r:0px] h-full shadow-[0_0_60px_-10px_rgba(0,0,0,.5)]',
}
// Deliberate normalisation: the demo borders a few opaque sheets (journey
// create/entry/settings, drawer) with --rowbr instead of --shbr; both hairlines
// are near-identical on the solid sheet surface, so every material keeps one
// border token here.
const MATERIAL: Record<MSheetMaterial, string> = {
glass:
'bg-[color:var(--m-sheet)] backdrop-blur-[40px] backdrop-saturate-[1.8] border border-[color:var(--m-shbr)]',
'bar-glass':
'bg-[color:var(--m-glass)] backdrop-blur-[30px] backdrop-saturate-[1.8] border border-[color:var(--m-gbr)]',
opaque: 'bg-[color:var(--m-sheetop)] border border-[color:var(--m-shbr)]',
}
// The full-height form on a short window: square at the foot, where it meets
// the edge of the screen, and no border there.
const SHORT_FORM_SHAPE = 'm-form-sheet short:flex-1 short:border-b-0 short:[--m-sheet-r:28px_28px_0_0]'
// Every sheet stands on the one opaque surface. A translucent one let the page
// behind read through as sharp ghost text: the frosting it counted on is
// dropped whenever an ancestor animates its opacity (the scrim fading in),
// which WebKit and Chromium both do, so the fields floated over the list behind.
const SURFACE = 'bg-[color:var(--m-sheetop)] border border-[color:var(--m-shbr)]'
// The docked sheet rises from below the screen edge like a native sheet; the
// card pops in place like an alert; the drawer slides from the side.
@@ -81,12 +174,14 @@ export default function MSheet({
open,
onClose,
variant = 'card',
material,
dimTransparent = false,
ariaLabel,
className = '',
children,
discardGuard,
wideWidth,
wideAnchor,
form = false,
}: MSheetProps) {
const { t } = useTranslation()
const { asking, markStart, requestClose, wouldAsk, keepEditing, discard, reset } = useDiscardGuard(discardGuard, onClose)
@@ -170,66 +265,63 @@ export default function MSheet({
const exitClass = EXIT[variant]
const animation = open ? (entered ? '' : enterClass) : flung ? '' : exitClass
// One material for every sheet, docked or floating, so they all read the same.
const resolvedMaterial = material ?? 'glass'
return createPortal(
// m-root on the overlay so the --m-* tokens resolve even when the portal
// has to fall back to document.body (sheet mounted in the same commit as
// the shell).
// A transparent scrim skips the overlay's opacity animation: an animating
// opacity<1 ancestor becomes a backdrop-root that cancels the sheet's own
// backdrop-blur (the "liquid glass" frost). The sheet still slides via m-sheet-in.
<div
className={`m-root fixed inset-0 z-[60] ${dimTransparent ? 'bg-transparent' : `bg-[color:var(--m-dim)] ${open ? 'm-fade-in' : 'm-fade-out'}`}`}
className={`m-root fixed inset-0 z-[60] ${dimTransparent ? 'bg-transparent short:bg-[color:var(--m-dim)]' : 'bg-[color:var(--m-dim)]'} ${open ? 'm-fade-in' : 'm-fade-out'}`}
role="presentation"
onClick={(e) => {
if (e.target === e.currentTarget) requestClose()
}}
>
<div className={POSITION[variant]}>
<div
ref={panelRef}
role="dialog"
aria-modal="true"
aria-label={ariaLabel}
data-m-sheet={open ? 'open' : 'closing'}
tabIndex={-1}
className={`relative flex flex-col overflow-hidden outline-none text-m-ink ${SHAPE[variant]} ${MATERIAL[resolvedMaterial]} ${animation} ${className}`}
onClick={(e) => e.stopPropagation()}
onKeyDown={handleKeyDown}
onKeyDownCapture={markStart}
onPointerDownCapture={markStart}
onAnimationEnd={(e) => {
if (e.target === e.currentTarget && open) setEntered(true)
}}
>
{variant === 'bottom' && (
// Invisible grab strip along the top edge — the design shows no
// handle, the panel starts directly with its content. The content
// pulls the sheet too, once it is scrolled to its top.
<div
className="absolute inset-x-0 top-0 z-[1] h-6 cursor-grab touch-none active:cursor-grabbing"
{...sheetDrag.strip}
/>
)}
{children}
{asking && (
<div className="absolute inset-0 z-10 grid place-items-center bg-[color:var(--m-dim)] p-5 backdrop-blur-[2px]">
<div role="alertdialog" aria-label={t('common.unsavedTitle')} className="w-full max-w-[320px] rounded-[22px] border border-[color:var(--m-shbr)] bg-[color:var(--m-sheetop)] p-5">
<div className="text-subtitle font-semibold text-m-ink">{t('common.unsavedTitle')}</div>
<p className="m-0 mt-1.5 text-body text-m-muted">{t('common.unsavedMessage')}</p>
<div className="mt-4 flex justify-end gap-2">
<button type="button" autoFocus onClick={keepEditing} className="rounded-full border border-[color:var(--m-shbr)] px-4 py-2 text-body font-semibold text-m-ink">
{t('common.keepEditing')}
</button>
<button type="button" onClick={discard} className={DISCARD_BUTTON}>
{t('common.discard')}
</button>
<div className={regionClass(variant, wideAnchor, form)}>
<div className={boxClass(variant, wideWidth, form)}>
<PopoverArrow variant={variant} anchor={wideAnchor} />
<div
ref={panelRef}
role="dialog"
aria-modal="true"
aria-label={ariaLabel}
data-m-sheet={open ? 'open' : 'closing'}
tabIndex={-1}
className={`relative flex min-h-0 flex-col overflow-hidden rounded-[var(--m-sheet-r)] text-m-ink outline-none ${SHAPE[variant]}${form ? ` ${SHORT_FORM_SHAPE}` : ''} ${SURFACE} ${animation} ${className}`}
onClick={(e) => e.stopPropagation()}
onKeyDown={handleKeyDown}
onKeyDownCapture={markStart}
onPointerDownCapture={markStart}
onAnimationEnd={(e) => {
if (e.target === e.currentTarget && open) setEntered(true)
}}
>
{variant === 'bottom' && (
// Invisible grab strip along the top edge — the design shows no
// handle, the panel starts directly with its content. The content
// pulls the sheet too, once it is scrolled to its top.
<div
className="absolute inset-x-0 top-0 z-[1] h-6 cursor-grab touch-none active:cursor-grabbing"
{...sheetDrag.strip}
/>
)}
{children}
{asking && (
<div className="absolute inset-0 z-10 grid place-items-center bg-[color:var(--m-dim)] p-5 backdrop-blur-[2px]">
<div role="alertdialog" aria-label={t('common.unsavedTitle')} className="w-full max-w-[320px] rounded-[22px] border border-[color:var(--m-shbr)] bg-[color:var(--m-sheetop)] p-5">
<div className="text-subtitle font-semibold text-m-ink">{t('common.unsavedTitle')}</div>
<p className="m-0 mt-1.5 text-body text-m-muted">{t('common.unsavedMessage')}</p>
<div className="mt-4 flex justify-end gap-2">
<button type="button" autoFocus onClick={keepEditing} className="rounded-full border border-[color:var(--m-shbr)] px-4 py-2 text-body font-semibold text-m-ink">
{t('common.keepEditing')}
</button>
<button type="button" onClick={discard} className={DISCARD_BUTTON}>
{t('common.discard')}
</button>
</div>
</div>
</div>
</div>
)}
)}
</div>
</div>
</div>
</div>,
+1 -1
View File
@@ -50,7 +50,7 @@ export default function MToastHost() {
if (toasts.length === 0) return null
return (
<div className="pointer-events-none fixed inset-x-0 bottom-[calc(var(--bottom-nav-h,84px)+16px)] z-[90] flex flex-col-reverse items-center gap-2 px-6">
<div className="m-span-x pointer-events-none fixed bottom-[calc(var(--bottom-nav-h,84px)+16px)] z-[90] flex flex-col-reverse items-center gap-2 px-6">
{toasts.map((toast) => (
<MToastPill key={toast.id} toast={toast} onDismiss={dismissToast} />
))}
@@ -0,0 +1,44 @@
import type { LucideIcon } from 'lucide-react';
import { useEffect, useLayoutEffect, useRef } from 'react';
import { create } from 'zustand';
/**
* A screen's own action at the head of the navigation rail, in the place the
* "+" holds elsewhere: the vacation calendar's switch between the year and the
* editor. On the phone the same screen draws it into the dock's centre slot.
*/
export interface RailAction {
label: string;
icon: LucideIcon;
/** On: drawn as a ring rather than filled (the editor is open). */
pressed?: boolean;
run: () => void;
}
const useRailActionStore = create<{ action: RailAction | null }>(() => ({ action: null }));
/** The action the screen on show has put at the head of the rail, if any. */
export function useRailAction(): RailAction | null {
return useRailActionStore((s) => s.action);
}
/**
* Puts the screen's action at the head of the rail while it is mounted; null
* takes it away. Pressing it always runs the newest handler, and the rail only
* redraws when what it shows changes.
*/
export function useScreenRailAction(action: RailAction | null): void {
const run = useRef(action?.run);
useLayoutEffect(() => {
run.current = action?.run;
});
const shown = action !== null;
const label = action?.label ?? '';
const icon = action?.icon;
const pressed = action?.pressed ?? false;
useEffect(() => {
if (!shown || !icon) return;
useRailActionStore.setState({ action: { label, icon, pressed, run: () => run.current?.() } });
return () => useRailActionStore.setState({ action: null });
}, [shown, label, icon, pressed]);
}
@@ -0,0 +1,65 @@
import { useEffect, useState } from 'react';
export interface EdgeFade {
/** Content is scrolled past the leading (or top) edge. */
start: boolean;
/** More content waits beyond the trailing (or bottom) edge. */
end: boolean;
}
/** Which way a scroller runs: a strip of chips sideways, a list or a card down. */
export type FadeAxis = 'x' | 'y';
const NONE: EdgeFade = { start: false, end: false };
/**
* Which ends of a scroller have more behind them, so it can fade out there
* (m-fade-s / m-fade-e, m-fade-t / m-fade-b in mobile.css) instead of cutting a
* chip or a row in half with nothing to say the content goes on. Follows
* scrolling, every change of size (a rotation, a fold, the pane beside the map
* opening) and every change of what it holds (a day switched, a message in).
*/
export function useEdgeFade(el: HTMLElement | null, axis: FadeAxis = 'x'): EdgeFade {
const [fade, setFade] = useState<EdgeFade>(NONE);
useEffect(() => {
if (!el) return;
const measure = () => {
// scrollLeft runs from 0 towards the negative in a right-to-left strip.
const scrolled = Math.abs(axis === 'x' ? el.scrollLeft : el.scrollTop);
const room = axis === 'x' ? el.scrollWidth - el.clientWidth : el.scrollHeight - el.clientHeight;
const next = { start: scrolled > 1, end: room - scrolled > 1 };
setFade((prev) => (prev.start === next.start && prev.end === next.end ? prev : next));
};
measure();
el.addEventListener('scroll', measure, { passive: true });
const resize = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(measure);
resize?.observe(el);
// New content changes what lies past an edge without changing the box itself.
let frame = 0;
const changed =
typeof MutationObserver === 'undefined'
? null
: new MutationObserver(() => {
cancelAnimationFrame(frame);
frame = requestAnimationFrame(measure);
});
changed?.observe(el, { childList: true, subtree: true, characterData: true });
return () => {
el.removeEventListener('scroll', measure);
resize?.disconnect();
changed?.disconnect();
cancelAnimationFrame(frame);
};
}, [el, axis]);
return fade;
}
/** The mask classes for a fade state: sideways by default, `y` for a scroller that runs down. */
export function edgeFadeClass(fade: EdgeFade, axis: FadeAxis = 'x'): string {
const [both, start, end] =
axis === 'x' ? ['m-fade-se', 'm-fade-s', 'm-fade-e'] : ['m-fade-tb', 'm-fade-t', 'm-fade-b'];
if (fade.start && fade.end) return both;
if (fade.start) return start;
if (fade.end) return end;
return '';
}
@@ -0,0 +1,91 @@
import { useEffect } from 'react';
import { useNativeCapabilities } from '../../native/capabilities';
import { isNativeApp, isNativeIos } from '../../native/platform';
import { reportNativeFailure } from '../../native/reportFailure';
import { TrekShell } from '../../native/trekShell';
import { hasOpenOverlay, useHasOpenOverlay } from '../overlayStack';
import { findShortcut, toNativeCommand, useShortcutRegistry } from './shortcuts';
/**
* Whether a modal is in front of the page: a sheet (it registers on the overlay
* stack) or any other dialog that marks itself aria-modal. The page's shortcuts
* wait behind it. ⌘[ while a reservation was being edited used to leave the trip
* and drop the form without the "discard changes?" a tap on the scrim asks.
*/
function modalOpen(): boolean {
if (hasOpenOverlay()) return true;
// A dialog kept mounted while closed (the assistant's) is hidden, not gone.
return Array.from(document.querySelectorAll('[aria-modal="true"]')).some((el) => !el.closest('[hidden]'));
}
/**
* Answers the touch shell's keyboard shortcuts (useShortcuts), once for every
* screen, in the app only. A browser keeps its own keys: there the touch shell
* is a phone layout, and Ctrl+1 is a tab switch and Alt+← is Back.
*
* The binary takes the shortcuts over as native key commands when it can
* (feature keyCommands): on iOS they show in the overlay a held ⌘ shows and in
* the iPadOS menu bar, on Android in the system's keyboard shortcuts list. The
* page then leaves those keys alone, so nothing runs twice. Only a shell that
* has the feature reports it, so the feature alone decides, whatever the
* platform. An older binary without it leaves every key to the page.
*
* While a sheet or dialog is open none of them run: the page listens to
* nothing and the app's list is emptied, so a shortcut can neither navigate
* away under an unsaved form nor open a second editor over it.
*/
export default function MKeyboardHost() {
const lists = useShortcutRegistry((s) => s.lists);
const { caps } = useNativeCapabilities();
const app = isNativeApp();
const native = app && caps.features.includes('keyCommands');
const blocked = useHasOpenOverlay();
useEffect(() => {
if (!app) return;
const apple = isNativeIos();
const onKey = (e: KeyboardEvent) => {
if (e.defaultPrevented || e.isComposing || modalOpen()) return;
const all = lists.map((l) => l.shortcuts);
const hit = findShortcut(e, native ? all.map((l) => l.filter((s) => !s.mod && !s.ctrl)) : all, apple);
if (!hit) return;
e.preventDefault();
hit.run();
};
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [lists, native, app]);
useEffect(() => {
if (!native) return;
const commands = blocked
? []
: lists.flatMap((l) => l.shortcuts.filter((s) => s.mod || s.ctrl).map((s) => toNativeCommand(l.owner, s)));
TrekShell.setKeyCommands({ commands }).catch(reportNativeFailure);
}, [lists, native, blocked]);
useEffect(() => {
if (!native) return;
let cancelled = false;
let remove: (() => Promise<void>) | null = null;
TrekShell.addListener('keyCommand', ({ id }) => {
// A command can still arrive from a list sent before a dialog opened.
if (modalOpen()) return;
const at = id.indexOf(':');
const owner = id.slice(0, at);
const list = useShortcutRegistry.getState().lists.find((l) => l.owner === owner);
list?.shortcuts.find((s) => s.id === id.slice(at + 1))?.run();
})
.then((handle) => {
if (cancelled) void handle.remove();
else remove = handle.remove;
})
.catch(reportNativeFailure);
return () => {
cancelled = true;
if (remove) void remove();
};
}, [native]);
return null;
}
@@ -0,0 +1,39 @@
import { useLocation, useMatch, useNavigate } from 'react-router';
import { useNavItems } from '../../components/Layout/navItems';
import { useCreateAction } from '../../components/Layout/useCreateAction';
import { useTranslation } from '../../i18n';
import type { Shortcut } from './shortcuts';
import { useShortcuts } from './useShortcuts';
/**
* The app-wide shortcuts of the touch shell: ⌘1 to ⌘9 for the sections in the
* order the rail shows them, ⌘N for what the "+" creates here, ⌘, for
* settings. Inside a trip the numbers and ⌘N belong to the trip (MTripShell):
* its tabs change without a new URL, and this component renders only on one,
* so a ⌘N of its own would still create what the tab the trip opened on does.
*/
export default function MShellShortcuts() {
const { t } = useTranslation();
const navigate = useNavigate();
const location = useLocation();
const inTrip = useMatch('/trips/:id');
const navItems = useNavItems();
const create = useCreateAction(true);
const noCreate = ['/settings', '/admin', '/vacay'].some((p) => location.pathname.startsWith(p));
const shortcuts: Shortcut[] = [
...(inTrip
? []
: navItems.slice(0, 9).map((item, i) => ({
id: `go-${item.id}`,
title: item.label,
key: String(i + 1),
mod: true,
run: () => void navigate(item.to),
}))),
...(noCreate || inTrip ? [] : [{ id: 'create', title: create.label, key: 'n', mod: true, run: create.run }]),
{ id: 'settings', title: t('nav.bottomSettings'), key: ',', mod: true, run: () => void navigate('/settings') },
];
useShortcuts('shell', shortcuts);
return null;
}
+115
View File
@@ -0,0 +1,115 @@
import { create } from 'zustand';
import type { NativeKeyCommand } from '../../native/trekShell';
/**
* A hardware keyboard shortcut of the touch shell. The shell is what an iPad
* shows, and an iPad often has a keyboard attached: there the app should
* answer ⌘1 and ⌘N the way any iPad app does.
*/
export interface Shortcut {
/** Stable id, also what the app's native key command reports back. */
id: string;
/** Its name, for the overlay a held ⌘ shows and the iPadOS menu bar. */
title: string;
/**
* KeyboardEvent.key, lower case for letters ('n', '1', ',', 'ArrowLeft'). A
* digit is matched by the key's place as well (KeyboardEvent.code), since a
* French keyboard types '&' on the 1 key without Shift.
*/
key: string;
/** ⌘ on an iPad (and any Apple keyboard), Ctrl on Android. */
mod?: boolean;
shift?: boolean;
alt?: boolean;
/** The physical Control key on top of ⌘ (⌃⌘S). */
ctrl?: boolean;
run: () => void;
}
type KeyEventLike = Pick<KeyboardEvent, 'key' | 'metaKey' | 'ctrlKey' | 'shiftKey' | 'altKey'> &
Partial<Pick<KeyboardEvent, 'code'>>;
/**
* Whether an event is this shortcut. The command key is the platform's own: ⌘
* on Apple keyboards, Ctrl elsewhere, never either one. Taking both made Ctrl+N
* in a Mac text field (the "next line" binding) run ⌘N.
*/
export function matchesShortcut(e: KeyEventLike, s: Shortcut, apple = true): boolean {
const key = e.key.length === 1 ? e.key.toLowerCase() : e.key;
const digitAt = /^[0-9]$/.test(s.key) && e.code === `Digit${s.key}`;
if (key !== s.key && !digitAt) return false;
if (s.ctrl) {
if (!e.ctrlKey || !e.metaKey) return false;
} else {
const command = apple ? e.metaKey : e.ctrlKey;
const other = apple ? e.ctrlKey : e.metaKey;
if (!!s.mod !== command || other) return false;
}
return !!s.shift === e.shiftKey && !!s.alt === e.altKey;
}
/** Typing in a field keeps its keys: a shortcut without ⌘ never fires there. */
export function isTypingTarget(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) return false;
if (target.isContentEditable) return true;
const tag = target.tagName;
return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT';
}
/** The first shortcut of the lists (later ones first) that the event is. */
export function findShortcut(
e: KeyEventLike & Pick<KeyboardEvent, 'target'>,
lists: Shortcut[][],
apple = true
): Shortcut | null {
for (let i = lists.length - 1; i >= 0; i--) {
for (const s of lists[i]) {
if (!s.mod && !s.ctrl && isTypingTarget(e.target)) continue;
if (matchesShortcut(e, s, apple)) return s;
}
}
return null;
}
interface ShortcutRegistry {
/** The lists that are live, by owner, in the order they were registered. */
lists: { owner: string; shortcuts: Shortcut[] }[];
set: (owner: string, shortcuts: Shortcut[]) => void;
remove: (owner: string) => void;
}
/**
* Every screen's live shortcuts in one place, so a single keydown listener and
* the app's native key commands (NativeKeyCommands) answer for all of them, a
* trip's own shortcuts ahead of the shell's.
*/
export const useShortcutRegistry = create<ShortcutRegistry>((set) => ({
lists: [],
set: (owner, shortcuts) =>
set((state) => {
const at = state.lists.findIndex((l) => l.owner === owner);
if (at === -1) return { lists: [...state.lists, { owner, shortcuts }] };
const lists = state.lists.slice();
lists[at] = { owner, shortcuts };
return { lists };
}),
remove: (owner) => set((state) => ({ lists: state.lists.filter((l) => l.owner !== owner) })),
}));
const NATIVE_KEYS: Record<string, string> = {
ArrowLeft: 'left',
ArrowRight: 'right',
ArrowUp: 'up',
ArrowDown: 'down',
Escape: 'escape',
};
/** A shortcut as the app's UIKeyCommand, under an id unique across screens. */
export function toNativeCommand(owner: string, s: Shortcut): NativeKeyCommand {
const modifiers: string[] = [];
if (s.mod || s.ctrl) modifiers.push('command');
if (s.ctrl) modifiers.push('control');
if (s.shift) modifiers.push('shift');
if (s.alt) modifiers.push('option');
return { id: `${owner}:${s.id}`, title: s.title, input: NATIVE_KEYS[s.key] ?? s.key, modifiers };
}
@@ -0,0 +1,27 @@
import { useEffect, useRef } from 'react';
import { useShortcutRegistry, type Shortcut } from './shortcuts';
/**
* Registers a screen's keyboard shortcuts while it is mounted (MKeyboardHost
* answers them). The list may be rebuilt on every render: the registry only
* changes when a shortcut's id, name or keys do, and pressing one always runs
* the newest handler.
*/
export function useShortcuts(owner: string, shortcuts: Shortcut[]): void {
const latest = useRef(shortcuts);
useEffect(() => {
latest.current = shortcuts;
});
const signature = shortcuts.map((s) => [s.id, s.title, s.key, s.mod, s.shift, s.alt, s.ctrl].join('|')).join('\n');
const setList = useShortcutRegistry((s) => s.set);
const remove = useShortcutRegistry((s) => s.remove);
// Re-registered when the signature changes, never for a new handler alone:
// each registered shortcut runs whatever handler is newest when pressed.
useEffect(() => {
setList(
owner,
latest.current.map((s) => ({ ...s, run: () => latest.current.find((x) => x.id === s.id)?.run() }))
);
}, [owner, signature, setList]);
useEffect(() => () => remove(owner), [owner, remove]);
}
+307
View File
@@ -10,6 +10,9 @@
--m-bg: #F3F2EF;
--m-scr: radial-gradient(120% 80% at 50% -10%, #FBFAF7 0%, #F1EFEA 55%, #E9E7E1 100%);
--m-glass: rgba(255,255,255,.6);
/* The dock's glass: denser than --m-glass, so a photo scrolling under it
* neither turns it muddy nor washes out its icons. */
--m-dock: rgba(255,255,255,.8);
--m-gbr: rgba(255,255,255,.75);
--m-card: rgba(255,255,255,.55);
--m-cbr: rgba(255,255,255,.75);
@@ -31,6 +34,9 @@
--m-mkr: #101013;
--m-mkfg: #fff;
--m-trackoff: rgba(16,16,19,.14);
/* The edge of an empty checkbox: at least 3:1 against a row, so an open item
* shows its box rather than reading as plain text. */
--m-checkbr: rgba(16,16,19,.55);
/* Strength of a dayTintProvider plugin's colour on an inactive day chip. A notch
* above the desktop badge: the chip has no neutral base under it, so a softer tint
@@ -53,6 +59,22 @@
* real devices derive them from the safe-area insets. */
--bottom-nav-h: calc(84px + env(safe-area-inset-bottom, 0px));
--m-safe-top: calc(env(safe-area-inset-top, 0px) + 12px);
/* The navigation rail's share of the leading edge (wide windows only), what
* every full-width fixed bar and the page column start after. */
--m-rail-w: 0px;
}
/* Wide size class (hooks/sizeClass.ts, written to the shell's root as
* data-size): the rail on the leading edge takes the dock's place, so nothing
* docks at the bottom any more and content only clears the home indicator.
* Asked from <html>, so a sheet's own .m-root (portalled, perhaps to body)
* reads the same values as the shell. */
html:has(.m-shell[data-size='wide'], .m-shell[data-size='split']) .m-root {
--bottom-nav-h: env(safe-area-inset-bottom, 0px);
--m-rail-w: calc(env(safe-area-inset-left, 0px) + 100px);
}
html[dir='rtl']:has(.m-shell[data-size='wide'], .m-shell[data-size='split']) .m-root {
--m-rail-w: calc(env(safe-area-inset-right, 0px) + 100px);
}
.dark .m-root {
@@ -62,6 +84,7 @@
--m-bg: #0A0A0C;
--m-scr: radial-gradient(120% 80% at 50% -10%, #17171C 0%, #0C0C0F 55%, #08080A 100%);
--m-glass: rgba(26,26,31,.6);
--m-dock: rgba(26,26,31,.82);
--m-gbr: rgba(255,255,255,.12);
--m-card: rgba(24,24,29,.55);
--m-cbr: rgba(255,255,255,.1);
@@ -83,6 +106,7 @@
--m-mkr: #F5F5F7;
--m-mkfg: #101013;
--m-trackoff: rgba(255,255,255,.14);
--m-checkbr: rgba(255,255,255,.4);
--day-tint-chip: 28%;
}
@@ -142,7 +166,27 @@
from { opacity: 1; transform: translateY(0); }
to { opacity: 0; transform: translateY(6px); }
}
/* A panel at the trailing edge (the assistant on a wide window) slides in from
that edge, the way Slide Over does. */
@keyframes m-panel-in {
from { opacity: 0; transform: translateX(28px); }
to { opacity: 1; transform: translateX(0); }
}
@keyframes m-panel-in-rtl {
from { opacity: 0; transform: translateX(-28px); }
to { opacity: 1; transform: translateX(0); }
}
/* A form sheet on a wide window sits mid-screen, so it rises from the bottom
edge of the window rather than from just below its own resting place. */
@keyframes m-sheet-up-wide {
from { transform: translateY(calc(50dvh + 50%)); }
to { transform: translateY(0); }
}
@keyframes m-sheet-down-wide {
from { transform: translateY(0); }
to { transform: translateY(calc(50dvh + 50%)); }
}
.m-sheet-in { animation: m-sheet-in 260ms var(--ease-out-quint) both; }
.m-sheet-out { animation: m-sheet-out 220ms var(--ease-drawer) both; }
.m-sheet-up { animation: m-sheet-up 420ms var(--ease-drawer) both; }
@@ -154,6 +198,15 @@
.m-pop-in { animation: m-pop-in 220ms var(--ease-out-quint) both; }
.m-toast-in { animation: m-toast-in 260ms var(--ease-out-quint) both; }
.m-toast-out { animation: m-toast-out 220ms ease both; }
@media (prefers-reduced-motion: no-preference) {
html:has(.m-shell[data-size='wide'], .m-shell[data-size='split']) .m-sheet-up { animation-name: m-sheet-up-wide; }
html:has(.m-shell[data-size='wide'], .m-shell[data-size='split']) .m-sheet-down { animation-name: m-sheet-down-wide; }
}
html:has(.m-shell[data-size='wide'], .m-shell[data-size='split']) .m-panel-in { animation: m-panel-in 320ms var(--ease-out-quint) both; }
html[dir='rtl']:has(.m-shell[data-size='wide'], .m-shell[data-size='split']) .m-panel-in { animation-name: m-panel-in-rtl; }
@media (prefers-reduced-motion: reduce) {
html:has(.m-shell[data-size='wide'], .m-shell[data-size='split']) .m-panel-in { animation: m-fade-in 120ms ease-out both; }
}
/* Reduced motion: gentler, not zero (same rule the desktop keyframes follow). */
@media (prefers-reduced-motion: reduce) {
@@ -499,3 +552,257 @@
.m-root [data-field-trigger='pad'] {
padding-inline: 14px !important;
}
/* ── The page column on a wide window ───────────────────────────────────────
Fixed bars span the page, not the window: they start after the navigation
rail (--m-rail-w, 0 on a phone, so these are the phone's insets there). */
.m-bar-x {
inset-inline-start: calc(var(--m-rail-w, 0px) + 16px);
inset-inline-end: 16px;
}
.m-span-x {
inset-inline-start: var(--m-rail-w, 0px);
inset-inline-end: 0;
}
/* A screen whose background is a map reaches under the rail: the map runs to
the window's edge and the rail floats over it like the top controls do. Its
own bars then keep to the page column with m-bar-x / m-span-x. */
.m-bleed {
margin-inline-start: calc(var(--m-rail-w, 0px) * -1);
}
/* The trip's pane beside the map (split size class, MTripShell): a third of
the window, within what a list and its rows need. On a foldable that reports
its fold (two horizontal viewport segments), the pane takes the first half
to the fold exactly, so no row runs across the crease and the map has the
other half to itself. */
.m-trip {
--trip-pane-open: clamp(360px, 38vw, 460px);
}
@media (horizontal-viewport-segments: 2) {
.m-trip {
--trip-pane-open: calc(env(viewport-segment-width 0 0, 50vw) - var(--m-rail-w, 0px));
}
}
/* The leading pane of a two-pane screen (a tab's summary, a list beside its
detail): about a third of the page, within what its cards need. On a
foldable that reports its fold it ends at the fold, like the trip's pane. */
.m-root {
--m-pane-aside-w: clamp(320px, 34vw, 420px);
--m-pane-trail-w: clamp(340px, 36vw, 440px);
}
@media (horizontal-viewport-segments: 2) {
.m-root {
--m-pane-aside-w: calc(env(viewport-segment-width 0 0, 50vw) - var(--m-rail-w, 0px));
--m-pane-trail-w: env(viewport-segment-width 1 0, 50vw);
}
}
/* The Android app reports a foldable's fold itself (native/windowFold.ts):
MobileShell marks a vertical fold that separates the window with data-fold
and its place in --m-fold-start/--m-fold-end, and the panes meet there. The
leading side follows the reading direction, the fold's place does not. */
.m-root[data-fold='vertical'] {
--m-pane-aside-w: calc(var(--m-fold-start) - var(--m-rail-w, 0px));
--m-pane-trail-w: calc(100vw - var(--m-fold-end));
}
.m-root[data-fold='vertical'] .m-trip {
--trip-pane-open: calc(var(--m-fold-start) - var(--m-rail-w, 0px));
}
:root[dir='rtl'] .m-root[data-fold='vertical'] {
--m-pane-aside-w: calc(100vw - var(--m-fold-end) - var(--m-rail-w, 0px));
--m-pane-trail-w: var(--m-fold-start);
}
:root[dir='rtl'] .m-root[data-fold='vertical'] .m-trip {
--trip-pane-open: calc(100vw - var(--m-fold-end) - var(--m-rail-w, 0px));
}
.m-pane-aside {
width: var(--m-pane-aside-w);
}
/* The trailing pane: what accompanies the page rather than leads it, like the
trip chat beside its notes and polls. */
.m-pane-trail {
width: var(--m-pane-trail-w);
}
/* The phone rules of index.css, for the app. It shows this shell at every
size, an iPad's included (mobile/useIsPhone.ts), where their media query does
not match; the shell's root carries .m-shell, so they follow it there: the
document is the scroller, the shell paints the background, no zoom buttons
on a touch map, no scrollbars. */
html:has(.m-shell) {
overflow: visible;
overscroll-behavior: none;
}
html:has(.m-shell) body {
height: auto;
overflow: visible;
overflow-x: clip;
background-color: transparent;
}
/* The few routes without a phone screen of their own (a plugin's page, help,
a trip's files) still render through PageShell. Inside the touch shell its
desktop navbar, shown from 768px up, would stand over the rail and repeat the
navigation, and --nav-h would keep 64px of room for it: the shell has its own
navigation, so neither is there. */
html:has(.m-shell) {
--nav-h: 0px;
}
html:has(.m-shell) [data-desktop-navbar] {
display: none;
}
html:has(.m-shell) .leaflet-control-zoom {
display: none !important;
}
html:has(.m-shell) * {
scrollbar-width: none;
}
html:has(.m-shell) ::-webkit-scrollbar {
width: 0;
height: 0;
}
/* While the trip's map runs under the rail (MTripShell, --trip-map-inset), the
controls both engines draw in its corners step clear of the rail. A margin on
the leading side moves only the corner next to the rail, in either reading
direction: the corner on the far side is anchored there and does not move. */
.m-trip .leaflet-control-container > div,
.m-trip .maplibregl-control-container > div,
.m-trip .mapboxgl-control-container > div {
margin-inline-start: var(--trip-map-inset, 0px);
}
/* The strip under a floating dock (components/MDockScrim.tsx): solid across
the gap below the dock, clear from about its middle up. */
.m-dock-scrim {
-webkit-mask-image: linear-gradient(to top, #000 calc(env(safe-area-inset-bottom, 0px) + 12px), transparent calc(env(safe-area-inset-bottom, 0px) + 40px));
mask-image: linear-gradient(to top, #000 calc(env(safe-area-inset-bottom, 0px) + 12px), transparent calc(env(safe-area-inset-bottom, 0px) + 40px));
}
/* A horizontal strip with more behind an edge fades out there
(components/useEdgeFade.ts), so a cut chip reads as "the row goes on". */
.m-fade-s,
.m-fade-e,
.m-fade-se {
/* A strip with a chevron over its faded end fades wider (--m-fade-reach). */
--m-fade-w: var(--m-fade-reach, 28px);
-webkit-mask-image: var(--m-fade-mask);
mask-image: var(--m-fade-mask);
}
.m-fade-s {
--m-fade-mask: linear-gradient(var(--m-fade-dir, 90deg), transparent 0, #000 var(--m-fade-w));
}
.m-fade-e {
--m-fade-mask: linear-gradient(var(--m-fade-dir, 90deg), #000 calc(100% - var(--m-fade-w)), transparent 100%);
}
.m-fade-se {
--m-fade-mask: linear-gradient(var(--m-fade-dir, 90deg), transparent 0, #000 var(--m-fade-w), #000 calc(100% - var(--m-fade-w)), transparent 100%);
}
:root[dir='rtl'] .m-fade-s,
:root[dir='rtl'] .m-fade-e,
:root[dir='rtl'] .m-fade-se {
--m-fade-dir: 270deg;
}
/* The same for a vertical scroller, a form sheet's body above all: it fades
out at the top once scrolled and at the foot while more waits below, so a
field cut by the footer reads as "the form goes on". Never mirrored. */
.m-fade-t,
.m-fade-b,
.m-fade-tb {
--m-fade-h: var(--m-fade-reach-y, 24px);
-webkit-mask-image: var(--m-fade-vmask);
mask-image: var(--m-fade-vmask);
}
.m-fade-t {
--m-fade-vmask: linear-gradient(to bottom, transparent 0, #000 var(--m-fade-h));
}
.m-fade-b {
--m-fade-vmask: linear-gradient(to bottom, #000 calc(100% - var(--m-fade-h)), transparent 100%);
}
.m-fade-tb {
--m-fade-vmask: linear-gradient(to bottom, transparent 0, #000 var(--m-fade-h), #000 calc(100% - var(--m-fade-h)), transparent 100%);
}
/* A hint longer than its field ends in an ellipsis instead of being cut off
mid-word at the field's edge ("Код аэропорта или город (нап"). */
.m-root input:placeholder-shown {
text-overflow: ellipsis;
}
/* ── Sheets ─────────────────────────────────────────────────────────────────
A sheet takes the focus itself when it opens (modalFocus.ts), so a screen
reader starts inside it. The app-wide focus ring (index.css) is meant for
controls: on the sheet it drew a 2px frame round the whole dialog and
squared its corners off to 4px. */
.m-root [data-m-sheet]:focus-visible {
outline: none;
border-radius: var(--m-sheet-r);
}
/* A form sheet (MSheet `form`) on a window as short as a phone on its side
fills the height, and its actions move up beside the title the way an
iPhone form carries Cancel and Save in its bar, so the fields get every
line in between instead of a slot 140px tall. Cancel then does what the
header's close button did, so that one goes. */
@media (max-height: 500px) {
/* Row 1: the title and the actions. Row 2: whatever a form keeps fixed
under its title (the transport's manual/automated switch). Row 3: the
fields, which take the rest. Doubled class: the panel's own `flex`
utility must not win the display back. */
.m-form-sheet.m-form-sheet {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
grid-template-rows: auto auto minmax(0, 1fr);
align-items: center;
}
.m-form-sheet > * {
grid-column: 1 / -1;
}
.m-form-sheet > .m-fs-head {
grid-area: 1 / 1;
}
.m-form-sheet > .m-fs-foot {
grid-area: 1 / 2;
border-top: 0;
padding-block: 16px 8px;
padding-inline: 0 18px;
}
.m-form-sheet > .m-fs-body {
grid-row: 3;
align-self: stretch;
padding-bottom: calc(env(safe-area-inset-bottom, 0px) + 16px);
}
.m-form-sheet:has(> .m-fs-foot) > .m-fs-head .m-fs-close {
display: none;
}
}
/* A segmented control in the trip's top bar that does not fit between the back
button and the trailing button draws its segments tighter first
(MTripShellParts GlassSegment), and scrolls only where that is not enough. */
.m-seg[data-tight='true'] > * {
padding-inline: 10px;
}
/* A one-line Markdown caption (MarkdownText clamp: an address, a note in a
row) ends in an ellipsis. The clamp sits on the wrapper, but the text is in
the paragraph inside it, so the line was cut off mid-letter at the row's
edge or under its buttons with nothing to say it went on. */
.m-root .collab-note-md.truncate > * {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* The trip's shared links in the touch shell (CollabLinks in MCollabTab). Their
edit, pin and delete buttons are always out on a touch screen, so a link in
one of two columns kept a sliver for its title ("JR seat / reservations") and
cut its host. One column, until the board has room for two links side by
side with their titles on one line. */
.m-root .collab-link-grid__cells {
grid-template-columns: minmax(0, 1fr);
}
@container (min-width: 720px) {
.m-root .collab-link-grid__cells {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
+10 -1
View File
@@ -1,6 +1,6 @@
import { renderHook } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { _resetOverlayStack, closeTopOverlay, hasOpenOverlay, useOverlay } from './overlayStack';
import { _resetOverlayStack, closeTopOverlay, hasOpenOverlay, useHasOpenOverlay, useOverlay } from './overlayStack';
beforeEach(() => _resetOverlayStack());
@@ -33,4 +33,13 @@ describe('overlayStack', () => {
expect(hasOpenOverlay()).toBe(false);
expect(closeTopOverlay()).toBe(false);
});
it('tells a component when an overlay opens and when the last one closes', () => {
const watch = renderHook(() => useHasOpenOverlay());
expect(watch.result.current).toBe(false);
const sheet = renderHook(({ open }) => useOverlay(open, () => {}), { initialProps: { open: true } });
expect(watch.result.current).toBe(true);
sheet.rerender({ open: false });
expect(watch.result.current).toBe(false);
});
});
+19 -1
View File
@@ -1,4 +1,4 @@
import { useEffect, useLayoutEffect, useRef } from 'react';
import { useEffect, useLayoutEffect, useRef, useSyncExternalStore } from 'react';
/**
* The overlays open on screen, newest last, so a way back closes the one on
@@ -9,6 +9,16 @@ import { useEffect, useLayoutEffect, useRef } from 'react';
type Close = () => void;
const stack: { id: number; close: () => void }[] = [];
let nextId = 1;
const listeners = new Set<() => void>();
function changed(): void {
for (const listener of listeners) listener();
}
function subscribe(listener: () => void): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
}
/** Closes the overlay on top. False when none is open, so the caller goes back instead. */
export function closeTopOverlay(): boolean {
@@ -22,6 +32,11 @@ export function hasOpenOverlay(): boolean {
return stack.length > 0;
}
/** Whether an overlay is open, for a component that changes with it (the keyboard shortcuts). */
export function useHasOpenOverlay(): boolean {
return useSyncExternalStore(subscribe, hasOpenOverlay, () => false);
}
/** Registers an overlay while `open`, with the way it closes. */
export function useOverlay(open: boolean, close: Close): void {
const closeRef = useRef(close);
@@ -32,9 +47,11 @@ export function useOverlay(open: boolean, close: Close): void {
if (!open) return;
const entry = { id: nextId++, close: () => closeRef.current() };
stack.push(entry);
changed();
return () => {
const i = stack.findIndex((e) => e.id === entry.id);
if (i >= 0) stack.splice(i, 1);
changed();
};
}, [open]);
}
@@ -42,4 +59,5 @@ export function useOverlay(open: boolean, close: Close): void {
/** Test helper. */
export function _resetOverlayStack(): void {
stack.length = 0;
changed();
}
+42 -17
View File
@@ -45,6 +45,8 @@ import MAdminStoragePanel from './MAdminStoragePanel'
import MAdminUsersSection from './MAdminUsersSection'
import { MAdminButton } from './MAdminUi'
import { managedAdminTabs } from '../../../managed'
import { useIsSplit } from '../../../hooks/sizeClass'
import MSectionNav, { MSectionRow, SECTION_SCREEN, type MSection } from '../../components/MSectionNav'
// Stat card of the 2×2 grid (design §6.3): 34px icon tile, 19px/800 number,
// small tracking label. Count-up animation matches the desktop stat cards.
@@ -55,9 +57,9 @@ function MAdminStat({ label, value, icon: Icon }: { label: string; value: number
<span className="flex h-[34px] w-[34px] flex-none items-center justify-center rounded-[11px] bg-[color:var(--m-ic)] text-m-ink">
<Icon size={16} strokeWidth={2} />
</span>
<div>
<div className="min-w-0">
<div className="text-[1.1875rem] font-extrabold leading-none text-m-ink">{animated}</div>
<div className="mt-[2px] font-geist text-[0.59375rem] font-bold tracking-[0.06em] text-m-faint">{label}</div>
<div className="mt-[2px] truncate font-geist text-[0.59375rem] font-bold tracking-[0.06em] text-m-faint" title={label}>{label}</div>
</div>
</div>
)
@@ -80,10 +82,13 @@ export default function MAdmin() {
saveDemoBaseline, toggleBagTracking, toggleCollabFeature,
} = admin
const [sectionsOpen, setSectionsOpen] = useState(false)
// With room for two panes the sections stand in a list beside the page, as in
// settings, instead of behind the header's dropdown.
const split = useIsSplit()
// Same sections and gating as the desktop sidebar, ordered and iconed like
// the design's dropdown (§6.1).
const sections: { id: string; label: string; icon: LucideIcon }[] = [
const sections: MSection[] = [
{ id: 'users', label: t('admin.tabs.users'), icon: Users },
{ id: 'defaults', label: t('admin.tabs.defaults'), icon: UserCog },
{ id: 'config', label: t('admin.tabs.config'), icon: SlidersHorizontal },
@@ -107,8 +112,30 @@ export default function MAdmin() {
return (
// Flow screen: scrolls with the document (#1809), so no height and no
// scroll container of its own.
<div className="px-4 pb-[calc(var(--bottom-nav-h,84px)+16px)] pt-[var(--m-safe-top,12px)]">
<div className={SECTION_SCREEN}>
{split && (
<MSectionNav
title={t('admin.title')}
backLabel={t('common.back')}
onBack={() => void navigate('/dashboard')}
sections={sections}
activeId={activeSection.id}
onPick={setActiveTab}
action={
<button
type="button"
onClick={() => setShowCreateUser(true)}
className="flex h-[34px] w-full items-center justify-center gap-[7px] rounded-full bg-m-act px-3 text-[length:calc(12px*var(--fs-scale-body,1))] font-bold text-m-actfg outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--m-act)] focus-visible:ring-offset-2"
>
<UserPlus size={14} strokeWidth={2.2} className="flex-none" />
<span className="truncate">{t('admin.createUser')}</span>
</button>
}
/>
)}
<div className="min-w-0">
{/* Header: back · section switcher · create user */}
{!split && (
<div className="mb-3 flex items-center gap-2">
<button
type="button"
@@ -116,7 +143,7 @@ export default function MAdmin() {
onClick={() => navigate('/dashboard')}
className="flex h-[38px] w-[38px] flex-none items-center justify-center rounded-full border border-[color:var(--m-gbr)] bg-[color:var(--m-sheet)] text-m-ink shadow-[0_5px_12px_-8px_rgba(0,0,0,.18)]"
>
<ChevronLeft size={18} strokeWidth={2.2} />
<ChevronLeft size={18} strokeWidth={2.2} className="rtl:-scale-x-100" />
</button>
<button
type="button"
@@ -138,25 +165,21 @@ export default function MAdmin() {
<UserPlus size={16} strokeWidth={2.2} />
</button>
</div>
)}
{/* Section dropdown */}
{sectionsOpen && (
{sectionsOpen && !split && (
<div className="-mt-[6px] mb-3 rounded-2xl border border-[color:var(--m-rowbr)] bg-[color:var(--m-sheetop)] p-[6px] shadow-[0_16px_40px_-20px_rgba(0,0,0,.4)]">
{sections.map((section) => (
<button
<MSectionRow
key={section.id}
type="button"
onClick={() => {
section={section}
active={section.id === activeSection.id}
onPick={() => {
setActiveTab(section.id)
setSectionsOpen(false)
}}
className={`flex w-full items-center gap-[10px] rounded-[11px] p-[10px] text-start text-[0.8125rem] font-semibold text-m-ink ${
section.id === activeSection.id ? 'bg-[color:var(--m-ic)]' : ''
}`}
>
<section.icon size={15} strokeWidth={2} />
{section.label}
</button>
/>
))}
</div>
)}
@@ -192,7 +215,7 @@ export default function MAdmin() {
{/* Stats grid (§6.3) */}
{stats && (
<div className="mb-3 grid grid-cols-2 gap-[10px]">
<div className="mb-3 grid grid-cols-2 gap-[10px] wide:grid-cols-4">
<MAdminStat label={t('admin.stats.users', { count: stats.totalUsers })} value={stats.totalUsers} icon={Users} />
<MAdminStat label={t('admin.stats.trips', { count: stats.totalTrips })} value={stats.totalTrips} icon={Briefcase} />
<MAdminStat label={t('admin.stats.places', { count: stats.totalPlaces })} value={stats.totalPlaces} icon={Map} />
@@ -234,6 +257,8 @@ export default function MAdmin() {
activeTab === tab.id ? <div key={tab.id}>{tab.mobileElement ?? tab.element}</div> : null,
)}
</div>
<MAdminSheets admin={admin} t={t} />
</div>
)
@@ -36,10 +36,14 @@ export default function MAdminUsersSection({ admin, t, locale }: MAdminUsersSect
return (
<div className="space-y-3">
<MAdminCard>
<div className="mb-1 flex items-center gap-2">
<span className="text-[0.875rem] font-extrabold text-m-ink">{t('admin.tabs.users')}</span>
<span className="font-geist text-[0.625rem] font-bold text-m-faint">
{users.length} {t('admin.stats.users', { count: users.length })}
{/* Wraps rather than overflows: in a long language the button moves under
the title and its count, inside the card, instead of past its edge. */}
<div className="mb-1 flex flex-wrap items-center gap-x-2 gap-y-2">
<span className="flex min-w-0 items-baseline gap-2">
<span className="text-[0.875rem] font-extrabold text-m-ink">{t('admin.tabs.users')}</span>
<span className="whitespace-nowrap font-geist text-[0.625rem] font-bold text-m-faint">
{users.length} {t('admin.stats.users', { count: users.length })}
</span>
</span>
<MAdminButton className="ms-auto" onClick={() => setShowCreateUser(true)}>
<UserPlus size={12} strokeWidth={2.2} />
@@ -77,9 +81,11 @@ export default function MAdminUsersSection({ admin, t, locale }: MAdminUsersSect
</span>
<span
className={`flex-none whitespace-nowrap rounded-full px-[9px] py-[3px] font-geist text-[0.5625rem] font-extrabold ${
// Dark mode lifts the words towards the ink: the plain status red on a
// dark red ground and grey on grey were both below 4.5:1 there.
u.role === 'admin'
? 'bg-[color:color-mix(in_srgb,var(--m-st-danger)_12%,transparent)] text-[color:var(--m-st-danger)]'
: 'bg-[color:var(--m-ic)] text-m-muted'
? 'bg-[color:color-mix(in_srgb,var(--m-st-danger)_12%,transparent)] text-[color:var(--m-st-danger)] dark:bg-[color:color-mix(in_srgb,var(--m-st-danger)_18%,transparent)] dark:text-[color:color-mix(in_srgb,var(--m-st-danger)_55%,var(--m-ink))]'
: 'bg-[color:var(--m-ic)] text-m-muted dark:text-m-ink'
}`}
>
{u.role === 'admin' ? t('settings.roleAdmin') : t('settings.roleUser')}
+18 -4
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useSearchParams } from 'react-router'
import { CalendarClock, X } from 'lucide-react'
import { useAtlas } from '../../../pages/atlas/useAtlas'
@@ -28,7 +28,21 @@ const removeBtnCls = 'mt-4 w-full rounded-full bg-[rgba(214,39,59,.12)] py-[11px
* useAtlas hook — this file is presentation and wiring only.
*/
export default function MAtlas() {
const atlas = useAtlas()
const rootRef = useRef<HTMLDivElement>(null)
const headerRef = useRef<HTMLDivElement>(null)
// The map runs under the rail (m-bleed) and the header: the camera keeps clear
// of both. The rail's share is the root's own negative margin, resolved by the
// browser; the header's is how far down it reaches.
const atlas = useAtlas({
chromeInsets: () => {
const root = rootRef.current
if (!root) return { top: 0, start: 0 }
const start = Math.max(0, -parseFloat(getComputedStyle(root).marginInlineStart) || 0)
const header = headerRef.current?.getBoundingClientRect()
const top = header ? Math.max(0, header.bottom - root.getBoundingClientRect().top) : 0
return { top, start }
},
})
const {
t,
navigate,
@@ -109,7 +123,7 @@ export default function MAtlas() {
return (
// h-dvh, not h-full: the shell stopped providing a definite height (#1809)
// and a map on a percentage of an auto-height parent collapses to zero.
<div className="relative h-dvh overflow-hidden">
<div ref={rootRef} className="m-bleed relative h-dvh overflow-hidden">
<div ref={mapRef} className="absolute inset-0 z-[1] bg-[color:var(--m-mapb)] [&_.leaflet-control-zoom]:hidden" />
{/* Region hover tooltip — positioned imperatively by useAtlas. */}
<div
@@ -120,7 +134,7 @@ export default function MAtlas() {
{/* Full-width bucket-list button. The bottom nav makes a back button
redundant on this main-nav screen, so the header spans the width. */}
<div className="absolute inset-x-4 top-[var(--m-safe-top,12px)] z-[5] flex items-center gap-2">
<div ref={headerRef} className="m-bar-x absolute top-[var(--m-safe-top,12px)] z-[5] flex items-center gap-2 wide:mx-auto wide:max-w-[720px]">
<button
type="button"
onClick={() => setBucketOpen(true)}
@@ -50,7 +50,7 @@ export default function MAtlasSearch({
const q = query.trim().toLowerCase()
const rows = q ? options.filter((o) => o.label.toLowerCase().includes(q)).slice(0, 6) : suggestions
const scrimCls = 'm-fade-in fixed inset-0 z-[70] flex flex-col bg-[rgba(16,16,19,.28)] px-[18px] pt-[calc(var(--m-safe-top,12px)+12px)] backdrop-blur-[22px] backdrop-saturate-[1.6]' // theme-lint-disable — fixed scrim value from the design
const scrimCls = 'm-fade-in fixed inset-0 z-[70] flex flex-col bg-[rgba(16,16,19,.28)] px-[18px] pt-[calc(var(--m-safe-top,12px)+12px)] backdrop-blur-[22px] backdrop-saturate-[1.6] wide:pt-[calc(var(--m-safe-top,12px)+72px)] wide:[&>*]:mx-auto wide:[&>*]:w-full wide:[&>*]:max-w-[640px]' // theme-lint-disable — fixed scrim value from the design
return (
<div
@@ -24,7 +24,7 @@ export default function MAtlasStatsCard({ stats }: MAtlasStatsCardProps) {
const planned = stats.totalCountriesPlanned || 0
return (
<div className="absolute bottom-[calc(var(--bottom-nav-h,84px)+16px)] inset-x-4 z-[5]">
<div className="m-bar-x absolute bottom-[calc(var(--bottom-nav-h,84px)+16px)] z-[5] wide:mx-auto wide:max-w-[720px]">
<div className="flex w-full rounded-[22px] border border-[color:var(--m-shbr)] bg-[color:var(--m-sheet)] px-3 py-[14px] shadow-[0_18px_44px_-20px_rgba(0,0,0,.4)]">
{cols.map(([n, l], i) => (
<div key={l} className="flex-1 text-center">
@@ -99,8 +99,8 @@ export default function MCollAddSheet({ open, collectionId, collectionName, list
<MapPin size={14} strokeWidth={2.2} />
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-[0.8125rem] font-semibold text-m-ink">{str(r.name)}</span>
{str(r.address) && <span className="block truncate font-geist text-[0.625rem] text-m-muted">{str(r.address)}</span>}
<span dir="auto" className="block truncate text-[0.8125rem] font-semibold text-m-ink">{str(r.name)}</span>
{str(r.address) && <span dir="auto" className="block truncate font-geist text-[0.625rem] text-m-muted">{str(r.address)}</span>}
</span>
</button>
))}
@@ -27,6 +27,7 @@ export default function MCollLinksEditor({ links, onChange, t }: MCollLinksEdito
className={`w-24 flex-none ${rowInput}`}
/>
<input
dir="ltr"
value={l.url}
onChange={e => setLink(i, { url: e.target.value })}
placeholder="https://…"
@@ -0,0 +1,74 @@
import { Layers, Plus } from 'lucide-react';
import { ALL_SAVED } from '../../../store/collectionStore';
interface ListEntry {
id: number;
name: string;
/** The list's own colour, already resolved to the default for a list without one. */
color: string;
place_count?: number | null;
}
interface MCollListNavProps {
lists: ListEntry[];
allSaved: boolean;
activeId: number | typeof ALL_SAVED | null;
onSelect: (id: number | typeof ALL_SAVED) => void;
onNewList: () => void;
t: (key: string) => string;
}
/**
* The lists a user keeps: all saved places, each list with its colour and its
* count, and a new one. The phone opens them as the header's dropdown; with
* room for two panes they stand in a column of their own beside the places,
* the way Notes on an iPad keeps its folders in view.
*/
export default function MCollListNav({ lists, allSaved, activeId, onSelect, onNewList, t }: MCollListNavProps) {
const row =
'flex w-full items-center gap-[10px] rounded-[11px] p-[10px] text-start outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--m-act)]';
return (
<>
<button
type="button"
onClick={() => onSelect(ALL_SAVED)}
aria-current={allSaved ? 'page' : undefined}
className={`${row} text-[length:calc(13px*var(--fs-scale-body,1))] font-semibold text-m-ink ${allSaved ? 'bg-[color:var(--m-ic)]' : 'hover:bg-[color:var(--m-ic)]'}`}
>
<Layers size={15} strokeWidth={2} className="flex-none" />
<span className="min-w-0 flex-1 truncate">{t('collections.allSaved')}</span>
</button>
<div className="my-[2px] h-px bg-[color:var(--m-rowbr)]" />
{lists.map((l) => {
const active = activeId === l.id;
return (
<button
key={l.id}
type="button"
onClick={() => onSelect(l.id)}
aria-current={active ? 'page' : undefined}
className={`${row} ${active ? 'bg-[color:var(--m-ic)]' : 'hover:bg-[color:var(--m-ic)]'}`}
style={active ? { boxShadow: `inset 0 0 0 1.5px ${l.color}` } : undefined}
>
<span className="h-[9px] w-[9px] flex-none rounded-full" style={{ background: l.color }} />
<span className="min-w-0 flex-1 truncate text-[length:calc(13px*var(--fs-scale-body,1))] font-semibold text-m-ink">
{l.name}
</span>
<span className="font-geist text-[length:calc(11px*var(--fs-scale-caption,1))] font-bold text-m-faint">
{l.place_count ?? 0}
</span>
</button>
);
})}
<div className="my-[2px] h-px bg-[color:var(--m-rowbr)]" />
<button
type="button"
onClick={onNewList}
className={`${row} py-[11px] text-[length:calc(13px*var(--fs-scale-body,1))] font-bold text-m-muted hover:bg-[color:var(--m-ic)]`}
>
<Plus size={15} strokeWidth={2.2} className="flex-none" /> {t('collections.newList')}
</button>
</>
);
}
@@ -56,7 +56,7 @@ export default function MCollListPickerSheet({ mode, lists, count, onPick, onClo
className="mb-1 flex w-full items-center gap-[10px] rounded-[13px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-sheet)] px-3 py-[11px] text-start disabled:opacity-60"
>
<span className="h-[9px] w-[9px] flex-none rounded-full" style={{ background: list.color || '#6366F1' }} />
<span className="min-w-0 flex-1 truncate text-[0.84375rem] font-semibold text-m-ink">{list.name}</span>
<span dir="auto" className="min-w-0 flex-1 truncate text-[0.84375rem] font-semibold text-m-ink">{list.name}</span>
{busyId === list.id
? <Loader2 size={13} className="flex-none animate-spin text-m-faint" />
: <span className="font-geist text-[0.6875rem] font-bold text-m-faint">{list.place_count ?? 0}</span>}
@@ -16,9 +16,12 @@ interface MCollPlaceRowProps {
}
/**
* A saved place: the main card (tinted category tile, name, address) next to
* the status column with the status pill (tap = cycle Idea → Want to go →
* Visited) and the category pill. In select mode a tap toggles the selection.
* A saved place, one card: the tinted category tile, name and address, and at
* its trailing end the status pill (tap = cycle Idea → Want to go → Visited)
* over the category pill. In select mode a tap toggles the selection.
*
* The pills used to stand in a card of their own beside the place, mostly empty
* and a third of a narrow window wide, which cut the names short.
*/
export default function MCollPlaceRow({
place, selectMode, selected, canEdit, onOpen, onToggleSelect, onSetStatus, t,
@@ -30,16 +33,24 @@ export default function MCollPlaceRow({
const StatusIcon = status.icon
const pill =
'inline-flex w-[78px] box-border items-center justify-center gap-1 truncate rounded-full py-[5px] font-geist text-[0.5625rem] font-extrabold'
'inline-flex max-w-[92px] box-border items-center justify-center gap-1 rounded-full px-2 py-[4px] font-geist text-[0.5625rem] font-extrabold'
// The words in the colour, pulled towards the ink so they read on the light
// ground as well as on the dark one; the ground a stronger wash of it.
const pillStyle = (color: string) => ({
background: `color-mix(in srgb, ${color} 16%, transparent)`,
color: `color-mix(in srgb, ${color} 62%, var(--m-ink))`,
})
return (
<div className="mt-2 flex items-stretch gap-2">
<div
className="mt-2 flex items-center rounded-2xl border border-[color:var(--m-rowbr)] bg-m-sheetop"
style={selected ? { boxShadow: 'inset 0 0 0 1.5px var(--m-act)' } : undefined}
>
<button
type="button"
onClick={() => (selectMode ? onToggleSelect(place.id) : onOpen(place.id))}
aria-pressed={selectMode ? selected : undefined}
className="flex min-w-0 flex-1 items-center gap-[11px] rounded-2xl border border-[color:var(--m-rowbr)] bg-m-sheetop px-3 py-[11px] text-start"
style={selected ? { boxShadow: 'inset 0 0 0 1.5px var(--m-act)' } : undefined}
className="flex min-w-0 flex-1 items-center gap-[11px] self-stretch rounded-2xl py-[11px] pe-2 ps-3 text-start"
>
<span
className={`flex h-10 w-10 flex-none items-center justify-center rounded-xl ${selected ? 'bg-m-act text-m-actfg' : ''}`}
@@ -48,32 +59,32 @@ export default function MCollPlaceRow({
{selected ? <Check size={17} strokeWidth={2.4} /> : <TileIcon size={17} strokeWidth={2.2} />}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-[0.78125rem] font-bold text-m-ink">{place.name}</span>
<span dir="auto" className="block truncate text-[0.78125rem] font-bold text-m-ink">{place.name}</span>
{place.address && (
<span className="mt-[2px] flex items-center gap-[3px] truncate font-geist text-[0.625rem] text-m-muted">
<MapPin size={9} strokeWidth={2.2} className="flex-none" />
<span className="truncate">{place.address}</span>
<span dir="auto" className="truncate">{place.address}</span>
</span>
)}
</span>
</button>
<div className="flex flex-none flex-col justify-center gap-[5px] rounded-2xl border border-[color:var(--m-rowbr)] bg-m-sheetop px-[10px] py-2">
<div className="flex flex-none flex-col items-end justify-center gap-[5px] py-2 pe-3">
<button
type="button"
disabled={!canEdit || selectMode}
onClick={() => onSetStatus(place.id, nextStatus(place.status))}
aria-label={t(status.labelKey)}
className={pill}
style={{ background: tint(status.color, '18'), color: status.color }}
style={pillStyle(status.color)}
>
<StatusIcon size={9} strokeWidth={2.6} className="flex-none" />
<span className="truncate">{t(status.labelKey)}</span>
</button>
{cat && place.category?.name && (
<span className={pill} style={{ background: tint(cat.color, '18'), color: cat.color }}>
<span className={pill} style={pillStyle(cat.color)}>
<TileIcon size={9} strokeWidth={2.6} className="flex-none" />
<span className="truncate">{place.category.name}</span>
<span dir="auto" className="truncate">{place.category.name}</span>
</span>
)}
</div>
@@ -89,7 +89,7 @@ export default function MCollPlaceSheet({
{held.category?.name && (
<span className={HERO_CAT_CHIP}>
<MapPin size={9} strokeWidth={2.6} className="flex-none" />
<span className="truncate">{held.category.name}</span>
<span dir="auto" className="truncate">{held.category.name}</span>
</span>
)}
{/* -me-1 keeps the buttons 14px off the edge, where close has always sat. */}
@@ -59,7 +59,7 @@ export default function MCollTripPickerSheet({ open, count, onCopy, onClose, t }
: <MapPin size={15} strokeWidth={2} />}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-[0.8125rem] font-semibold text-m-ink">{trip.title}</span>
<span dir="auto" className="block truncate text-[0.8125rem] font-semibold text-m-ink">{trip.title}</span>
{dateRange(trip) && (
<span className="mt-[1px] flex items-center gap-1 truncate font-geist text-[0.6875rem] text-m-muted">
<CalendarDays size={11} strokeWidth={2} className="flex-none" /> {dateRange(trip)}
@@ -24,6 +24,8 @@ import MCollTours from './MCollTours'
import MCollInvites from './MCollInvites'
import { STATUS_SPEC } from './collectionsMobileModel'
import MConfirmSheet from '../settings/MConfirmSheet'
import MCollListNav from './MCollListNav'
import { useIsSplit } from '../../../hooks/sizeClass'
const HEADER_CIRCLE =
'flex h-[38px] w-[38px] flex-none items-center justify-center rounded-full border border-[color:var(--m-gbr)] bg-[color:var(--m-sheet)] text-m-ink shadow-[0_5px_12px_-8px_rgba(0,0,0,.18)]'
@@ -66,6 +68,9 @@ export default function MCollections() {
const { t } = c
const [drop, setDrop] = useState(false)
// With room for two panes the lists stand in a column beside the places
// (MCollListNav) instead of behind the header's dropdown.
const split = useIsSplit()
const [statusDrop, setStatusDrop] = useState(false)
const [labelDrop, setLabelDrop] = useState(false)
@@ -145,6 +150,20 @@ export default function MCollections() {
c.setLabelFilter(c.labelFilter.includes(id) ? c.labelFilter.filter(x => x !== id) : [...c.labelFilter, id])
}
const navLists = [...c.ownedLists, ...c.sharedLists].map(l => ({
id: l.id, name: l.name, place_count: l.place_count, color: l.color || '#6366F1',
}))
const listNav = (
<MCollListNav
lists={navLists}
allSaved={c.isAllSaved}
activeId={c.activeId}
onSelect={selectList}
onNewList={openNewList}
t={t}
/>
)
const selectChip = (disabled: boolean) =>
`flex flex-none items-center gap-[5px] rounded-full border border-[color:var(--m-rowbr)] bg-m-sheetop px-3 py-2 text-[0.75rem] font-semibold text-m-ink ${disabled ? 'opacity-40' : ''}`
@@ -153,10 +172,28 @@ export default function MCollections() {
// Map view fills the viewport like the trip and journal maps do (#2104);
// h-dvh because the shell owns no scroll container (#1809). List view
// keeps the document as the scroller.
className={`px-4 pb-[calc(var(--bottom-nav-h,84px)+12px)] pt-[var(--m-safe-top,12px)] ${c.view === 'map' ? 'flex h-dvh flex-col overflow-hidden' : ''}`}
className={`px-4 pb-[calc(var(--bottom-nav-h,84px)+12px)] pt-[var(--m-safe-top,12px)] wide:px-6 split:grid split:grid-cols-[260px_minmax(0,1fr)] split:gap-8 ${c.view === 'map' ? 'flex h-dvh flex-col overflow-hidden' : 'wide:mx-auto wide:max-w-[1040px] split:max-w-[1240px] split:items-start'}`}
>
{/* Header: back · list switcher · edit · share */}
{split && (
<nav
aria-label={t('collections.title')}
className="sticky top-[var(--m-safe-top,12px)] flex max-h-[calc(100dvh-var(--m-safe-top,12px)-16px)] flex-col gap-1 self-start overflow-y-auto rounded-2xl border border-[color:var(--m-rowbr)] bg-[color:var(--m-sheet)] p-[6px] shadow-[0_16px_40px_-24px_rgba(0,0,0,.3)] [scrollbar-width:none]"
>
<div className="mb-1 px-[10px] pb-1 pt-[9px] text-subtitle font-bold text-m-ink">{t('collections.title')}</div>
{listNav}
</nav>
)}
<div className={`min-w-0 ${c.view === 'map' ? 'flex min-h-0 flex-1 flex-col' : ''}`}>
{/* Header: back · list switcher · edit · share. Beside the lists it names the list on show. */}
<div className="mb-3 flex flex-none items-center gap-2 pt-2">
{split ? (
<h1 className="flex min-w-0 flex-1 items-center gap-2 text-title font-extrabold text-m-ink">
{!c.isAllSaved && c.activeCollection && (
<span className="h-[11px] w-[11px] flex-none rounded-full" style={{ background: navLists.find(l => l.id === c.activeId)?.color }} />
)}
<span className="min-w-0 truncate">{title}</span>
</h1>
) : (
<button
type="button"
onClick={() => setDrop(v => !v)}
@@ -167,6 +204,7 @@ export default function MCollections() {
<span className="min-w-0 flex-1 truncate text-start">{title}</span>
<ChevronDown size={13} strokeWidth={2} className="flex-none text-m-faint" />
</button>
)}
{canEditList && (
<button type="button" onClick={() => c.setEditorTarget(c.activeCollection!)} aria-label={t('collections.editListTitle')} className={HEADER_CIRCLE}>
<Pencil size={15} strokeWidth={2.2} />
@@ -182,40 +220,9 @@ export default function MCollections() {
{/* List switcher dropdown (in flow, like the design). In map view it is a
flex item of a viewport-high column, so it has to cap itself and give
way rather than push its last entries under the dock (#2104). */}
{drop && (
{drop && !split && (
<div className="-mt-[6px] mb-3 max-h-[50dvh] min-h-0 overflow-y-auto rounded-2xl border border-[color:var(--m-rowbr)] bg-m-sheetop p-[6px] shadow-[0_16px_40px_-20px_rgba(0,0,0,.4)]">
<button
type="button"
onClick={() => selectList(ALL_SAVED)}
className={`flex w-full items-center gap-[9px] rounded-[11px] p-[10px] text-[0.8125rem] font-semibold text-m-ink ${c.isAllSaved ? 'bg-[color:var(--m-ic)]' : ''}`}
>
<Layers size={15} strokeWidth={2} /> {t('collections.allSaved')}
</button>
<div className="my-[2px] h-px bg-[color:var(--m-rowbr)]" />
{[...c.ownedLists, ...c.sharedLists].map(l => {
const active = c.activeId === l.id
return (
<button
key={l.id}
type="button"
onClick={() => selectList(l.id)}
className={`flex w-full items-center gap-[10px] rounded-[11px] p-[10px] text-start ${active ? 'bg-[color:var(--m-ic)]' : ''}`}
style={active ? { boxShadow: `inset 0 0 0 1.5px ${l.color || '#6366F1'}` } : undefined}
>
<span className="h-[9px] w-[9px] flex-none rounded-full" style={{ background: l.color || '#6366F1' }} />
<span className="min-w-0 flex-1 truncate text-[0.8125rem] font-semibold text-m-ink">{l.name}</span>
<span className="font-geist text-[0.6875rem] font-bold text-m-faint">{l.place_count ?? 0}</span>
</button>
)
})}
<div className="my-[2px] h-px bg-[color:var(--m-rowbr)]" />
<button
type="button"
onClick={openNewList}
className="flex w-full items-center gap-[9px] rounded-[11px] px-[10px] py-[11px] text-[0.8125rem] font-bold text-m-muted"
>
<Plus size={15} strokeWidth={2.2} /> {t('collections.newList')}
</button>
{listNav}
</div>
)}
@@ -360,7 +367,7 @@ export default function MCollections() {
className={`flex w-full items-center gap-[10px] rounded-[11px] p-[10px] text-start ${on ? 'bg-[color:var(--m-ic)]' : ''}`}
>
<span className="h-[9px] w-[9px] flex-none rounded-full" style={{ background: l.color || '#6366F1' }} />
<span className="min-w-0 flex-1 truncate text-[0.8125rem] font-semibold text-m-ink">{l.name}</span>
<span dir="auto" className="min-w-0 flex-1 truncate text-[0.8125rem] font-semibold text-m-ink">{l.name}</span>
<span className="font-geist text-[0.6875rem] font-bold text-m-faint">{l.count}</span>
{on && <Check size={14} strokeWidth={2.6} className="flex-none text-m-ink" />}
</button>
@@ -429,7 +436,8 @@ export default function MCollections() {
) : c.visiblePlaces.length === 0 ? (
<EmptyNote icon={<Search size={19} strokeWidth={1.8} />} title={t('collections.empty.noMatchTitle')} />
) : (
<div>
// Two columns of rows where the page has the room for them.
<div className="wide:grid wide:grid-cols-[repeat(auto-fill,minmax(min(100%,380px),1fr))] wide:gap-x-3">
{c.visiblePlaces.map(p => (
<MCollPlaceRow
key={p.id}
@@ -466,6 +474,8 @@ export default function MCollections() {
</>
)}
</div>
{/* Sheets */}
<MCollPlaceSheet
place={c.selectedPlace}
@@ -140,7 +140,7 @@ function MCurrencyWidget(): React.ReactElement {
</div>
</div>
<div className="mt-[9px] font-geist text-[0.65625rem] text-m-muted">
{rate != null ? `1 ${from} = ${rate.toFixed(4)} ${to}` : t('dashboard.fx.unavailable')}
{rate != null ? <span dir="ltr">{`1 ${from} = ${rate.toFixed(4)} ${to}`}</span> : t('dashboard.fx.unavailable')}
</div>
</WidgetPanel>
)
@@ -195,7 +195,7 @@ function MCollectionsWidget(): React.ReactElement {
className="mt-[11px] flex w-full items-center rounded-[14px] p-[15px_14px] text-start text-white"
style={{ background: list.color || entityGradient(list.id) }}
>
<span className="min-w-0 flex-1 truncate text-[0.875rem] font-bold">{list.name}</span>
<span dir="auto" className="min-w-0 flex-1 truncate text-[0.875rem] font-bold">{list.name}</span>
<span className="ms-auto flex flex-none items-center gap-1 rounded-full bg-white/[.22] px-[10px] py-[3px] font-geist text-[0.625rem] font-bold">
<MapPin size={10} strokeWidth={2.4} />
{list.place_count ?? 0}
@@ -307,7 +307,7 @@ function MUpcomingWidget({ items }: { items: UpcomingReservation[] }): React.Rea
</span>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-1.5">
<span className="truncate text-[0.8125rem] font-semibold">{r.title}</span>
<span dir="auto" className="truncate text-[0.8125rem] font-semibold">{r.title}</span>
{r.status === 'pending' && (
<span className="flex-none rounded-full bg-[color:var(--m-ic)] px-[6px] py-px font-geist text-[0.5625rem] font-bold uppercase tracking-[.08em] text-m-muted">
{t('reservations.pending')}
@@ -1,4 +1,5 @@
import React, { useEffect, useRef, useState } from 'react'
import { useIsSplit } from '../../../hooks/sizeClass'
import {
Archive, ArchiveRestore, ArrowRight, Bell, CalendarDays, CalendarPlus, Copy,
LayoutGrid, List, MapPin, Pencil, Plus, RefreshCw, Sparkles, Trash2, Users,
@@ -28,6 +29,7 @@ import type { MobileDashToken } from '@trek/shared'
import { usePullToRefresh } from '../../usePullToRefresh'
import MPullIndicator from '../../components/MPullIndicator'
import SharedConfirmSheet from '../settings/MConfirmSheet'
import { edgeFadeClass, useEdgeFade } from '../../components/useEdgeFade'
type TripFilter = 'planned' | 'archive' | 'completed'
@@ -58,8 +60,13 @@ export default function MDashboard(): React.ReactElement {
const unread = useInAppNotificationStore(s => s.unreadCount)
const fetchUnreadCount = useInAppNotificationStore(s => s.fetchUnreadCount)
const [menuOpen, setMenuOpen] = useState(false)
const split = useIsSplit()
const { available: assistantAvailable, open: openAssistant } = useAssistantHomeLauncher()
const [subOpen, setSubOpen] = useState(false)
// The filter chips scroll when a language's words do not fit beside the two
// buttons, and fade out where more of them waits.
const [filterStrip, setFilterStrip] = useState<HTMLDivElement | null>(null)
const filterFade = useEdgeFade(filterStrip)
// Plugin dashboard widgets + trip-card badges, mirroring the desktop page:
// same slot filter (only true dashboard widgets), one badge fetch for all
@@ -132,7 +139,7 @@ export default function MDashboard(): React.ReactElement {
forced zoom. min-w-max keeps the pill track wrapping its chips,
and the box stays content-wide (no flex-1) so the pill still hugs
them on wide phones while ms-auto parks the icons at the end. */}
<div className="m-hscroll min-w-0">
<div ref={setFilterStrip} className={`m-hscroll min-w-0 ${edgeFadeClass(filterFade)}`}>
<MSegmented<TripFilter>
value={tripFilter}
onChange={setTripFilter}
@@ -179,7 +186,7 @@ export default function MDashboard(): React.ReactElement {
)}
{viewMode === 'grid' ? (
<div className="mt-[10px] flex flex-col gap-3">
<div className="mt-[10px] flex flex-col gap-3 wide:grid wide:grid-cols-[repeat(auto-fill,minmax(300px,1fr))] wide:gap-4">
{gridTrips.map(trip => (
<MTripGridCard
key={trip.id}
@@ -193,7 +200,7 @@ export default function MDashboard(): React.ReactElement {
))}
</div>
) : (
<div className="mt-[10px] flex flex-col gap-3">
<div className="mt-[10px] flex flex-col gap-3 wide:grid wide:grid-cols-[repeat(auto-fill,minmax(300px,1fr))] wide:gap-x-4">
{gridTrips.map(trip => (
<MTripListCard
key={trip.id}
@@ -214,7 +221,8 @@ export default function MDashboard(): React.ReactElement {
return (
<>
<MGlassBar floating>
{/* In the column the cards below stand in, so their edges line up. */}
<MGlassBar column="wide:max-w-[1280px]">
<button
type="button"
// The page itself is the scroller since #1809, no inner container to walk up to.
@@ -256,7 +264,7 @@ export default function MDashboard(): React.ReactElement {
{demoMode && <MDemoWelcome />}
<MPullIndicator state={pull} label={t('common.loading')} />
<div ref={contentRef} className="px-4 pb-[calc(var(--bottom-nav-h,84px)+32px)] pt-[calc(var(--m-safe-top,12px)+82px)]">
<div ref={contentRef} className="px-4 pb-[calc(var(--bottom-nav-h,84px)+32px)] pt-[calc(var(--m-safe-top,12px)+82px)] wide:mx-auto wide:max-w-[1280px] wide:px-6">
{loadError && (
<div role="alert" className="mb-3 flex items-center gap-3 rounded-[20px] border border-[color:var(--m-gbr)] bg-[color:var(--m-glass)] p-[14px]">
<span className="min-w-0 flex-1 text-[0.8125rem] font-medium text-m-ink">{t('dashboard.loadErrorBanner')}</span>
@@ -271,24 +279,36 @@ export default function MDashboard(): React.ReactElement {
</div>
)}
{spotlight && (
<MSpotlightCard
trip={spotlight}
t={t}
onOpen={() => navigate(`/trips/${spotlight.id}`)}
actions={actionsFor(spotlight, 'list')}
/>
)}
{dashOrder.map(id => (
<React.Fragment key={id}>{renderBlock(id)}</React.Fragment>
))}
{widgetPlugins.length > 0 && (
<div className="mt-3 flex flex-col gap-3">
<PluginWidgets plugins={widgetPlugins} tripId={spotlight ? String(spotlight.id) : null} />
{/* Beside each other from the split size class on, the way the desktop
dashboard holds them: the trips in the main column, the widgets in a
column of their own, each in the order the user set. */}
<div className="split:grid split:grid-cols-[minmax(0,1fr)_minmax(300px,360px)] split:items-start split:gap-6">
<div className="min-w-0">
{spotlight && (
<MSpotlightCard
trip={spotlight}
t={t}
onOpen={() => navigate(`/trips/${spotlight.id}`)}
actions={actionsFor(spotlight, 'list')}
/>
)}
{(split ? dashOrder.filter(id => id === 'trips') : dashOrder).map(id => (
<React.Fragment key={id}>{renderBlock(id)}</React.Fragment>
))}
</div>
)}
{(split || widgetPlugins.length > 0) && (
<div className="min-w-0 split:sticky split:top-[calc(var(--m-safe-top,12px)+82px)] split:-mt-[14px]">
{split && dashOrder.filter(id => id !== 'trips').map(id => (
<React.Fragment key={id}>{renderBlock(id)}</React.Fragment>
))}
{widgetPlugins.length > 0 && (
<div className="mt-3 flex flex-col gap-3">
<PluginWidgets plugins={widgetPlugins} tripId={spotlight ? String(spotlight.id) : null} />
</div>
)}
</div>
)}
</div>
</div>
<MNewTripSheet
@@ -377,7 +397,9 @@ function MSpotlightCard({ trip, t, onOpen, actions }: {
tabIndex={0}
onClick={onOpen}
onKeyDown={e => { if (e.key === 'Enter') onOpen() }}
className="relative h-[300px] cursor-pointer overflow-hidden rounded-[26px] shadow-[0_24px_56px_-22px_rgba(0,0,0,.5)]"
// A phone on its side has no 300px to spare between the top bar and the dock:
// the card ends above the dock rather than under it.
className="relative h-[clamp(200px,calc(100svh-var(--m-safe-top,12px)-82px-var(--bottom-nav-h,84px)-14px),300px)] cursor-pointer overflow-hidden rounded-[26px] shadow-[0_24px_56px_-22px_rgba(0,0,0,.5)] outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--m-act)] wide:h-[clamp(300px,42vh,440px)]"
>
{trip.cover_image
? <img src={trip.cover_image} alt={trip.title} className="absolute inset-0 h-full w-full object-cover" />
@@ -385,7 +407,7 @@ function MSpotlightCard({ trip, t, onOpen, actions }: {
<div className="absolute end-[10px] top-[10px] flex gap-[6px]">
{actions.map(a => <CoverActionBtn key={a.key} action={a} />)}
</div>
<div className="absolute bottom-[10px] inset-x-[10px] rounded-[18px] border border-white/[.16] bg-[rgba(14,14,17,.52)] p-[12px_14px] text-white backdrop-blur-[22px] backdrop-saturate-[1.6]">{/* theme-lint-disable — fixed dark glass on the cover photo */}
<div className="absolute bottom-[10px] inset-x-[10px] rounded-[18px] border border-white/[.16] bg-[rgba(14,14,17,.52)] p-[12px_14px] text-white backdrop-blur-[22px] backdrop-saturate-[1.6] wide:bottom-4 wide:end-auto wide:start-4 wide:w-[min(560px,calc(100%-32px))] wide:p-[16px_18px]">{/* theme-lint-disable — fixed dark glass on the cover photo */}
<span className="flex gap-[6px]">
<span className="rounded-full bg-white/[.92] px-2 py-[3px] text-[0.625rem] font-bold uppercase tracking-[.07em] text-[#101013]">{/* theme-lint-disable — fixed on-photo badge */}
{primaryBadge}
@@ -396,13 +418,13 @@ function MSpotlightCard({ trip, t, onOpen, actions }: {
</span>
)}
</span>
<div className="mt-[7px] truncate text-[1.4375rem] font-bold">{trip.title}</div>
<div dir="auto" className="mt-[7px] truncate text-[1.4375rem] font-bold">{trip.title}</div>
{ongoing && (
<div className="relative mb-2 mt-[9px] h-1 rounded-full bg-white/25">
<span className="absolute bottom-0 left-0 top-0 rounded-full bg-white" style={{ width: `${progress}%` }} />
<span className="absolute bottom-0 start-0 top-0 rounded-full bg-white" style={{ width: `${progress}%` }} />
</div>
)}
<div className={`flex gap-[6px] ${ongoing ? '' : 'mt-[9px]'}`}>
<div className={`flex flex-wrap gap-[6px] ${ongoing ? '' : 'mt-[9px]'}`}>
<SpotlightPill icon={<CalendarDays size={11} strokeWidth={2.2} />} label={t('dashboard.mobile.spotlightDays', { count: days })} />
<SpotlightPill icon={<MapPin size={11} strokeWidth={2.2} />} label={t('dashboard.hero.destinations', { count: places })} />
<SpotlightPill icon={<Users size={11} strokeWidth={2.2} />} label={t('dashboard.hero.travelers', { count: people })} />
@@ -425,7 +447,8 @@ function MSpotlightCard({ trip, t, onOpen, actions }: {
*/
function SpotlightPill({ icon, label }: { icon: React.ReactElement; label: string }): React.ReactElement {
return (
<span className="inline-flex items-center gap-1 rounded-full bg-white/[.28] px-[9px] py-[3px] font-geist text-[0.5625rem] font-bold tracking-[.07em] text-white uppercase">
// One line per chip: a narrow card wraps whole chips rather than breaking the number from its word.
<span className="inline-flex items-center gap-1 whitespace-nowrap rounded-full bg-white/[.28] px-[9px] py-[3px] font-geist text-[0.5625rem] font-bold tracking-[.07em] text-white uppercase">
{icon}
{label}
</span>
@@ -439,7 +462,9 @@ function CoverActionBtn({ action }: { action: CardAction }): React.ReactElement
type="button"
aria-label={action.label}
onClick={e => { e.stopPropagation(); action.onClick() }}
className="flex h-[34px] w-[34px] items-center justify-center rounded-full border border-white/30 bg-white/[.22] text-white backdrop-blur-[8px]"
// A dark ground, not a white veil: over a bright sky the veil and its white
// icon disappeared together.
className="flex h-[34px] w-[34px] items-center justify-center rounded-full border border-white/25 bg-[color:var(--overlay)] text-white backdrop-blur-[8px]"
>
{action.icon}
</button>
@@ -515,14 +540,13 @@ function MTripGridCard({ trip, locale, badge, pluginBadges, actions, onOpen }: {
</div>
</div>
<div className="p-[9px_12px_12px]">
<div className="flex items-center gap-2">
<div className="min-w-0 flex-1 truncate text-[0.875rem] font-semibold">{trip.title}</div>
<span className="inline-flex flex-none items-center gap-[6px] rounded-full bg-[color:var(--m-ic)] px-[10px] py-1 font-geist text-[0.6875rem] font-semibold text-m-ink">
<span>{fullDate(trip.start_date, locale) ?? '—'}</span>
<ArrowRight size={12} strokeWidth={2.2} className="text-m-faint" />
<span>{fullDate(trip.end_date, locale) ?? '—'}</span>
</span>
</div>
{/* The name has the card's whole width; the dates take the line under it. */}
<div dir="auto" className="truncate text-[0.875rem] font-semibold">{trip.title}</div>
<span className="mt-[6px] inline-flex max-w-full items-center gap-[6px] rounded-full bg-[color:var(--m-ic)] px-[10px] py-1 font-geist text-[0.6875rem] font-semibold text-m-ink">
<span className="truncate">{fullDate(trip.start_date, locale) ?? '—'}</span>
<ArrowRight size={12} strokeWidth={2.2} className="flex-none text-m-faint" />
<span className="truncate">{fullDate(trip.end_date, locale) ?? '—'}</span>
</span>
<MTripBadges items={pluginBadges} />
</div>
</div>
@@ -548,7 +572,7 @@ function MTripListCard({ trip, locale, t, badge, pluginBadges, actions, onOpen }
<div className="absolute end-3 top-3 flex gap-[7px]">
{actions.map(a => <CoverActionBtn key={a.key} action={a} />)}
</div>
<div className="absolute bottom-[14px] inset-x-4 truncate text-[1.625rem] font-extrabold text-white [text-shadow:0_2px_12px_rgba(0,0,0,.4)]">
<div dir="auto" className="absolute bottom-[14px] inset-x-4 truncate text-[1.625rem] font-extrabold text-white [text-shadow:0_2px_12px_rgba(0,0,0,.4)]">
{trip.title}
</div>
</div>
@@ -5,14 +5,13 @@ import { useCanDo } from '../../../store/permissionsStore'
import { useSettingsStore } from '../../../store/settingsStore'
import { useAuthStore } from '../../../store/authStore'
import MChip from '../../components/MChip'
import { CustomDatePicker } from '../../../components/shared/CustomDateTimePicker'
import CustomSelect from '../../../components/shared/CustomSelect'
import { currenciesWith, SYMBOLS } from '../../../components/Budget/BudgetPanel.constants'
import type { DashboardTrip } from '../../../pages/dashboard/dashboardModel'
import { MAX_TRIP_DAYS, type Trip, type TripCreateRequest } from '@trek/shared'
import MSheet from '../../components/MSheet'
import { FIELD_AREA_CLS } from '../trip/sheets/PlSheetChrome'
import { Eyebrow, SHEET_FIELD, SHEET_PRIMARY, SHEET_ROUND_PRIMARY, SHEET_SECONDARY, SheetSection, TileHeader } from '../trip/sheets/MTripSheetUi'
import { Eyebrow, FIELD_AREA_CLS, FIELD_CLS, FormSheetBody, FormSheetFooter, FormSheetHeader, SheetDateField } from '../trip/sheets/PlSheetChrome'
import { SHEET_FIELD, SHEET_ROUND_PRIMARY, SHEET_SECONDARY } from '../trip/sheets/MTripSheetUi'
import MDayImpactList from '../../components/MDayImpactList'
import MConfirmSheet from '../settings/MConfirmSheet'
import { useTripRangeGuard, type RangeRemoval } from '../../../hooks/useTripRangeGuard'
@@ -31,9 +30,6 @@ interface MNewTripSheetProps {
onArchive?: () => void
}
/** A date picker in the sheets' round field. */
const DATE_FIELD = 'flex h-[42px] items-center rounded-full border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-[14px]'
/** The reminder presets the desktop form offers; anything else is a custom number of days. */
const REMINDER_PRESETS = [0, 1, 3, 9]
@@ -151,108 +147,100 @@ export default function MNewTripSheet({ open, trip: tripProp, onClose, onSave, o
return (
<>
<MSheet open={open} onClose={onClose} variant="card" material="glass" ariaLabel={isEditing ? t('dashboard.editTrip') : t('dashboard.createTrip')}>
<div className="flex-none px-[18px] pt-4">
<TileHeader
icon={<Luggage size={19} strokeWidth={1.8} />}
title={isEditing ? t('dashboard.editTrip') : t('dashboard.createTrip')}
sub={isEditing ? trip?.title : undefined}
onClose={onClose}
closeLabel={t('common.cancel')}
/>
</div>
<MSheet open={open} onClose={onClose} form ariaLabel={isEditing ? t('dashboard.editTrip') : t('dashboard.createTrip')}>
<FormSheetHeader
icon={Luggage}
title={isEditing ? t('dashboard.editTrip') : t('dashboard.createTrip')}
subtitle={isEditing ? trip?.title : undefined}
onClose={onClose}
closeLabel={t('common.cancel')}
/>
<div className="m-scroll-fade mt-2 flex min-h-0 flex-1 flex-col gap-2.5 overflow-y-auto px-[18px] pb-[18px] pt-[14px]">
{/* Flat fields under their labels, as in every other form sheet: no
cards inside the card. */}
<FormSheetBody>
{error && (
<div className="flex-none rounded-[14px] bg-[color:var(--m-ic)] px-3 py-[10px] text-caption font-semibold text-[color:var(--m-st-danger)]">
<div className="mt-2 rounded-[14px] bg-[color:var(--m-ic)] px-3 py-[10px] text-caption font-semibold text-[color:var(--m-st-danger)]">
{error}
</div>
)}
<SheetSection title={t('dashboard.tripTitle')}>
<input
value={title}
onChange={e => { if (canEditTrip) { setError(''); setTitle(e.target.value) } }}
readOnly={!canEditTrip}
placeholder={t('dashboard.tripTitlePlaceholder')}
aria-label={t('dashboard.tripTitle')}
className={`${SHEET_FIELD} w-full flex-none font-semibold`}
/>
<Eyebrow className="mt-1 uppercase">{t('dashboard.tripDescription')}</Eyebrow>
<textarea
value={description}
onChange={e => { if (canEditTrip) { setError(''); setDescription(e.target.value) } }}
readOnly={!canEditTrip}
placeholder={t('dashboard.tripDescriptionPlaceholder')}
aria-label={t('dashboard.tripDescription')}
rows={2}
className={FIELD_AREA_CLS}
/>
</SheetSection>
<Eyebrow className="mb-[5px] mt-2 uppercase">{t('dashboard.tripTitle')}</Eyebrow>
<input
value={title}
onChange={e => { if (canEditTrip) { setError(''); setTitle(e.target.value) } }}
readOnly={!canEditTrip}
placeholder={t('dashboard.tripTitlePlaceholder')}
aria-label={t('dashboard.tripTitle')}
className={`${FIELD_CLS} font-semibold`}
/>
<Eyebrow className="mb-[5px] mt-3 uppercase">{t('dashboard.tripDescription')}</Eyebrow>
<textarea
value={description}
onChange={e => { if (canEditTrip) { setError(''); setDescription(e.target.value) } }}
readOnly={!canEditTrip}
placeholder={t('dashboard.tripDescriptionPlaceholder')}
aria-label={t('dashboard.tripDescription')}
rows={2}
className={FIELD_AREA_CLS}
/>
<SheetSection>
<div className="flex gap-2">
<div className="min-w-0 flex-1">
<Eyebrow className="mb-[5px] uppercase">{t('dashboard.startDate')}</Eyebrow>
<div className={DATE_FIELD}>
<CustomDatePicker
value={startDate}
onChange={v => { if (canEditTrip) changeStart(v) }}
placeholder={t('dashboard.startDate')}
borderless
/>
</div>
</div>
<div className="min-w-0 flex-1">
<Eyebrow className="mb-[5px] uppercase">{t('dashboard.endDate')}</Eyebrow>
<div className={DATE_FIELD}>
<CustomDatePicker
value={endDate}
onChange={v => { if (canEditTrip) { setError(''); setEndDate(v) } }}
placeholder={t('dashboard.endDate')}
borderless
/>
</div>
</div>
{/* Side by side. A placeholder longer than its field ("Дата окончания")
ends in an ellipsis inside it rather than running past its edge. */}
<div className="mt-3 grid grid-cols-2 gap-2">
<div className="min-w-0">
<Eyebrow className="mb-[5px] truncate uppercase">{t('dashboard.startDate')}</Eyebrow>
<SheetDateField
value={startDate}
onChange={v => { if (canEditTrip) changeStart(v) }}
placeholder={t('dashboard.startDate')}
/>
</div>
{!startDate && !endDate && (
<>
<Eyebrow className="mt-1 uppercase">{t('dashboard.dayCount')}</Eyebrow>
<input
type="number"
inputMode="numeric"
min={1}
max={MAX_TRIP_DAYS}
value={dayCount}
onChange={e => {
if (!canEditTrip) return
setError('')
const raw = e.target.value
setDayCount(raw === '' ? '' : Math.min(MAX_TRIP_DAYS, Math.max(1, Math.floor(Number(raw)) || 1)))
}}
readOnly={!canEditTrip}
aria-label={t('dashboard.dayCount')}
className={`${SHEET_FIELD} w-full flex-none`}
/>
{!isEditing && <p className="px-1 font-geist text-[length:calc(10px*var(--fs-scale-caption,1))] text-m-faint">{t('dashboard.noDateHint')}</p>}
</>
)}
</SheetSection>
<div className="min-w-0">
<Eyebrow className="mb-[5px] truncate uppercase">{t('dashboard.endDate')}</Eyebrow>
<SheetDateField
value={endDate}
onChange={v => { if (canEditTrip) { setError(''); setEndDate(v) } }}
placeholder={t('dashboard.endDate')}
/>
</div>
</div>
{!startDate && !endDate && (
<>
<Eyebrow className="mb-[5px] mt-3 uppercase">{t('dashboard.dayCount')}</Eyebrow>
<input
type="number"
inputMode="numeric"
min={1}
max={MAX_TRIP_DAYS}
value={dayCount}
onChange={e => {
if (!canEditTrip) return
setError('')
const raw = e.target.value
setDayCount(raw === '' ? '' : Math.min(MAX_TRIP_DAYS, Math.max(1, Math.floor(Number(raw)) || 1)))
}}
readOnly={!canEditTrip}
aria-label={t('dashboard.dayCount')}
className={FIELD_CLS}
/>
{!isEditing && <p className="mt-[6px] px-1 font-geist text-[length:calc(10px*var(--fs-scale-caption,1))] text-m-faint">{t('dashboard.noDateHint')}</p>}
</>
)}
<SheetSection title={t('dashboard.currency')}>
<CustomSelect
value={currency}
onChange={v => { if (canEditTrip) { setError(''); setCurrency(String(v)) } }}
disabled={!canEditTrip}
searchable
size="sm"
options={currenciesWith(currency).map(c => ({ value: c, label: `${c} (${SYMBOLS[c] || c})` }))}
style={{ width: '100%' }}
/>
</SheetSection>
<Eyebrow className="mb-[5px] mt-3 uppercase">{t('dashboard.currency')}</Eyebrow>
<CustomSelect
value={currency}
onChange={v => { if (canEditTrip) { setError(''); setCurrency(String(v)) } }}
disabled={!canEditTrip}
searchable
size="sm"
options={currenciesWith(currency).map(c => ({ value: c, label: `${c} (${SYMBOLS[c] || c})` }))}
style={{ width: '100%' }}
/>
{showReminder && (
<SheetSection>
<div className="mt-3">
<ReminderField
enabled={remindersEnabled}
days={reminderDays}
@@ -261,35 +249,35 @@ export default function MNewTripSheet({ open, trip: tripProp, onClose, onSave, o
onCustom={() => { setCustomReminder(true); if (REMINDER_PRESETS.includes(reminderDays)) setReminderDays(7) }}
onDays={setReminderDays}
/>
</SheetSection>
</div>
)}
{canUploadCover && (
<SheetSection>
<>
<Eyebrow className="mt-3 uppercase">{t('dashboard.coverImage')}</Eyebrow>
<CoverField cover={cover} title={title} />
</SheetSection>
</>
)}
{isEditing && onArchive && (
<button
type="button"
onClick={() => { onArchive(); onClose() }}
className={`${SHEET_SECONDARY} flex-none`}
className={`${SHEET_SECONDARY} mt-4 w-full`}
>
{trip?.is_archived ? <ArchiveRestore size={14} strokeWidth={2.2} /> : <Archive size={14} strokeWidth={2.2} />}
{trip?.is_archived ? t('dashboard.restore') : t('dashboard.archive')}
</button>
)}
</div>
</FormSheetBody>
<div className="flex flex-none items-center gap-2 border-t border-[color:var(--m-rowbr)] px-[18px] pb-4 pt-3">
<button type="button" onClick={onClose} className={`${SHEET_SECONDARY} ms-auto`}>
{t('common.cancel')}
</button>
<button type="button" onClick={handleSave} disabled={busy} className={SHEET_PRIMARY}>
{busy ? t('common.saving') : isEditing ? t('common.update') : t('dashboard.createTrip')}
</button>
</div>
<FormSheetFooter
onCancel={onClose}
cancelLabel={t('common.cancel')}
onSubmit={handleSave}
submitLabel={busy ? t('common.saving') : isEditing ? t('common.update') : t('dashboard.createTrip')}
submitDisabled={busy}
/>
</MSheet>
{/* After the sheet, so it opens on top of it. */}
+39 -17
View File
@@ -8,6 +8,8 @@ import MDancingTrek from '../../components/MDancingTrek'
import { journeyCoverSrc, journeyCoverStyle } from './mobileJourneyMeta'
import { usePullToRefresh } from '../../usePullToRefresh'
import MPullIndicator from '../../components/MPullIndicator'
import MGlassBar from '../../components/MGlassBar'
import { useIsWide } from '../../../hooks/sizeClass'
type JourneyListItem = Journey & {
entry_count?: number
@@ -25,6 +27,7 @@ export default function MJourney() {
openCreateModal, handleCreate, activeJourney,
} = useJourney()
const scrollRef = useRef<HTMLDivElement>(null)
const wide = useIsWide()
const pull = usePullToRefresh(scrollRef, loadJourneys, true, scrollRef)
const list = journeys as JourneyListItem[]
@@ -36,20 +39,22 @@ export default function MJourney() {
// The feed below keeps its own scroller, so this screen does not move the
// document and Safari's address bar stays put here.
<div className="relative h-dvh">
{/* Floating header: back + create pill */}
<div className="fixed inset-x-4 top-[var(--m-safe-top,12px)] z-30 flex items-center gap-[10px]">
<button
type="button"
onClick={() => openCreateModal()}
className="flex h-[38px] flex-1 items-center justify-center gap-[6px] whitespace-nowrap rounded-full bg-m-act px-[15px] text-[0.78125rem] font-bold text-m-actfg"
>
<Plus size={14} strokeWidth={2.4} />
{t('journey.frontpage.createJourney')}
</button>
</div>
{/* Floating header: the create pill. On a wide window it is the glass bar
the dashboard and the notifications have, in the column of cards below
it, and names the page at its start, as an iPad title bar does. */}
{wide ? (
<MGlassBar column="wide:max-w-[1088px]">
<h1 className="min-w-0 flex-1 truncate ps-1 text-subtitle font-extrabold text-m-ink">{t('journey.title')}</h1>
<CreateButton label={t('journey.frontpage.createJourney')} onClick={() => openCreateModal()} />
</MGlassBar>
) : (
<div className="m-bar-x fixed top-[var(--m-safe-top,12px)] z-30 flex items-center gap-[10px]">
<CreateButton label={t('journey.frontpage.createJourney')} onClick={() => openCreateModal()} grow />
</div>
)}
<MPullIndicator state={pull} label={t('common.loading')} />
<div ref={scrollRef} className="h-full overflow-y-auto px-4 pt-[calc(var(--m-safe-top,12px)+52px)] pb-[calc(var(--bottom-nav-h,84px)+16px)]">
<div ref={scrollRef} className="h-full overflow-y-auto px-4 pt-[calc(var(--m-safe-top,12px)+52px)] pb-[calc(var(--bottom-nav-h,84px)+16px)] wide:px-6 wide:pt-[calc(var(--m-safe-top,12px)+72px)] wide:[&>*]:mx-auto wide:[&>*]:max-w-[1040px]">
{loading && list.length === 0 ? (
<div className="flex justify-center py-16">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-[color:var(--m-rowbr)] border-t-m-ink" />
@@ -65,14 +70,16 @@ export default function MJourney() {
{rest.length > 0 && (
<>
{/* On a wide window it stands at the start, over the cards it
introduces, like every section label; the phone centres it. */}
<div className="mx-[2px] mb-3 mt-[2px] flex items-center gap-[10px]">
<span className="h-px flex-1 bg-[color:var(--m-rowbr)]" />
<span className="h-px flex-1 bg-[color:var(--m-rowbr)] wide:hidden" />
<span className="font-geist text-[0.625rem] font-bold uppercase tracking-[.09em] text-m-faint">
{t('mobileJourney.otherJourneys')}
</span>
<span className="h-px flex-1 bg-[color:var(--m-rowbr)]" />
</div>
<div className="grid grid-cols-2 gap-3">
<div className="grid grid-cols-2 gap-3 split:grid-cols-3 split:gap-4">
{rest.map(j => (
<GridCard key={j.id} journey={j} onOpen={() => navigate(`/journey/${j.id}`)} />
))}
@@ -104,6 +111,21 @@ export default function MJourney() {
)
}
function CreateButton({ label, onClick, grow = false }: { label: string; onClick: () => void; grow?: boolean }) {
return (
<button
type="button"
onClick={onClick}
className={`flex h-[38px] items-center justify-center gap-[6px] whitespace-nowrap rounded-full bg-m-act text-[0.78125rem] font-bold text-m-actfg ${
grow ? 'flex-1 px-[15px]' : 'flex-none px-5'
}`}
>
<Plus size={14} strokeWidth={2.4} />
{label}
</button>
)
}
function HeroCard({ journey, onOpen }: { journey: JourneyListItem; onOpen: () => void }) {
const { t } = useTranslation()
const src = journeyCoverSrc(journey.cover_image)
@@ -116,7 +138,7 @@ function HeroCard({ journey, onOpen }: { journey: JourneyListItem; onOpen: () =>
<button
type="button"
onClick={onOpen}
className="relative mb-3 block h-[220px] w-full overflow-hidden rounded-[26px] text-start shadow-[0_24px_56px_-22px_rgba(0,0,0,.5)]"
className="relative mb-3 block h-[220px] w-full overflow-hidden rounded-[26px] wide:h-[clamp(260px,38vh,420px)] text-start shadow-[0_24px_56px_-22px_rgba(0,0,0,.5)]"
>
{src ? (
<img src={src} alt="" className="absolute inset-0 h-full w-full object-cover" />
@@ -129,7 +151,7 @@ function HeroCard({ journey, onOpen }: { journey: JourneyListItem; onOpen: () =>
{t('mobileJourney.latestJourney')}
</span>
</span>
<div className="mt-[7px] truncate text-[1.4375rem] font-bold">{journey.title}</div>
<div dir="auto" className="mt-[7px] truncate text-[1.4375rem] font-bold">{journey.title}</div>
<div className="mt-[9px] flex gap-[6px]">
<HeroPill icon={<NotebookPen size={10} strokeWidth={2.2} />} label={t('mobileJourney.entriesCount', { count: journey.entry_count ?? 0 })} />
<HeroPill icon={<Image size={10} strokeWidth={2.2} />} label={t('mobileJourney.photosCount', { count: journey.photo_count ?? 0 })} />
@@ -164,7 +186,7 @@ function GridCard({ journey, onOpen }: { journey: JourneyListItem; onOpen: () =>
>
<div className="h-[74px]" style={journeyCoverStyle(journey)} />
<div className="px-3 pb-[13px] pt-[11px]">
<div className="truncate text-[0.875rem] font-extrabold">{journey.title}</div>
<div dir="auto" className="truncate text-[0.875rem] font-extrabold">{journey.title}</div>
{hasStats && (
<div className="mt-[9px] flex gap-[5px]">
<span className="inline-flex items-center gap-[3px] rounded-full bg-[color:var(--m-ic)] px-[7px] py-[2px] font-geist text-[0.5625rem] font-bold text-m-muted">
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'
import { useSearchParams } from 'react-router'
import { ChevronLeft, MapPin, Grid3x3, MoreHorizontal, Play, Image, Camera, EyeOff, Settings2, X, Route, ListChecks } from 'lucide-react'
import JourneyLayeredMap from '../../../components/Journey/JourneyLayeredMap'
@@ -27,6 +27,8 @@ import { useJourneyCarouselSync } from '../../../components/Journey/useJourneyCa
import { useGalleryUpload } from '../../../components/Journey/useGalleryUpload'
import { dayPrepText, dayPrepUnsettled } from '../../../components/Journey/journeyRecordingPrepModel'
import MJourneyEntrySheet from './MJourneyEntrySheet'
import { useIsSplit, useIsWide } from '../../../hooks/sizeClass'
import MJourneyFeed from './MJourneyFeed'
import MJourneySettingsSheet from './MJourneySettingsSheet'
import MJourneyDayImportSheet from './MJourneyDayImportSheet'
import MJourneyRecordingSheet from './MJourneyRecordingSheet'
@@ -40,7 +42,31 @@ import MJourneyRecordingSheet from './MJourneyRecordingSheet'
* (`embedded`): the trip's back button and controls sit on top, so the screen
* drops its own back button and leaves the trip's URL alone.
*/
/** A phone on its side: too short for the regular strip of cards and the map above it. */
const SHORT_WINDOW = '(max-height: 500px)'
function subscribeShort(onChange: () => void) {
const mq = window.matchMedia(SHORT_WINDOW)
mq.addEventListener('change', onChange)
return () => mq.removeEventListener('change', onChange)
}
const readShort = () => window.matchMedia(SHORT_WINDOW).matches
export default function MJourneyDetail({ journeyId, embedded = false }: JourneyDetailOptions = {}) {
// Larger cards on a wide window, where the strip has the room for the whole name.
const wide = useIsWide()
// With room for two panes the entries stand as a feed beside the map (MJourneyFeed)
// instead of the strip of cards over it.
const split = useIsSplit()
// On a short window the cards are smaller and the map is framed above them.
const short = useSyncExternalStore(subscribeShort, readShort, () => false)
// The map runs under the rail (m-bleed); framing keeps the journey clear of it.
// The rail's share is the root's own negative margin, as the browser resolves it.
const rootRef = useRef<HTMLDivElement>(null)
const [railInset, setRailInset] = useState(0)
useLayoutEffect(() => {
const root = rootRef.current
if (root) setRailInset(Math.max(0, -parseFloat(getComputedStyle(root).marginInlineStart) || 0))
}, [wide])
const {
id, navigate, toast, t, locale,
current, loading,
@@ -104,7 +130,16 @@ export default function MJourneyDetail({ journeyId, embedded = false }: JourneyD
const {
mapRef, carouselRef, cardRefs, activeIndex, focusIndex, setActiveIndex, scrubberDays,
syncMapToCard, handleMarkerClick, handleCardTap, openEntryById, jumpToDay,
} = useJourneyCarouselSync<JourneyEntry>({ entries, mapEntries: sidebarMapItems, onOpenEntry: setEditingEntry, syncMapOnTap: true })
} = useJourneyCarouselSync<JourneyEntry>({
entries, mapEntries: sidebarMapItems, onOpenEntry: setEditingEntry, syncMapOnTap: true, layoutKey: split,
})
const feed = split && view === 'timeline' && entries.length > 0
// The map's box changes when the feed comes or goes (a rotation, Split View).
useEffect(() => {
const timer = window.setTimeout(() => mapRef.current?.invalidateSize(), 320)
return () => window.clearTimeout(timer)
}, [feed, mapRef])
// Initial focus — give Leaflet time to initialise and fit bounds first. Opens on
// today when today is part of the journey (see openAtEntryId), rather than always
@@ -195,7 +230,7 @@ export default function MJourneyDetail({ journeyId, embedded = false }: JourneyD
observer.observe(node)
setRailHeight(node.offsetHeight)
return () => observer.disconnect()
}, [view, entries.length])
}, [view, entries.length, split])
const openUpload = useCallback(() => {
if (availableProviders.length > 0) setShowUploadMenu(true)
@@ -299,9 +334,14 @@ export default function MJourneyDetail({ journeyId, embedded = false }: JourneyD
return (
// h-dvh, not h-full: the shell stopped providing a definite height (#1809)
// and a map on a percentage of an auto-height parent collapses to zero.
<div className="relative h-dvh overflow-hidden">
{/* Integrated map — always mounted, the gallery overlays it */}
<div className="absolute inset-0 z-0">
<div ref={rootRef} className="m-bleed relative h-dvh overflow-hidden">
{/* Integrated map — always mounted, the gallery overlays it. Beside the feed
it begins where the feed ends, so its controls and its framing stay in
the part that can be seen. */}
<div
className="absolute inset-y-0 end-0 start-0 z-0"
style={feed ? { insetInlineStart: 'calc(var(--m-rail-w, 0px) + var(--m-pane-aside-w))' } : undefined}
>
<JourneyLayeredMap
ref={mapRef}
layers={mapLayers}
@@ -320,20 +360,21 @@ export default function MJourneyDetail({ journeyId, embedded = false }: JourneyD
onMarkerClick={handleMarkerClick}
onMarkerCardClick={openEntryById}
fullScreen
paddingBottom={250}
paddingBottom={short ? 280 : 250}
paddingStart={feed ? 0 : railInset}
/>
</div>
{/* Gallery tab overlay */}
{view === 'gallery' && (
<div className="absolute inset-0 z-[5] overflow-y-auto bg-[color:var(--m-bg)] bg-[image:var(--m-scr)] px-4 pt-[calc(var(--m-safe-top,12px)+56px)] pb-[calc(var(--bottom-nav-h,84px)+16px)]">
<div className="m-span-x absolute inset-y-0 z-[5] overflow-y-auto bg-[color:var(--m-bg)] bg-[image:var(--m-scr)] px-4 pt-[calc(var(--m-safe-top,12px)+56px)] pb-[calc(var(--bottom-nav-h,84px)+16px)] wide:px-6">
{gallery.length === 0 ? (
<div className="flex min-h-full flex-col items-center justify-center px-8 py-10 text-center">
<MDancingTrek scene="journey" className="mb-2" />
<p className="font-geist text-[0.8125rem] font-medium text-m-muted">{t('journey.detail.noPhotos')}</p>
</div>
) : (
<div className="grid grid-cols-2 gap-2">
<div className="grid grid-cols-2 gap-2 wide:grid-cols-[repeat(auto-fill,minmax(180px,1fr))] wide:gap-3">
{gallery.map((photo, i) => (
// A wrapper, not one button: the delete sits beside the tile's own
// button instead of inside it (#2593).
@@ -373,8 +414,37 @@ export default function MJourneyDetail({ journeyId, embedded = false }: JourneyD
</div>
)}
{/* Header: back — segment — upload / overflow menu */}
<div className="absolute inset-x-4 top-[var(--m-safe-top,12px)] z-10 flex items-center justify-between gap-2">
{/* The entries as a feed beside the map, on a window with room for two panes. */}
{feed && (
<div
className="absolute inset-y-0 z-[6] overflow-y-auto overflow-x-hidden border-e border-[color:var(--m-rowbr)] bg-[color:var(--m-bg)] bg-[image:var(--m-scr)] px-4 pb-[calc(var(--bottom-nav-h,84px)+16px)] pt-[calc(var(--m-safe-top,12px)+60px)]"
// Entries scrolled up fade out under the header's pills instead of showing through between them.
style={{
insetInlineStart: 'var(--m-rail-w, 0px)',
width: 'var(--m-pane-aside-w)',
maskImage: 'linear-gradient(180deg, transparent calc(var(--m-safe-top, 12px) + 22px), #000 calc(var(--m-safe-top, 12px) + 58px))',
}}
>
<MJourneyFeed
entries={entries}
days={scrubberDays}
activeIndex={activeIndex}
cardRefs={cardRefs}
onCardTap={handleCardTap}
showMood={current.show_mood !== 0}
showWeather={current.show_weather !== 0}
t={t}
locale={locale}
/>
</div>
)}
{/* Header: back — segment — upload / overflow menu. Beside the feed it keeps
to the feed, as the trip's controls keep to its pane. */}
<div
className="m-bar-x absolute top-[var(--m-safe-top,12px)] z-10 flex items-center justify-between gap-2"
style={feed ? { insetInlineEnd: 'calc(100% - var(--m-rail-w, 0px) - var(--m-pane-aside-w) + 16px)' } : undefined}
>
{embedded ? (
<span className="w-[38px] flex-none" aria-hidden />
) : (
@@ -384,7 +454,7 @@ export default function MJourneyDetail({ journeyId, embedded = false }: JourneyD
aria-label={t('journey.detail.backToJourney')}
className="flex h-[38px] w-[38px] flex-none items-center justify-center rounded-full bg-[color:var(--m-sheet)] text-m-ink shadow-[0_5px_14px_-6px_rgba(0,0,0,.3)]"
>
<ChevronLeft size={18} strokeWidth={2.2} />
<ChevronLeft size={18} strokeWidth={2.2} className="rtl:-scale-x-100" />
</button>
)}
<div className="absolute left-1/2 top-0 flex -translate-x-1/2 rounded-full bg-[color:var(--m-sheet)] p-[3px] shadow-[0_5px_14px_-6px_rgba(0,0,0,.3)]">
@@ -489,16 +559,20 @@ export default function MJourneyDetail({ journeyId, embedded = false }: JourneyD
inside the 84px the variable reserves, so +16 left a band of map doing
nothing between the cards and it; two pixels keeps a hair of daylight and
gives the strip the rest. */}
{view === 'timeline' && entries.length > 0 && (
<div ref={railRef} className="absolute inset-x-0 z-[8] bottom-[calc(var(--bottom-nav-h,84px)+2px)]">
<JourneyDayScrubber
days={scrubberDays}
activeDate={entries[activeIndex]?.entry_date ?? null}
onPick={jumpToDay}
/>
{view === 'timeline' && entries.length > 0 && !feed && (
<div ref={railRef} className="m-span-x absolute z-[8] bottom-[calc(var(--bottom-nav-h,84px)+2px)]">
{/* The days and the date on a plate of their own, so the map's names do
not run through them. */}
<div className="mx-3 rounded-[18px] border border-[color:var(--m-gbr)] bg-[color:var(--m-glass)] pt-[6px] backdrop-blur-[18px] backdrop-saturate-[1.4] empty:hidden">
<JourneyDayScrubber
days={scrubberDays}
activeDate={entries[activeIndex]?.entry_date ?? null}
onPick={jumpToDay}
/>
</div>
<div
ref={carouselRef}
className="flex items-end gap-[22px] overflow-x-auto px-6 pb-1 pt-[30px] [-webkit-overflow-scrolling:touch] [scrollbar-width:none]"
className={`flex items-end gap-[22px] overflow-x-auto px-6 pb-1 ${short ? 'pt-[22px]' : 'pt-[30px]'} [-webkit-overflow-scrolling:touch] [scrollbar-width:none]`}
style={{ scrollSnapType: 'x mandatory' }}
>
{entries.map((entry, i) => (
@@ -515,6 +589,7 @@ export default function MJourneyDetail({ journeyId, embedded = false }: JourneyD
showMood={current.show_mood !== 0}
showWeather={current.show_weather !== 0}
tone="mobile"
size={wide ? 'large' : short ? 'compact' : 'regular'}
/>
</div>
))}

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