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.
This commit is contained in:
Maurice
2026-11-07 16:37:58 +01:00
parent 36ed0e0d23
commit 0b7cee0b0d
56 changed files with 2743 additions and 528 deletions
+43
View File
@@ -976,3 +976,46 @@ 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: 900px) and (min-height: 600px) and (orientation: landscape) {
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));
}
.screen-leave {
inset-inline: max(32px, calc(25vw - 220px));
}
}
@media (min-width: 700px) and (min-height: 900px) and (orientation: portrait) {
body::before,
body::after {
height: 52vh;
}
}
+13 -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.",
@@ -1055,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,
-1
View File
@@ -83,7 +83,6 @@
"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,
+5 -5
View File
@@ -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,7 +181,7 @@
"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/MNewTripSheet.tsx": 12,
@@ -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": 8,
"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,4 +1,4 @@
// 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';
@@ -285,3 +285,60 @@ 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 = () => {};
class Observer {
constructor(callback: ResizeObserverCallback) {
report = () => callback([], this as unknown as ResizeObserver);
}
observe() {}
unobserve() {}
disconnect() {}
}
function mountList(scrollHeight: number) {
let scrollTop = 0;
const list = document.createElement('div');
Object.defineProperty(list, 'scrollHeight', { configurable: true, get: () => scrollHeight });
Object.defineProperty(list, 'clientHeight', { configurable: true, get: () => 400 });
Object.defineProperty(list, 'scrollTop', {
configurable: true,
get: () => scrollTop,
set: (v: number) => {
scrollTop = v;
},
});
const hook = renderHook(() => {
const chat = useCollabChatData({
tripId: 1,
t,
toast: { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() },
});
(chat.scrollRef as { current: HTMLDivElement | null }).current = list;
return chat;
});
return { hook, list, grow: (to: number) => (scrollHeight = to) };
}
afterEach(() => vi.unstubAllGlobals());
it('FE-COLLAB-CHATDATA-017: a list at its newest message stays there when a rotation or a fold resizes it', () => {
vi.stubGlobal('ResizeObserver', Observer);
const { list, grow } = mountList(400);
grow(900);
report();
expect(list.scrollTop).toBe(900);
});
it('FE-COLLAB-CHATDATA-018: a list read further up keeps its place', () => {
vi.stubGlobal('ResizeObserver', Observer);
const { hook, list, grow } = mountList(2000);
list.scrollTop = 200;
hook.result.current.checkAtBottom();
grow(2600);
report();
expect(list.scrollTop).toBe(200);
});
});
@@ -77,6 +77,19 @@ 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.
useEffect(() => {
const el = scrollRef.current;
if (!el || typeof ResizeObserver === 'undefined') return;
const observer = new ResizeObserver(() => {
if (isAtBottom.current) el.scrollTop = el.scrollHeight;
});
observer.observe(el);
return () => observer.disconnect();
}, []);
useEffect(() => {
let cancelled = false;
setLoading(true);
@@ -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
@@ -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
+1 -1
View File
@@ -62,7 +62,7 @@ export default function MNavRail() {
? [
{
id: '/admin',
label: t('nav.bottomAdmin'),
label: t('nav.admin'),
icon: Shield,
active: isActive('/admin'),
go: () => void navigate('/admin'),
+20 -10
View File
@@ -1,5 +1,7 @@
import type { ReactNode } from 'react';
import { useFitLabel } from './useFitLabel';
/**
* The glass 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
@@ -32,9 +34,9 @@ interface MRailButtonProps {
/**
* One destination: the icon on the dock's 40px circle, filled while it is the
* current one, its name underneath. Names wrap to two lines rather than being
* cut, since a rail entry has no other way to say what it is; the title gives
* the whole name to a pointer hovering it.
* current one, its name underneath. The name is never cut: it wraps to two
* lines and, where a word is still too long for the rail, shrinks to fit
* (useFitLabel), since a touch screen has no hover to tell the rest.
*/
export function MRailButton({ label, active, onClick, children }: MRailButtonProps) {
return (
@@ -53,13 +55,21 @@ export function MRailButton({ label, active, onClick, children }: MRailButtonPro
>
{children}
</span>
<span
className={`line-clamp-2 w-full hyphens-auto break-words text-center font-geist text-[length:calc(10px*var(--fs-scale-caption,1))] leading-[1.2] ${
active ? 'font-bold text-m-ink' : 'font-semibold text-m-muted'
}`}
>
{label}
</span>
<RailLabel label={label} active={active} />
</button>
);
}
function RailLabel({ label, active }: { label: string; active: boolean }) {
const ref = useFitLabel<HTMLSpanElement>(label);
return (
<span
ref={ref}
className={`line-clamp-2 w-full text-center font-geist text-[length:calc(10px*var(--fs-scale-caption,1)*var(--fit,1))] leading-[1.2] ${
active ? 'font-bold text-m-ink' : 'font-semibold text-m-muted'
}`}
>
{label}
</span>
);
}
@@ -0,0 +1,90 @@
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>
);
}
interface MSectionNavProps {
title: string;
backLabel: string;
onBack: () => void;
sections: MSection[];
activeId: string;
onPick: (id: string) => void;
/** An action of the screen, at the end of the list's head (admin: a new user). */
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>
{action}
</div>
{sections.map((section) => (
<MSectionRow
key={section.id}
section={section}
active={section.id === activeId}
current={section.id === activeId}
onPick={() => onPick(section.id)}
/>
))}
</nav>
);
}
@@ -0,0 +1,62 @@
import { useLayoutEffect, useRef } from 'react';
/** The smallest share of its size a label may shrink to. */
const MIN_SCALE = 0.72;
const STEP = 0.04;
/**
* Fits a short label into a narrow, fixed box, such as a navigation rail
* entry: a name in its own words, on at most two lines, whatever the language.
*
* The label wraps between words. A word that is still too wide for the box
* (German "Einstellungen", Russian "Администрирование") first shrinks the
* label, step by step down to MIN_SCALE, the way a tab bar fits its titles; a
* name read whole beats one split over two lines. Only a word too long even
* then is hyphenated, where the browser knows the language, and past that
* broken anywhere. A tap target has no hover, so the whole name has to be on
* screen.
*
* What it settles on is written to the element's own style: `--fit`, which the
* element multiplies its font size with, and its hyphenation and wrapping. A
* measurement is a fact about the laid-out element, not React state, and
* keeping it there spares a second render of every rail entry.
*/
export function useFitLabel<T extends HTMLElement>(label: string) {
const ref = useRef<T>(null);
useLayoutEffect(() => {
const el = ref.current;
if (!el) return;
let cancelled = false;
const fits = () => {
const lineHeight = parseFloat(getComputedStyle(el).lineHeight) || 0;
const tooWide = el.scrollWidth > el.clientWidth + 0.5;
const tooTall = lineHeight > 0 && el.scrollHeight > lineHeight * 2 + 1;
return !tooWide && !tooTall;
};
const shrink = (hyphens: 'manual' | 'auto') => {
el.style.hyphens = hyphens;
let scale = 1;
el.style.setProperty('--fit', '1');
while (!fits() && scale - STEP >= MIN_SCALE) {
scale = Math.round((scale - STEP) * 100) / 100;
el.style.setProperty('--fit', String(scale));
}
return fits();
};
const measure = () => {
if (cancelled) return;
el.style.overflowWrap = 'normal';
if (shrink('manual') || shrink('auto')) return;
el.style.overflowWrap = 'anywhere';
};
measure();
// The label's font may arrive after the first layout, and it measures differently.
void document.fonts?.ready.then(measure);
return () => {
cancelled = true;
};
}, [label]);
return ref;
}
+39
View File
@@ -158,6 +158,16 @@
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. */
@@ -182,6 +192,13 @@
.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 (min-width: 600px) and (min-height: 501px) {
.m-panel-in { animation: m-panel-in 320ms var(--ease-out-quint) both; }
[dir='rtl'] .m-panel-in { animation-name: m-panel-in-rtl; }
@media (prefers-reduced-motion: reduce) {
.m-panel-in, [dir='rtl'] .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) {
@@ -559,6 +576,28 @@
}
}
/* 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);
}
}
.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
+39 -14
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, 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.
@@ -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)] wide:mx-auto wide:max-w-[1040px] wide:px-6">
<div className="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">
{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"
aria-label={t('admin.createUser')}
onClick={() => setShowCreateUser(true)}
className="flex h-[34px] w-[34px] flex-none items-center justify-center rounded-full bg-m-act text-m-actfg outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--m-act)] focus-visible:ring-offset-2"
>
<UserPlus size={15} strokeWidth={2.2} />
</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"
@@ -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>
)
@@ -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>
</>
);
}
@@ -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)] wide:px-6 ${c.view === 'map' ? 'flex h-dvh flex-col overflow-hidden' : 'wide:mx-auto wide:max-w-[1040px]'}`}
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>
)}
@@ -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}
@@ -36,8 +36,10 @@ 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="m-bar-x fixed top-[var(--m-safe-top,12px)] z-30 flex items-center gap-[10px]">
{/* Floating header: back + create pill. On a wide window it keeps to the
column of cards and names the page at its start, as an iPad title bar does. */}
<div className="m-bar-x fixed top-[var(--m-safe-top,12px)] z-30 flex items-center gap-[10px] wide:mx-auto wide:max-w-[1040px]">
<h1 className="hidden min-w-0 flex-1 truncate text-title font-extrabold text-m-ink wide:block">{t('journey.title')}</h1>
<button
type="button"
onClick={() => openCreateModal()}
@@ -27,7 +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 { useIsWide } from '../../../hooks/sizeClass'
import { useIsSplit, useIsWide } from '../../../hooks/sizeClass'
import MJourneyFeed from './MJourneyFeed'
import MJourneySettingsSheet from './MJourneySettingsSheet'
import MJourneyDayImportSheet from './MJourneyDayImportSheet'
import MJourneyRecordingSheet from './MJourneyRecordingSheet'
@@ -44,6 +45,9 @@ import MJourneyRecordingSheet from './MJourneyRecordingSheet'
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()
const {
id, navigate, toast, t, locale,
current, loading,
@@ -107,7 +111,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
@@ -198,7 +211,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)
@@ -303,8 +316,13 @@ export default function MJourneyDetail({ journeyId, embedded = false }: JourneyD
// 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="m-bleed relative h-dvh overflow-hidden">
{/* Integrated map — always mounted, the gallery overlays it */}
<div className="absolute inset-0 z-0">
{/* 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}
@@ -336,7 +354,7 @@ export default function MJourneyDetail({ journeyId, embedded = false }: JourneyD
<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).
@@ -376,8 +394,37 @@ export default function MJourneyDetail({ journeyId, embedded = false }: JourneyD
</div>
)}
{/* Header: back — segment — upload / overflow menu */}
<div className="m-bar-x absolute 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 />
) : (
@@ -492,7 +539,7 @@ 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 && (
{view === 'timeline' && entries.length > 0 && !feed && (
<div ref={railRef} className="m-span-x absolute z-[8] bottom-[calc(var(--bottom-nav-h,84px)+2px)]">
<JourneyDayScrubber
days={scrubberDays}
@@ -0,0 +1,206 @@
import { MapPin, Play } from 'lucide-react';
import type { MutableRefObject } from 'react';
import {
cardPhotoId,
cardPlace,
cardTitle,
dayColorOf,
type JourneyDay,
} from '../../../components/Journey/journeyCard';
import CountryFlag from '../../../components/shared/CountryFlag';
import { posterlessVideo } from '../../../pages/journeyDetail/JourneyDetailPage.helpers';
import type { JourneyEntry } from '../../../store/journeyStore';
import { moodMeta, storyExcerpt, weatherMeta } from './mobileJourneyMeta';
interface MJourneyFeedProps {
entries: JourneyEntry[];
days: JourneyDay[];
activeIndex: number;
/** The carousel's card refs: a marker tapped on the map scrolls its entry into view here. */
cardRefs: MutableRefObject<Map<number, HTMLDivElement>>;
onCardTap: (entry: JourneyEntry, index: number) => void;
showMood: boolean;
showWeather: boolean;
t: (key: string, params?: Record<string, string | number>) => string;
locale: string;
}
/**
* The journey as a feed beside its map, once the window has room for two panes
* (split size class): every entry in reading order under its day, with its
* photo, its place and the start of its story, where the phone has room only
* for the strip of cards over the map. The two stay in step the way the strip
* and the map do: a tap brings an entry to the map, a second one opens it, and
* a marker tapped on the map scrolls its entry into view.
*/
export default function MJourneyFeed({
entries,
days,
activeIndex,
cardRefs,
onCardTap,
showMood,
showWeather,
t,
locale,
}: MJourneyFeedProps) {
return (
<div className="flex flex-col">
{entries.map((entry, index) => {
const color = dayColorOf(days, entry.entry_date);
const newDay = index === 0 || entries[index - 1].entry_date !== entry.entry_date;
const dayNumber = days.findIndex((d) => d.date === entry.entry_date) + 1;
return (
<div key={entry.id}>
{newDay && (
<div className="mb-2 mt-5 flex items-center gap-2 px-[2px] first:mt-0">
<span className="h-[9px] w-[9px] flex-none rounded-full" style={{ background: color }} />
<span className="font-geist text-[length:calc(11px*var(--fs-scale-caption,1))] font-extrabold uppercase tracking-[.09em] text-m-ink">
{t('journey.detail.day', { number: dayNumber })}
</span>
<span className="font-geist text-[length:calc(11px*var(--fs-scale-caption,1))] font-semibold text-m-faint">
{new Date(entry.entry_date + 'T00:00:00').toLocaleDateString(locale, {
weekday: 'short',
day: 'numeric',
month: 'long',
})}
</span>
</div>
)}
<div
ref={(node) => {
if (node) cardRefs.current.set(index, node);
else cardRefs.current.delete(index);
}}
className="mb-[10px] scroll-my-[calc(var(--m-safe-top,12px)+64px)]"
>
<FeedCard
entry={entry}
color={color}
active={index === activeIndex}
onTap={() => onCardTap(entry, index)}
showMood={showMood}
showWeather={showWeather}
t={t}
/>
</div>
</div>
);
})}
</div>
);
}
interface FeedCardProps {
entry: JourneyEntry;
color: string;
active: boolean;
onTap: () => void;
showMood: boolean;
showWeather: boolean;
t: (key: string) => string;
}
function FeedCard({ entry, color, active, onTap, showMood, showWeather, t }: FeedCardProps) {
const suggestion = entry.type === 'skeleton';
const place = cardPlace(entry);
const story = storyExcerpt(entry.story);
return (
<button
type="button"
onClick={onTap}
aria-current={active ? 'true' : undefined}
className={`block w-full overflow-hidden rounded-2xl border bg-m-sheetop text-start outline-none transition-shadow focus-visible:ring-2 focus-visible:ring-[color:var(--m-act)] ${
suggestion ? 'border-dashed' : ''
}`}
// The day's colour is the card's edge, heavier on the entry the map shows.
style={{
borderColor: active ? color : 'var(--m-rowbr)',
boxShadow: active ? `0 0 0 1.5px ${color}, 0 14px 32px -20px rgba(0,0,0,.45)` : undefined,
}}
>
<FeedPhoto entry={entry} />
<span className="flex flex-col gap-[5px] px-[13px] pb-3 pt-[11px]">
<span className="flex items-start gap-2">
<span
className={`min-w-0 flex-1 text-body font-extrabold leading-[1.3] text-m-ink ${suggestion ? 'italic text-m-muted' : ''}`}
>
{cardTitle(entry, t)}
</span>
<FeedMarks entry={entry} showMood={showMood} showWeather={showWeather} />
</span>
{place && (
<span className="flex min-w-0 items-center gap-[5px] text-m-muted">
{entry.country_code ? (
<CountryFlag code={entry.country_code} size={12} />
) : (
<MapPin size={11} strokeWidth={2.2} className="flex-none" />
)}
<span className="truncate font-geist text-[length:calc(11px*var(--fs-scale-caption,1))] font-semibold">
{place}
</span>
</span>
)}
{story && <span className="line-clamp-3 text-caption leading-[1.45] text-m-ink opacity-80">{story}</span>}
</span>
</button>
);
}
/** The entry's first photo across the card's head, with a count of the others. */
function FeedPhoto({ entry }: { entry: JourneyEntry }) {
const first = entry.photos?.[0];
const photoId = cardPhotoId(first);
const posterless = first != null && posterlessVideo(first);
if (photoId == null && !posterless) return null;
const morePhotos = (entry.photos?.length ?? 0) - 1;
return (
<span className="relative block aspect-[16/9] w-full overflow-hidden bg-[color:var(--m-ic)]">
{posterless ? (
<span className="absolute inset-0 flex items-center justify-center text-m-muted">
<Play size={26} strokeWidth={1.8} fill="currentColor" />
</span>
) : (
<img
src={`/api/photos/${photoId}/thumbnail`}
alt=""
loading="lazy"
className="absolute inset-0 h-full w-full object-cover"
/>
)}
{morePhotos > 0 && (
<span className="absolute bottom-2 end-2 rounded-full bg-[color:var(--overlay)] px-[8px] py-[2px] font-geist text-[length:calc(10px*var(--fs-scale-caption,1))] font-bold text-[color:var(--m-knob)] backdrop-blur">
+{morePhotos}
</span>
)}
</span>
);
}
/** How the moment was and when, as marks beside the title. */
function FeedMarks({ entry, showMood, showWeather }: { entry: JourneyEntry; showMood: boolean; showWeather: boolean }) {
const mood = showMood ? moodMeta(entry.mood) : undefined;
const weather = showWeather ? weatherMeta(entry.weather) : undefined;
if (!mood && !weather && !entry.entry_time) return null;
const mark = 'flex h-[22px] w-[22px] items-center justify-center rounded-full bg-[color:var(--m-ic)]';
return (
<span className="flex flex-none items-center gap-1">
{mood && (
<span className={mark} style={{ color: mood.color }}>
<mood.icon size={12} strokeWidth={2.4} />
</span>
)}
{weather && (
<span className={`${mark} text-m-muted`}>
<weather.icon size={12} strokeWidth={2.4} />
</span>
)}
{entry.entry_time && (
<span className="rounded-full bg-[color:var(--m-ic)] px-[8px] py-[2px] font-geist text-[length:calc(10px*var(--fs-scale-caption,1))] font-bold tabular-nums text-m-muted">
{entry.entry_time.slice(0, 5)}
</span>
)}
</span>
);
}
@@ -57,3 +57,17 @@ export function journeyCoverStyle(journey: { id: number; cover_image?: string |
if (src) return { backgroundImage: `url('${src}')`, backgroundSize: 'cover', backgroundPosition: 'center' }
return { background: pickGradient(journey.id) }
}
/**
* The opening of a story as plain text for the card: the reader opens the entry
* for the formatted story, and links inside a tappable card would compete with it.
*/
export function storyExcerpt(story: string | null | undefined): string {
if (!story) return ''
return story
.replace(/!\[[^\]]*\]\([^)]*\)/g, '')
.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
.replace(/[#>*_`~]+/g, '')
.replace(/\s+/g, ' ')
.trim()
}
@@ -40,7 +40,8 @@ export default function MNotifications() {
return (
<>
<MGlassBar>
{/* On a wide window the bar keeps to the column of the list below it. */}
<MGlassBar className="wide:mx-auto wide:max-w-[760px]">
<MIconBtn ariaLabel={t('common.back')} onClick={() => navigate(-1)}>
<ArrowLeft size={18} strokeWidth={2} />
</MIconBtn>
@@ -14,7 +14,6 @@ import {
SlidersHorizontal,
User,
} from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import { useTranslation } from '../../../i18n'
import { useSettings } from '../../../pages/settings/useSettings'
import MSettingsPlugins from './MSettingsPlugins'
@@ -28,12 +27,7 @@ import MSettingsAccount from './MSettingsAccount'
import MSettingsAbout from './MSettingsAbout'
import MSettingsServer from './MSettingsServer'
import { useIsSplit } from '../../../hooks/sizeClass'
interface SectionTab {
id: string
label: string
icon: LucideIcon
}
import MSectionNav, { MSectionRow, type MSection } from '../../components/MSectionNav'
/**
* Mobile settings screen: back + section-switcher pill on top, the section
@@ -50,7 +44,7 @@ export default function MSettings() {
// the page, the way Settings on an iPad does, instead of behind a dropdown.
const split = useIsSplit()
const tabs: SectionTab[] = [
const tabs: MSection[] = [
{ id: 'display', label: t('settings.tabs.display'), icon: SlidersHorizontal },
{ id: 'appearance', label: t('settings.tabs.appearance'), icon: Palette },
{ id: 'map', label: t('settings.tabs.map'), icon: Map },
@@ -69,38 +63,17 @@ export default function MSettings() {
const active = tabs.find((tab) => tab.id === activeTab) || tabs[0]
const sectionRow = (tab: SectionTab, onPick: () => void) => (
<button
key={tab.id}
type="button"
onClick={onPick}
aria-current={split && tab.id === active.id ? 'page' : undefined}
className={`flex w-full items-center gap-[10px] rounded-[11px] p-[10px] text-start text-[0.8125rem] font-semibold text-m-ink outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--m-act)] ${
tab.id === active.id ? 'bg-[color:var(--m-ic)]' : 'hover:bg-[color:var(--m-ic)]'
}`}
>
<tab.icon size={15} strokeWidth={2} className="flex-none" />
{tab.label}
</button>
)
return (
<div className="px-4 pb-[calc(var(--bottom-nav-h,84px)+16px)] pt-[var(--m-safe-top,12px)] wide:px-6 split:mx-auto split:grid split:max-w-[1120px] split:grid-cols-[260px_minmax(0,1fr)] split:items-start split:gap-8">
{split && (
<nav aria-label={t('settings.title')} className="sticky top-[var(--m-safe-top,12px)] flex flex-col gap-1 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)]">
<div className="mb-1 flex items-center gap-2 p-[4px]">
<button
type="button"
aria-label={t('common.back')}
onClick={() => void navigate('/dashboard')}
className="flex h-[34px] w-[34px] flex-none items-center justify-center rounded-full text-m-ink hover:bg-[color:var(--m-ic)]"
>
<ChevronLeft size={18} strokeWidth={2.2} className="rtl:-scale-x-100" />
</button>
<span className="min-w-0 truncate text-subtitle font-bold text-m-ink">{t('settings.title')}</span>
</div>
{tabs.map((tab) => sectionRow(tab, () => setActiveTab(tab.id)))}
</nav>
<MSectionNav
title={t('settings.title')}
backLabel={t('common.back')}
onBack={() => void navigate('/dashboard')}
sections={tabs}
activeId={active.id}
onPick={setActiveTab}
/>
)}
<div className="min-w-0 wide:mx-auto wide:w-full wide:max-w-[720px] split:mx-0">
{/* Header: back + section switcher pill */}
@@ -130,12 +103,17 @@ export default function MSettings() {
{/* Section dropdown, in flow (pushes the content down like the demo) */}
{dropOpen && !split && (
<div className="m-pop-in -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)]">
{tabs.map((tab) =>
sectionRow(tab, () => {
setActiveTab(tab.id)
setDropOpen(false)
}),
)}
{tabs.map((tab) => (
<MSectionRow
key={tab.id}
section={tab}
active={tab.id === active.id}
onPick={() => {
setActiveTab(tab.id)
setDropOpen(false)
}}
/>
))}
</div>
)}
+16 -25
View File
@@ -28,7 +28,7 @@ import { getIntlLanguage } from '@trek/shared'
import { useDayPreviewStore } from '../../dayPreviewStore'
import { useIsSplit, useIsWide } from '../../../hooks/sizeClass'
import MTripRail from './MTripRail'
import { CompactToggle, DocSyncButton, GlassSegment } from './MTripShellParts'
import { CollabSegment, CompactToggle, DocSyncButton, GlassSegment } from './MTripShellParts'
import { edgeFadeClass, useEdgeFade } from '../../components/useEdgeFade'
import { useShortcuts } from '../../keyboard/useShortcuts'
@@ -338,6 +338,13 @@ export default function MTripShell({
setMode(next)
setView('plan')
if (trTab !== 'plan') planner.handleTabChange('plan')
// The timeline shows one day and renders empty without one. Beside the map
// the places keep the all-days switch, so leaving them for the plan brings
// back the day an "all days" put away.
if (next !== 'browse' && planner.selectedDayId == null) {
const back = dayToRestore()
if (back != null) planner.handleSelectDay(back, true)
}
}
// Map focus on the selected day. Drives the same declutter the desktop sidebar's
@@ -429,6 +436,10 @@ export default function MTripShell({
// The pane is open while the plan tab shows its timeline or the places, or the
// road trip tab its chain. The list/map switch closes it for the whole map.
const paneOpen = split && ((trTab === 'plan' && (view === 'plan' || mode === 'browse')) || (trTab === 'roadtrip' && rtView === 'list'))
// The whole trip at once is a question for the map alone: beside the plan or the
// road trip chain, both of which show one day, the switch would empty the pane.
// The places beside the map list the whole trip, so they keep it.
const allDaysOffered = mapFront && !(paneOpen && (trTab === 'roadtrip' || mode !== 'browse'))
// With the plan beside the map, the map shows the day the plan shows: its
// places framed, its route drawn, the other days' pins put away, exactly as
@@ -670,7 +681,7 @@ export default function MTripShell({
nothing to widen there. Stays mounted while a day is active rather than
appearing on selection, which would shove the rail sideways under the
thumb. On the road trip map it means the whole drive in its day colours. */}
{mapFront && (
{allDaysOffered && (
<button
type="button"
onClick={toggleAllDays}
@@ -757,7 +768,7 @@ export default function MTripShell({
)}
{trTab === 'finanzplan' && (
<div className="absolute left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 items-center gap-[7px]">
<div className="absolute inset-x-0 top-1/2 mx-auto flex w-fit -translate-y-1/2 items-center gap-[7px]">
<PrimaryPill label={t('costs.addExpense')} onClick={() => setAddExpenseSignal(s => s + 1)} />
<MReceiptScanButton tripId={tripId} canEdit={planner.can('budget_edit', trip)} />
<MIconBtn ariaLabel={t('budget.exportCsv')} onClick={() => setExportCostsCsvSignal(s => s + 1)} size={40} className="text-m-muted backdrop-blur-[24px] backdrop-saturate-[1.7]">
@@ -789,27 +800,7 @@ export default function MTripShell({
</GlassSegment>
)}
{trTab === 'collab' && (
<GlassSegment>
{([
{ value: 'chat' as const, label: t('collab.tabs.chat') },
{ value: 'notes' as const, label: t('collab.tabs.notes') },
{ value: 'links' as const, label: t('collab.tabs.links') || 'Links' },
{ value: 'polls' as const, label: t('collab.tabs.polls') },
]).map(seg => (
<button
key={seg.value}
type="button"
onClick={() => setCollabTab(seg.value)}
className={`whitespace-nowrap rounded-full px-4 py-2 text-[0.8125rem] ${
collabTab === seg.value ? 'bg-m-act font-semibold text-m-actfg' : 'font-medium text-m-ink'
}`}
>
{seg.label}
</button>
))}
</GlassSegment>
)}
{trTab === 'collab' && <CollabSegment active={collabTab} onPick={setCollabTab} features={planner.collabFeatures} split={split} t={t} />}
{/* The stage's own header: the day it shows, and a way into the figures
behind it. A button rather than the plugin tabs' inert pill, because it
@@ -843,7 +834,7 @@ export default function MTripShell({
)}
{trTab === 'dateien' && (
<div className="absolute left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 items-center gap-[7px]">
<div className="absolute inset-x-0 top-1/2 mx-auto flex w-fit -translate-y-1/2 items-center gap-[7px]">
<PrimaryPill icon={<Upload size={13} strokeWidth={2.2} />} label={t('common.upload')} onClick={() => setUploadFilesSignal(s => s + 1)} />
<DocSyncButton tripId={tripId} trip={trip} label={t('docsync.title')} onOpen={() => setOpenDocSyncSignal(s => s + 1)} />
<MIconBtn ariaLabel={t('files.trash')} onClick={() => setOpenFilesTrashSignal(s => s + 1)} size={40} className="text-m-muted backdrop-blur-[24px] backdrop-saturate-[1.7]">
@@ -5,18 +5,74 @@ import { useDocSyncOffered } from '../../../components/Files/docsync/useDocSyncO
import { useAuthStore } from '../../../store/authStore';
import type { Trip } from '../../../types';
import MIconBtn from '../../components/MIconBtn';
import type { MTripCollabTab } from './MTripShell';
import { collabBoardFor, collabSideBySide } from './tabs/collabPanes';
// The small pieces of the trip screen's top controls (MTripShell).
/** Centre glass pill of the top controls (segments for plan/lists/collab). */
export function GlassSegment({ children }: { children: ReactNode }) {
return (
<div className="absolute left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 rounded-full border border-[color:var(--m-gbr)] bg-[color:var(--m-glass)] p-[3px] backdrop-blur-[24px] backdrop-saturate-[1.7]">
<div className="absolute inset-x-0 top-1/2 mx-auto flex w-fit -translate-y-1/2 rounded-full border border-[color:var(--m-gbr)] bg-[color:var(--m-glass)] p-[3px] backdrop-blur-[24px] backdrop-saturate-[1.7]">
{children}
</div>
);
}
interface CollabSegmentProps {
active: MTripCollabTab;
onPick: (tab: MTripCollabTab) => void;
features: { chat: boolean; notes: boolean; links: boolean; polls: boolean };
split: boolean;
t: (key: string) => string;
}
/**
* The collab tab's switch. Where the chat stands beside the board (MCollabTab),
* it switches only the board and sits centred over it, and the chat's name sits
* over the chat, so each pane has its heading where the eye looks for it.
*/
export function CollabSegment({ active, onPick, features, split, t }: CollabSegmentProps) {
const sideBySide = collabSideBySide(split, features);
const current = sideBySide ? collabBoardFor(active, features) : active;
const segments = [
{ value: 'chat' as const, label: t('collab.tabs.chat') },
{ value: 'notes' as const, label: t('collab.tabs.notes') },
{ value: 'links' as const, label: t('collab.tabs.links') },
{ value: 'polls' as const, label: t('collab.tabs.polls') },
].filter((seg) => !sideBySide || seg.value !== 'chat');
const pill = (
<GlassSegment>
{segments.map((seg) => (
<button
key={seg.value}
type="button"
onClick={() => onPick(seg.value)}
aria-pressed={current === seg.value}
className={`whitespace-nowrap rounded-full px-4 py-2 text-[length:calc(13px*var(--fs-scale-body,1))] ${
current === seg.value ? 'bg-m-act font-semibold text-m-actfg' : 'font-medium text-m-ink'
}`}
>
{seg.label}
</button>
))}
</GlassSegment>
);
if (!sideBySide) return pill;
// The controls row ends 16px short of the window's edge, where the chat pane
// ends too; the two boxes split the row where the panes meet.
return (
<>
<div className="absolute inset-y-0 end-[calc(var(--m-pane-trail-w)-16px)] start-0">{pill}</div>
<div className="pointer-events-none absolute inset-y-0 end-[-16px] flex w-[var(--m-pane-trail-w)] items-center justify-center">
<span className="rounded-full border border-[color:var(--m-gbr)] bg-[color:var(--m-glass)] px-4 py-[9px] text-[length:calc(13px*var(--fs-scale-body,1))] font-semibold text-m-ink backdrop-blur-[24px] backdrop-saturate-[1.7]">
{t('collab.tabs.chat')}
</span>
</div>
</>
);
}
/**
* The Files header's sync button, only where there is something behind it.
*
@@ -196,147 +196,159 @@ export function AssistantScreen({ tripId, tripTitle, hidden, hops, onOpenTripCha
};
const threads = thread.threads ?? [];
// A phone gives the chat the whole screen. A wide window keeps the page in view
// and gives the chat a tall panel at the trailing edge, as Slide Over would,
// over a dimmed page that closes it on a tap, like the window's form sheets.
return (
<section
ref={sectionRef}
role="dialog"
aria-modal="true"
aria-label={t('assistant.window.title')}
hidden={hidden}
tabIndex={-1}
onKeyDown={(e) => keepTabInside(e, sectionRef.current)}
className={`fixed inset-0 z-[var(--z-bar)] ${hidden ? 'hidden' : 'flex'} flex-col bg-[var(--m-bg)] bg-[image:var(--m-scr)] text-m-ink outline-none`}
style={{ paddingBottom: `max(env(safe-area-inset-bottom, 0px), ${keyboard}px)` }}
>
<header className="flex flex-none items-center gap-2 px-4 pb-2 pt-[var(--m-safe-top,12px)]">
<MIconBtn ariaLabel={t('common.back')} onClick={close}>
<ChevronLeft size={19} strokeWidth={2.2} />
</MIconBtn>
<div className="min-w-0 flex-1">
<h2 className="m-0 truncate text-body font-semibold">
<bdi>{thread.title ?? tripTitle}</bdi>
</h2>
{thread.title && (
<div className="truncate text-caption text-m-muted">
<bdi>{tripTitle}</bdi>
</div>
<>
{!hidden && (
<div
aria-hidden="true"
onClick={close}
className="m-fade-in fixed inset-0 z-[var(--z-bar)] hidden bg-[color:var(--m-dim)] wide:block"
/>
)}
<section
ref={sectionRef}
role="dialog"
aria-modal="true"
aria-label={t('assistant.window.title')}
hidden={hidden}
tabIndex={-1}
onKeyDown={(e) => keepTabInside(e, sectionRef.current)}
className={`fixed inset-0 z-[var(--z-bar)] ${hidden ? 'hidden' : 'flex'} m-panel-in flex-col bg-[var(--m-bg)] bg-[image:var(--m-scr)] text-m-ink outline-none wide:inset-auto wide:bottom-3 wide:end-3 wide:top-3 wide:w-[min(500px,calc(100vw-var(--m-rail-w,0px)-24px))] wide:overflow-hidden wide:rounded-[28px] wide:border wide:border-[color:var(--m-shbr)] wide:shadow-modal`}
style={{ paddingBottom: `max(env(safe-area-inset-bottom, 0px), ${keyboard}px)` }}
>
<header className="flex flex-none items-center gap-2 px-4 pb-2 pt-[var(--m-safe-top,12px)] wide:pt-3">
<MIconBtn ariaLabel={t('common.back')} onClick={close}>
<ChevronLeft size={19} strokeWidth={2.2} />
</MIconBtn>
<div className="min-w-0 flex-1">
<h2 className="m-0 truncate text-body font-semibold">
<bdi>{thread.title ?? tripTitle}</bdi>
</h2>
{thread.title && (
<div className="truncate text-caption text-m-muted">
<bdi>{tripTitle}</bdi>
</div>
)}
</div>
<MIconBtn ariaLabel={t('assistant.history.open')} ariaExpanded={panel === 'history'} onClick={openHistory}>
<History size={16} strokeWidth={2} />
</MIconBtn>
<MIconBtn
ariaLabel={t('assistant.history.new')}
onClick={thread.newThread}
disabled={thread.threadId === null && !thread.isRunning}
>
<Plus size={17} strokeWidth={2.2} />
</MIconBtn>
<MIconBtn
ariaLabel={t('assistant.window.menu')}
ariaExpanded={panel === 'menu'}
ariaHaspopup="menu"
onClick={() => setPanel('menu')}
>
<Ellipsis size={17} strokeWidth={2.2} />
</MIconBtn>
</header>
<MDropdownPanel
open={panel === 'history'}
onClose={() => setPanel(null)}
className="end-4 top-[calc(var(--m-safe-top,12px)+48px)] max-h-[60vh] overflow-y-auto"
>
<MListRow icon={Plus} label={t('assistant.history.new')} onClick={() => pick(thread.newThread)} />
{threads.length === 0 ? (
<p className="m-0 px-[10px] py-2 text-caption text-m-muted">{t('assistant.history.empty')}</p>
) : (
threads.map((th) => (
<MListRow
key={th.id}
label={th.title}
className={th.id === thread.threadId ? 'bg-[color:var(--m-ic)]' : ''}
current={th.id === thread.threadId}
onClick={() => pick(() => thread.openThread(th.id))}
trailing={
<span className="flex-none text-caption text-m-muted">
{new Date(th.lastMessageAt).toLocaleDateString(locale, { day: 'numeric', month: 'short' })}
</span>
}
/>
))
)}
</div>
<MIconBtn ariaLabel={t('assistant.history.open')} ariaExpanded={panel === 'history'} onClick={openHistory}>
<History size={16} strokeWidth={2} />
</MIconBtn>
<MIconBtn
ariaLabel={t('assistant.history.new')}
onClick={thread.newThread}
disabled={thread.threadId === null && !thread.isRunning}
>
<Plus size={17} strokeWidth={2.2} />
</MIconBtn>
<MIconBtn
ariaLabel={t('assistant.window.menu')}
ariaExpanded={panel === 'menu'}
ariaHaspopup="menu"
onClick={() => setPanel('menu')}
>
<Ellipsis size={17} strokeWidth={2.2} />
</MIconBtn>
</header>
{thread.retentionNote && (
<p className="m-0 px-[10px] pb-1 pt-2 text-caption text-m-muted">{thread.retentionNote}</p>
)}
</MDropdownPanel>
<MDropdownPanel
open={panel === 'history'}
onClose={() => setPanel(null)}
className="end-4 top-[calc(var(--m-safe-top,12px)+48px)] max-h-[60vh] overflow-y-auto"
>
<MListRow icon={Plus} label={t('assistant.history.new')} onClick={() => pick(thread.newThread)} />
{threads.length === 0 ? (
<p className="m-0 px-[10px] py-2 text-caption text-m-muted">{t('assistant.history.empty')}</p>
) : (
threads.map((th) => (
<MDropdownPanel
open={panel === 'menu'}
onClose={() => setPanel(null)}
className="end-4 top-[calc(var(--m-safe-top,12px)+48px)]"
>
{tripChat && (
<MListRow
key={th.id}
label={th.title}
className={th.id === thread.threadId ? 'bg-[color:var(--m-ic)]' : ''}
current={th.id === thread.threadId}
onClick={() => pick(() => thread.openThread(th.id))}
trailing={
<span className="flex-none text-caption text-m-muted">
{new Date(th.lastMessageAt).toLocaleDateString(locale, { day: 'numeric', month: 'short' })}
</span>
}
icon={NotebookPen}
label={t(memory.has ? 'assistant.memory.edit' : 'assistant.memory.title')}
onClick={() => pick(() => setMemoryOpen(true))}
/>
))
)}
{thread.retentionNote && (
<p className="m-0 px-[10px] pb-1 pt-2 text-caption text-m-muted">{thread.retentionNote}</p>
)}
</MDropdownPanel>
<MDropdownPanel
open={panel === 'menu'}
onClose={() => setPanel(null)}
className="end-4 top-[calc(var(--m-safe-top,12px)+48px)]"
>
{tripChat && (
)}
<MListRow
icon={NotebookPen}
label={t(memory.has ? 'assistant.memory.edit' : 'assistant.memory.title')}
onClick={() => pick(() => setMemoryOpen(true))}
icon={hints.on ? LightbulbOff : Lightbulb}
label={t(hints.on ? 'assistant.hint.menuOff' : 'assistant.hint.menuOn')}
onClick={() => pick(hints.toggle)}
/>
)}
<MListRow
icon={hints.on ? LightbulbOff : Lightbulb}
label={t(hints.on ? 'assistant.hint.menuOff' : 'assistant.hint.menuOn')}
onClick={() => pick(hints.toggle)}
/>
<MListRow
icon={Settings}
label={t('assistant.window.settings')}
onClick={() =>
pick(() => {
close();
void navigate(AI_SETTINGS_PATH);
})
}
/>
{thread.threadId !== null && !thread.isRunning && (
<MListRow
icon={Trash2}
danger
label={t('assistant.window.deleteThread')}
onClick={() => pick(() => setConfirming(true))}
icon={Settings}
label={t('assistant.window.settings')}
onClick={() =>
pick(() => {
close();
void navigate(AI_SETTINGS_PATH);
})
}
/>
)}
</MDropdownPanel>
{thread.threadId !== null && !thread.isRunning && (
<MListRow
icon={Trash2}
danger
label={t('assistant.window.deleteThread')}
onClick={() => pick(() => setConfirming(true))}
/>
)}
</MDropdownPanel>
{memoryOpen && <AssistantMemoryDialog tripId={tripId} onClose={() => setMemoryOpen(false)} />}
<div className="flex min-h-0 flex-1 flex-col">
<AssistantSurfaceLazy
tripId={tripId}
tripTitle={tripTitle}
variant="fullscreen"
onOpen={hops.open}
onFocusDay={hops.focusDay}
onRevealMap={hops.revealMap}
onOpenTripChat={onOpenTripChat}
// The phone's Studio has its own panel: a journey's chat here is the journal's.
journal={assistantScopeOf(tripId).kind === 'journey'}
{memoryOpen && <AssistantMemoryDialog tripId={tripId} onClose={() => setMemoryOpen(false)} />}
<div className="flex min-h-0 flex-1 flex-col">
<AssistantSurfaceLazy
tripId={tripId}
tripTitle={tripTitle}
variant="fullscreen"
onOpen={hops.open}
onFocusDay={hops.focusDay}
onRevealMap={hops.revealMap}
onOpenTripChat={onOpenTripChat}
// The phone's Studio has its own panel: a journey's chat here is the journal's.
journal={assistantScopeOf(tripId).kind === 'journey'}
/>
</div>
<MConfirmSheet
open={confirming}
onClose={() => setConfirming(false)}
title={t('assistant.window.deleteThread')}
message={t('assistant.window.deleteConfirm')}
confirmLabel={t('common.delete')}
cancelLabel={t('common.cancel')}
danger
onConfirm={() => {
setConfirming(false);
void thread.removeThread();
}}
/>
</div>
<MConfirmSheet
open={confirming}
onClose={() => setConfirming(false)}
title={t('assistant.window.deleteThread')}
message={t('assistant.window.deleteConfirm')}
confirmLabel={t('common.delete')}
cancelLabel={t('common.cancel')}
danger
onConfirm={() => {
setConfirming(false);
void thread.removeThread();
}}
/>
</section>
</section>
</>
);
}
@@ -355,7 +367,7 @@ function BackToChat({ tripId, running }: { tripId: number; running: boolean }) {
const glass =
'rounded-full border border-[color:var(--m-gbr)] bg-[color:var(--m-glass)] shadow-elevated backdrop-blur-[30px] backdrop-saturate-[1.8]';
return (
<div className="absolute inset-x-0 bottom-[calc(env(safe-area-inset-bottom,0px)+86px)] z-[42] mx-auto flex w-fit max-w-[calc(100%-2rem)] items-center gap-2">
<div className="absolute inset-x-0 bottom-[calc(env(safe-area-inset-bottom,0px)+86px)] z-[42] mx-auto flex w-fit max-w-[calc(100%-2rem)] items-center gap-2 wide:bottom-[calc(env(safe-area-inset-bottom,0px)+24px)]">
<button
type="button"
onClick={() => {
@@ -5,7 +5,7 @@ import { formatTime, formatPriceText } from '../../../../utils/formatters'
import { useTranslation } from '../../../../i18n'
import type { Reservation } from '../../../../types'
import { CardActions, CardDeleteSheet, CardFiles, CardNotes, CardTitleButton, CardWhenAndCode } from './MReservationCardParts'
import { Field, ReservationPluginSlots, SectionHeader, StatusDot, TabScroller, TravelerAvatars, TravelerFilterRow } from './tabChrome'
import { CardGrid, Field, ReservationPluginSlots, SectionHeader, StatusDot, TabScroller, TravelerAvatars, TravelerFilterRow } from './tabChrome'
import { STATUS_COLOR, type MTabScreenProps } from './tabModel'
import { cardWhen, parseTransportMeta } from './transportsModel'
import { BOOKING_TYPE_COLOR } from '../../../../components/Planner/bookings/bookingsModel'
@@ -36,7 +36,7 @@ export default function MBookingsTab({ planner, shell }: MTabScreenProps) {
].filter(s => s.rows.length > 0)
return (
<TabScroller>
<TabScroller wide="full">
{showTravelerFilter && (
<TravelerFilterRow
members={planner.tripMembers}
@@ -60,8 +60,9 @@ export default function MBookingsTab({ planner, shell }: MTabScreenProps) {
open={!collapsed[section.id]}
onToggle={() => toggle(section.id)}
/>
{!collapsed[section.id] &&
section.rows.map(res => (
{!collapsed[section.id] && (
<CardGrid>
{section.rows.map(res => (
<BookingCard
key={res.id}
res={res}
@@ -69,7 +70,9 @@ export default function MBookingsTab({ planner, shell }: MTabScreenProps) {
canEdit={canEdit}
compact={shell.bookingsCompact}
/>
))}
))}
</CardGrid>
)}
</div>
))}
</TabScroller>
@@ -152,7 +152,7 @@ export default function MCollabNotes({ planner }: MCollabNotesProps) {
}
return (
<TabScroller>
<TabScroller wide="full">
<div className="flex items-center justify-between gap-2">
{categories.length > 0 ? (
<div className="flex flex-1 gap-[6px] overflow-x-auto">
@@ -202,7 +202,10 @@ export default function MCollabNotes({ planner }: MCollabNotesProps) {
<p className="font-geist text-[0.8125rem] font-medium text-m-muted">{t('collab.notes.empty')}</p>
</div>
) : (
sorted.map(note => (
// A board on a wide window: the notes in as many columns as fit, each
// card whole in its column.
<div className="wide:mt-2 wide:columns-[300px] wide:gap-3">
{sorted.map(note => (
<NoteCardRow
key={note.id}
note={note}
@@ -213,7 +216,8 @@ export default function MCollabNotes({ planner }: MCollabNotesProps) {
onDelete={() => setPendingDeleteId(note.id)}
t={t}
/>
))
))}
</div>
)}
<NoteFormSheet
@@ -274,7 +278,7 @@ function NoteCardRow({ note, color, canEdit, onTap, onTogglePin, onDelete, t }:
const websiteHref = safeExternalHref(note.website)
return (
<div className="mt-2 overflow-hidden rounded-2xl border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)]">
<div className="mt-2 overflow-hidden rounded-2xl border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] wide:mb-3 wide:mt-0 wide:break-inside-avoid">
<div className="flex items-center gap-[7px] px-3 py-[10px]" style={{ background: `${color}0d` }}>
{!!note.pinned && <Pin size={11} strokeWidth={2.4} style={{ color }} className="flex-none" />}
<button type="button" onClick={onTap} className="flex min-w-0 flex-1 items-center gap-[7px] text-start">
@@ -14,7 +14,7 @@ import MSheet from '../../../components/MSheet'
import { Eyebrow, FIELD_AREA_CLS, FormSheetFooter, FormSheetHeader } from '../sheets/PlSheetChrome'
import MConfirmSheet from '../../settings/MConfirmSheet'
import type { TripPlanner } from '../MTripShell'
import { SectionHeader, TabScroller } from './tabChrome'
import { CardGrid, SectionHeader, TabScroller } from './tabChrome'
import {
formatPollCountdown,
hasUserVoted,
@@ -86,7 +86,7 @@ export default function MCollabPolls({ planner }: MCollabPollsProps) {
const { active, closed } = splitPolls(polls)
return (
<TabScroller>
<TabScroller wide="full">
<div className="flex items-center justify-end">
{canEdit && (
<button
@@ -107,6 +107,7 @@ export default function MCollabPolls({ planner }: MCollabPollsProps) {
</div>
) : (
<>
<CardGrid>
{active.map(poll => (
<PollCardRow
key={poll.id}
@@ -119,6 +120,7 @@ export default function MCollabPolls({ planner }: MCollabPollsProps) {
onDelete={() => setPendingDeleteId(poll.id)}
/>
))}
</CardGrid>
{closed.length > 0 && (
<div>
@@ -130,7 +132,9 @@ export default function MCollabPolls({ planner }: MCollabPollsProps) {
onToggle={() => setCollapsedClosed(v => !v)}
/>
)}
{!collapsedClosed && closed.map(poll => (
{!collapsedClosed && (
<CardGrid>
{closed.map(poll => (
<PollCardRow
key={poll.id}
poll={poll}
@@ -141,7 +145,9 @@ export default function MCollabPolls({ planner }: MCollabPollsProps) {
onClosePoll={handleClosePoll}
onDelete={() => setPendingDeleteId(poll.id)}
/>
))}
))}
</CardGrid>
)}
</div>
)}
</>
@@ -6,6 +6,8 @@ import MCollabChat from './MCollabChat'
import MCollabNotes from './MCollabNotes'
import MCollabPolls from './MCollabPolls'
import CollabLinks from '../../../../components/Collab/CollabLinks'
import { useIsSplit } from '../../../../hooks/sizeClass'
import { collabBoardFor, collabSideBySide, isCollabBoard } from './collabPanes'
/**
* Tab 6 — Collab. Routes `shell.collabTab` ('chat' | 'notes' | 'polls', the
@@ -26,10 +28,47 @@ import CollabLinks from '../../../../components/Collab/CollabLinks'
* deliberately not handled here — see the task report for why.
*/
export default function MCollabTab({ planner, shell }: MTabScreenProps) {
const split = useIsSplit()
// With room for two panes the conversation stays in view at the trailing edge
// while the notes, links or polls it is about fill the page beside it, the way
// a comments pane accompanies a document. The header's segment then switches
// only the board (MTripShell asks collabPanes.ts the same question).
//
// The chat keeps its place in the tree either way (a slot that stays empty
// still holds its index), so a rotation or a fold that crosses into or out of
// two panes keeps the draft and the scroll position instead of remounting it.
const sideBySide = collabSideBySide(split, planner.collabFeatures)
const board = sideBySide
? collabBoardFor(shell.collabTab, planner.collabFeatures)
: isCollabBoard(shell.collabTab) ? shell.collabTab : null
const chat = sideBySide || shell.collabTab === 'chat'
if (!board && !chat) return null
return (
<div className="flex h-full">
{board && (
<div className="relative h-full min-w-0 flex-1">
<CollabPanel planner={planner} tab={board} />
</div>
)}
{chat && (
<div
className={sideBySide
? 'm-pane-trail relative h-full flex-none border-s border-[color:var(--m-rowbr)]'
: 'relative h-full min-w-0 flex-1'}
>
<CollabPanel planner={planner} tab="chat" />
</div>
)}
</div>
)
}
function CollabPanel({ planner, tab }: { planner: MTabScreenProps['planner']; tab: MTabScreenProps['shell']['collabTab'] }) {
const { t } = planner
const label = t('mobileTrip.collabFeatureDisabled')
if (shell.collabTab === 'chat') {
if (tab === 'chat') {
if (!planner.collabFeatures.chat) {
return (
<div className="flex h-full flex-col px-4 pb-[var(--bottom-nav-h,84px)] pt-[calc(var(--m-safe-top,12px)+58px)]">
@@ -40,7 +79,7 @@ export default function MCollabTab({ planner, shell }: MTabScreenProps) {
return <MCollabChat planner={planner} />
}
if (shell.collabTab === 'notes') {
if (tab === 'notes') {
if (!planner.collabFeatures.notes) {
return (
<TabScroller>
@@ -51,7 +90,7 @@ export default function MCollabTab({ planner, shell }: MTabScreenProps) {
return <MCollabNotes planner={planner} />
}
if (shell.collabTab === 'links') {
if (tab === 'links') {
if (planner.collabFeatures.links === false) {
return (
<TabScroller>
@@ -68,7 +107,7 @@ export default function MCollabTab({ planner, shell }: MTabScreenProps) {
)
}
if (shell.collabTab === 'polls') {
if (tab === 'polls') {
if (!planner.collabFeatures.polls) {
return (
<TabScroller>
@@ -0,0 +1,212 @@
import { NOTE_MAX } from '../../../../components/Budget/CostsPanel.helpers';
import { currencyOptions, type CostsSettlement } from '../../../../components/Budget/costsModel';
import { useSettlementForm } from '../../../../components/Budget/useSettlementForm';
import { CustomDatePicker } from '../../../../components/shared/CustomDateTimePicker';
import CustomSelect from '../../../../components/shared/CustomSelect';
import type { TripMember } from '../../../../types';
import { localizeAmountInput, zeroAmountInput } from '../../../../utils/formatters';
import MChip from '../../../components/MChip';
import MSheet from '../../../components/MSheet';
import { Eyebrow, FIELD_AREA_CLS, FIELD_CLS, FormSheetFooter, FormSheetHeader } from '../sheets/PlSheetChrome';
type TFn = (key: string, params?: Record<string, string | number>) => string;
// The costs tab's member avatar and its payment sheet, apart from the tab so it
// keeps to the size a source file may have.
/** Avatar or initials circle; `variant` picks the accent (member chips) or neutral (balances) tone. */
export function MemberAvatar({
name,
avatarUrl,
isMe,
variant,
size,
t,
}: {
name: string;
avatarUrl?: string | null;
isMe: boolean;
variant: 'accent' | 'neutral';
size: number;
t: TFn;
}) {
if (avatarUrl) {
return (
<img
src={avatarUrl}
alt=""
className="flex-none rounded-full object-cover"
style={{ width: size, height: size }}
/>
);
}
const initial = isMe ? t('costs.youShort') : (name || '?').charAt(0).toUpperCase();
const toneCls = variant === 'accent' ? 'bg-m-act text-m-actfg' : 'bg-[color:var(--m-ic)] text-m-ink';
return (
<span
className={`flex flex-none items-center justify-center rounded-full font-geist font-extrabold ${toneCls}`}
style={{ width: size, height: size, fontSize: Math.max(8, size * 0.42) }}
>
{initial}
</span>
);
}
/**
* "Add/edit payment": records or updates a manual settle-up transfer through
* the same `useSettlementForm` as the desktop dialog. No pixel spec exists for
* this form (the demo only toasts "Demo: add payment", 03-trip-tabs.md §3.8)
* and no mobile/exported-desktop sheet covers it, so this is a small local
* sheet built from the trip form-sheet chrome, kept to the fields the
* settle-up card itself needs: from, to, amount plus its currency (a new
* payment starts in the display currency, a reopened one keeps the currency
* it was recorded in, like the desktop modal), and the day it happened
* (editable like an expense's, unlike the legacy created_at-only date, see
* `settlementDate` in CostsPanel.helpers.ts).
*/
export function AddPaymentSheet({
open,
editing,
onClose,
tripId,
base,
tripCurrency,
people,
me,
toast,
t,
onSaved,
}: {
open: boolean;
editing: CostsSettlement | null;
onClose: () => void;
tripId: number;
base: string;
tripCurrency: string;
people: TripMember[];
me: number;
toast: { error: (message: string) => void; info: (message: string) => void };
t: TFn;
onSaved: () => void;
}) {
const {
fromId,
setFromId,
toId,
setToId,
amount,
setAmount,
currency,
setCurrency,
day,
setDay,
note,
setNote,
saving,
valid,
save,
} = useSettlementForm({
tripId,
tripCurrency,
base,
people,
me,
editing,
open,
t,
toast,
onSaved,
oneSaveAtATime: true,
});
const title = editing ? t('costs.editPayment') : t('costs.addPayment');
return (
<MSheet open={open} onClose={onClose} ariaLabel={title}>
<FormSheetHeader title={title} onClose={onClose} closeLabel={t('common.close')} />
<div className="min-h-0 flex-1 overflow-y-auto px-[18px] pb-[6px] pt-1">
<Eyebrow className="mb-[7px] uppercase">{t('costs.from')}</Eyebrow>
<div className="flex flex-wrap gap-[6px]">
{people.map((p) => (
<MChip key={p.id} active={fromId === p.id} onClick={() => setFromId(p.id)}>
<MemberAvatar
name={p.username}
avatarUrl={p.avatar_url}
isMe={p.id === me}
variant={fromId === p.id ? 'accent' : 'neutral'}
size={16}
t={t}
/>
{p.id === me ? t('costs.you') : p.username}
</MChip>
))}
</div>
<Eyebrow className="mb-[7px] mt-[14px] uppercase">{t('costs.to')}</Eyebrow>
<div className="flex flex-wrap gap-[6px]">
{people.map((p) => (
<MChip key={p.id} active={toId === p.id} onClick={() => setToId(p.id)}>
<MemberAvatar
name={p.username}
avatarUrl={p.avatar_url}
isMe={p.id === me}
variant={toId === p.id ? 'accent' : 'neutral'}
size={16}
t={t}
/>
{p.id === me ? t('costs.you') : p.username}
</MChip>
))}
</div>
<Eyebrow className="mb-[7px] mt-[14px] uppercase">{t('costs.amount')}</Eyebrow>
<div className="flex items-center gap-2">
<input
type="text"
inputMode="decimal"
value={localizeAmountInput(amount, currency)}
onChange={(e) => setAmount(e.target.value)}
placeholder={zeroAmountInput(currency)}
className={FIELD_CLS}
/>
<span className="flex-none font-geist text-caption font-bold text-m-faint">{currency}</span>
</div>
<div className="mt-[14px] flex gap-2">
<div className="min-w-0 flex-1">
<Eyebrow className="mb-[7px] uppercase">{t('costs.currency')}</Eyebrow>
<CustomSelect
value={currency}
onChange={(v) => setCurrency(String(v))}
searchable
size="sm"
options={currencyOptions(currency)}
style={{ width: '100%' }}
/>
</div>
<div className="min-w-0 flex-1">
<Eyebrow className="mb-[7px] uppercase">{t('costs.day')}</Eyebrow>
<CustomDatePicker value={day} onChange={setDay} style={{ width: '100%' }} />
</div>
</div>
<Eyebrow className="mb-[6px] mt-[14px] uppercase">{t('costs.note')}</Eyebrow>
<textarea
value={note}
onChange={(e) => setNote(e.target.value)}
rows={2}
maxLength={NOTE_MAX}
placeholder={t('costs.paymentNotePlaceholder')}
className={FIELD_AREA_CLS}
/>
</div>
<FormSheetFooter
onCancel={onClose}
cancelLabel={t('common.cancel')}
onSubmit={save}
submitLabel={editing ? t('common.save') : t('costs.addPayment')}
submitDisabled={!valid || saving}
/>
</MSheet>
);
}
+23 -132
View File
@@ -5,29 +5,24 @@ import {
} from 'lucide-react'
import MDancingTrek from '../../../components/MDancingTrek'
import { useTranslation } from '../../../../i18n'
import { formatMoney, localizeAmountInput, zeroAmountInput } from '../../../../utils/formatters'
import { formatMoney } from '../../../../utils/formatters'
import { openFile } from '../../../../utils/fileDownload'
import MCostSheet from '../sheets/MCostSheet'
import { ReceiptPreviewModal } from '../../../../components/Budget/ReceiptPreviewModal'
import { useCostsLedger } from '../../../../components/Budget/useCostsLedger'
import { useExpenseLinks } from '../../../../components/Budget/useExpenseLinks'
import { useSettlementForm } from '../../../../components/Budget/useSettlementForm'
import { finalBudgetFor, finalBudgetSources, formerParticipantRows, NOTE_MAX, paidByUser, readUserNote } from '../../../../components/Budget/CostsPanel.helpers'
import { finalBudgetFor, finalBudgetSources, formerParticipantRows, paidByUser, readUserNote } from '../../../../components/Budget/CostsPanel.helpers'
import { catMeta, COST_CAT_META } from '../../../../components/Budget/costsCategories'
import CustomSelect from '../../../../components/shared/CustomSelect'
import { CustomDatePicker } from '../../../../components/shared/CustomDateTimePicker'
import MConfirmSheet from '../../settings/MConfirmSheet'
import MSheet from '../../../components/MSheet'
import MChip from '../../../components/MChip'
import { Eyebrow, FIELD_AREA_CLS, FIELD_CLS, FormSheetFooter, FormSheetHeader } from '../sheets/PlSheetChrome'
import { CountPill, TabScroller } from './tabChrome'
import { CountPill, TabPanes } from './tabChrome'
import { AddPaymentSheet, MemberAvatar } from './MCostsPayment'
import { STATUS_COLOR, type MTabScreenProps } from './tabModel'
import {
baseTotal, categoryFilterKeys, currencyOptions, dayFilterKeys, groupLedgerByDay, isUnfinished, lineOf, memberShareOf,
baseTotal, categoryFilterKeys, dayFilterKeys, groupLedgerByDay, isUnfinished, lineOf, memberShareOf,
paymentAmount, paymentLineOf, tint, type CostsCtx, type CostsSettlement,
} from '../../../../components/Budget/costsModel'
import type { BudgetParticipantFinal } from '@trek/shared'
import type { BudgetItem, BudgetItemReceipt, TripMember } from '../../../../types'
import type { BudgetItem, BudgetItemReceipt } from '../../../../types'
type TFn = (key: string, params?: Record<string, string | number>) => string
@@ -129,8 +124,10 @@ export default function MCostsTab({ planner, shell }: MTabScreenProps) {
}
}
return (
<TabScroller>
// The summary: what the trip costs and who owes whom. Beside the expenses on
// a window with room for two panes, above them everywhere else.
const summary = (
<>
{/* Hero — "Total Trip Spend" (spec §3.1). Fixed dark card, both themes. */}
<div className="rounded-[20px] p-4 shadow-[0_18px_44px_-18px_rgba(0,0,0,.5)]" style={{ background: '#15151A', color: '#F5F5F7' }}>
<div className="font-geist text-[0.625rem] font-bold uppercase tracking-[.09em]" style={{ color: 'rgba(245,245,247,.55)' }}>
@@ -314,8 +311,16 @@ export default function MCostsTab({ planner, shell }: MTabScreenProps) {
)}
</div>
{/* Expenses — header, search, filters (spec §3.6) */}
<h2 className="mt-4 text-[1.0625rem] font-extrabold text-m-ink">{t('costs.expenses')}</h2>
</>
)
return (
<>
<TabPanes aside={summary} asideLabel={t('budget.title')}>
{/* Expenses — header, search, filters (spec §3.6). One block, so on a wide
window the search field keeps to the column like the heading above it. */}
<section aria-label={t('costs.expenses')}>
<h2 className="mt-4 text-[1.0625rem] font-extrabold text-m-ink split:mt-0">{t('costs.expenses')}</h2>
<input
type="text"
value={search}
@@ -487,6 +492,8 @@ export default function MCostsTab({ planner, shell }: MTabScreenProps) {
<p className="py-6 text-center font-geist text-[0.6875rem] text-m-faint">{t('costs.noMatch')}</p>
)
)}
</section>
</TabPanes>
{/* Add / edit expense — the shared desktop modal (spec §3.9); not rebuilt here. */}
{expenseModalOpen && (
@@ -548,7 +555,7 @@ export default function MCostsTab({ planner, shell }: MTabScreenProps) {
onClose={() => setPreviewReceipts(null)}
/>
)}
</TabScroller>
</>
)
}
@@ -796,119 +803,3 @@ function FinalBudgetBreakdown({ row, items, ctx, base, locale, t, personName }:
)
}
/** Avatar or initials circle; `variant` picks the accent (member chips) or neutral (balances) tone. */
function MemberAvatar({ name, avatarUrl, isMe, variant, size, t }: {
name: string
avatarUrl?: string | null
isMe: boolean
variant: 'accent' | 'neutral'
size: number
t: TFn
}) {
if (avatarUrl) {
return <img src={avatarUrl} alt="" className="flex-none rounded-full object-cover" style={{ width: size, height: size }} />
}
const initial = isMe ? t('costs.youShort') : (name || '?').charAt(0).toUpperCase()
const toneCls = variant === 'accent' ? 'bg-m-act text-m-actfg' : 'bg-[color:var(--m-ic)] text-m-ink'
return (
<span
className={`flex flex-none items-center justify-center rounded-full font-geist font-extrabold ${toneCls}`}
style={{ width: size, height: size, fontSize: Math.max(8, size * 0.42) }}
>
{initial}
</span>
)
}
/**
* "Add/edit payment": records or updates a manual settle-up transfer through
* the same `useSettlementForm` as the desktop dialog. No pixel spec exists for
* this form (the demo only toasts "Demo: add payment", 03-trip-tabs.md §3.8)
* and no mobile/exported-desktop sheet covers it, so this is a small local
* sheet built from the trip form-sheet chrome, kept to the fields the
* settle-up card itself needs: from, to, amount plus its currency (a new
* payment starts in the display currency, a reopened one keeps the currency
* it was recorded in, like the desktop modal), and the day it happened
* (editable like an expense's, unlike the legacy created_at-only date, see
* `settlementDate` in CostsPanel.helpers.ts).
*/
function AddPaymentSheet({ open, editing, onClose, tripId, base, tripCurrency, people, me, toast, t, onSaved }: {
open: boolean
editing: CostsSettlement | null
onClose: () => void
tripId: number
base: string
tripCurrency: string
people: TripMember[]
me: number
toast: { error: (message: string) => void; info: (message: string) => void }
t: TFn
onSaved: () => void
}) {
const { fromId, setFromId, toId, setToId, amount, setAmount, currency, setCurrency, day, setDay, note, setNote, saving, valid, save } =
useSettlementForm({ tripId, tripCurrency, base, people, me, editing, open, t, toast, onSaved, oneSaveAtATime: true })
const title = editing ? t('costs.editPayment') : t('costs.addPayment')
return (
<MSheet open={open} onClose={onClose} ariaLabel={title}>
<FormSheetHeader title={title} onClose={onClose} closeLabel={t('common.close')} />
<div className="min-h-0 flex-1 overflow-y-auto px-[18px] pb-[6px] pt-1">
<Eyebrow className="mb-[7px] uppercase">{t('costs.from')}</Eyebrow>
<div className="flex flex-wrap gap-[6px]">
{people.map(p => (
<MChip key={p.id} active={fromId === p.id} onClick={() => setFromId(p.id)}>
<MemberAvatar name={p.username} avatarUrl={p.avatar_url} isMe={p.id === me} variant={fromId === p.id ? 'accent' : 'neutral'} size={16} t={t} />
{p.id === me ? t('costs.you') : p.username}
</MChip>
))}
</div>
<Eyebrow className="mb-[7px] mt-[14px] uppercase">{t('costs.to')}</Eyebrow>
<div className="flex flex-wrap gap-[6px]">
{people.map(p => (
<MChip key={p.id} active={toId === p.id} onClick={() => setToId(p.id)}>
<MemberAvatar name={p.username} avatarUrl={p.avatar_url} isMe={p.id === me} variant={toId === p.id ? 'accent' : 'neutral'} size={16} t={t} />
{p.id === me ? t('costs.you') : p.username}
</MChip>
))}
</div>
<Eyebrow className="mb-[7px] mt-[14px] uppercase">{t('costs.amount')}</Eyebrow>
<div className="flex items-center gap-2">
<input type="text" inputMode="decimal" value={localizeAmountInput(amount, currency)} onChange={e => setAmount(e.target.value)} placeholder={zeroAmountInput(currency)} className={FIELD_CLS} />
<span className="flex-none font-geist text-[0.75rem] font-bold text-m-faint">{currency}</span>
</div>
<div className="mt-[14px] flex gap-2">
<div className="min-w-0 flex-1">
<Eyebrow className="mb-[7px] uppercase">{t('costs.currency')}</Eyebrow>
<CustomSelect
value={currency}
onChange={v => setCurrency(String(v))}
searchable
size="sm"
options={currencyOptions(currency)}
style={{ width: '100%' }}
/>
</div>
<div className="min-w-0 flex-1">
<Eyebrow className="mb-[7px] uppercase">{t('costs.day')}</Eyebrow>
<CustomDatePicker value={day} onChange={setDay} style={{ width: '100%' }} />
</div>
</div>
<Eyebrow className="mb-[6px] mt-[14px] uppercase">{t('costs.note')}</Eyebrow>
<textarea
value={note}
onChange={e => setNote(e.target.value)}
rows={2}
maxLength={NOTE_MAX}
placeholder={t('costs.paymentNotePlaceholder')}
className={FIELD_AREA_CLS}
/>
</div>
<FormSheetFooter onCancel={onClose} cancelLabel={t('common.cancel')} onSubmit={save} submitLabel={editing ? t('common.save') : t('costs.addPayment')} submitDisabled={!valid || saving} />
</MSheet>
)
}
@@ -0,0 +1,311 @@
import { ExternalLink, Link2, MoreHorizontal, Star, Wallet } from 'lucide-react';
import { useEffect, useRef, useState, type ReactNode } from 'react';
import { formatSize, isImage, isWalletPass } from '../../../../components/Files/FileManager.helpers';
import { linkedFileTargets } from '../../../../components/Files/fileListRules';
import { useTranslation } from '../../../../i18n';
import { fileRepo } from '../../../../repo/fileRepo';
import type { TripFile } from '../../../../types';
import { formatFileDate, getFileTypeMeta } from './filesModel';
import type { MTabScreenProps } from './tabModel';
type Planner = MTabScreenProps['planner'];
/** The product's name, the same in every language. */
const APPLE_WALLET = 'Apple Wallet';
const isPdf = (file: TripFile) => file.mime_type === 'application/pdf';
/**
* A file's signed address for showing it in place (a tile's picture, the
* preview pane), fetched once `enabled` turns on: every address costs a token
* of its own, so a tile asks only once it scrolls into view.
*/
function useSignedUrl(url: string | undefined, enabled: boolean): string {
const [signed, setSigned] = useState('');
useEffect(() => {
if (!url || !enabled) return;
let cancelled = false;
void fileRepo.previewUrl(url).then((next) => {
if (!cancelled) setSigned(next);
});
return () => {
cancelled = true;
};
}, [url, enabled]);
return enabled ? signed : '';
}
/** True once the element has come within a screen of the viewport. */
function useNearView<T extends Element>() {
const ref = useRef<T>(null);
// Without an observer to ask, every tile counts as near.
const [near, setNear] = useState(() => typeof IntersectionObserver === 'undefined');
useEffect(() => {
const el = ref.current;
if (!el || near) return;
const io = new IntersectionObserver(
(entries) => {
if (entries.some((e) => e.isIntersecting)) setNear(true);
},
{ rootMargin: '100% 0px' }
);
io.observe(el);
return () => io.disconnect();
}, [near]);
return { ref, near };
}
/** The type's own tile: its icon on its colour, for a file with no picture to show. */
function TypeArt({ file, large = false }: { file: TripFile; large?: boolean }) {
const meta = getFileTypeMeta(file);
const Icon = meta.icon;
return (
<span
className="absolute inset-0 flex flex-col items-center justify-center gap-[6px]"
style={{ background: `${meta.color}1f`, color: meta.color }}
>
<Icon size={large ? 44 : 30} strokeWidth={1.7} />
<span
className={`font-geist font-extrabold tracking-[.08em] ${large ? 'text-caption' : 'text-[length:calc(9px*var(--fs-scale-caption,1))]'}`}
>
{meta.label}
</span>
</span>
);
}
const roundBtn =
'flex h-7 w-7 flex-none items-center justify-center rounded-full outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--m-act)]';
interface FileTileProps {
file: TripFile;
selected: boolean;
canStar: boolean;
onPick: () => void;
onStar: () => void;
onMenu: () => void;
t: Planner['t'];
}
/**
* A file as a tile of the grid a wide window shows in place of the phone's
* rows: its picture (a photo's own, the type's colour and icon otherwise),
* the name under it, size and date as badges.
*/
export function FileTile({ file, selected, canStar, onPick, onStar, onMenu, t }: FileTileProps) {
const { locale } = useTranslation();
const { ref, near } = useNearView<HTMLDivElement>();
const picture = useSignedUrl(file.url, near && isImage(file.mime_type));
const starred = !!file.starred;
return (
<div
ref={ref}
className={`relative flex flex-col overflow-hidden rounded-2xl border bg-m-sheetop transition-shadow ${
selected ? 'border-[color:var(--m-act)] shadow-[0_0_0_1.5px_var(--m-act)]' : 'border-[color:var(--m-rowbr)]'
}`}
>
<button
type="button"
onClick={onPick}
aria-pressed={selected}
className="flex flex-1 flex-col text-start outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[color:var(--m-act)]"
>
<span className="relative block aspect-[4/3] w-full overflow-hidden">
{picture ? (
<img src={picture} alt="" className="absolute inset-0 h-full w-full object-cover" />
) : (
<TypeArt file={file} />
)}
</span>
<span className="flex flex-1 flex-col gap-[6px] px-3 pb-[10px] pt-2">
<span className="line-clamp-2 break-words text-[length:calc(12.5px*var(--fs-scale-body,1))] font-bold leading-[1.3] text-m-ink">
{file.original_name}
</span>
<span className="mt-auto flex flex-wrap items-center gap-1">
{!!file.file_size && <Badge>{formatSize(file.file_size)}</Badge>}
<Badge>{formatFileDate(file.created_at, locale)}</Badge>
</span>
</span>
</button>
<span className="absolute end-2 top-2 flex items-center gap-1">
{canStar ? (
<button
type="button"
onClick={onStar}
aria-label={starred ? t('files.unstar') : t('files.star')}
aria-pressed={starred}
className={`${roundBtn} ${starred ? 'bg-m-act text-m-actfg' : 'bg-[color:var(--m-glass)] text-m-ink backdrop-blur-[16px]'}`}
>
<Star size={13} strokeWidth={2.4} fill={starred ? 'currentColor' : 'none'} />
</button>
) : (
starred && (
<span className={`${roundBtn} bg-m-act text-m-actfg`}>
<Star size={13} strokeWidth={2.4} fill="currentColor" aria-label={t('files.filterStarred')} />
</span>
)
)}
<button
type="button"
onClick={onMenu}
aria-label={t('files.menu')}
className={`${roundBtn} bg-[color:var(--m-glass)] text-m-ink backdrop-blur-[16px]`}
>
<MoreHorizontal size={14} strokeWidth={2.2} />
</button>
</span>
</div>
);
}
function Badge({ children }: { children: ReactNode }) {
return (
<span className="whitespace-nowrap rounded-full bg-[color:var(--m-ic)] px-[7px] py-[2px] font-geist text-[length:calc(9px*var(--fs-scale-caption,1))] font-bold text-m-muted">
{children}
</span>
);
}
interface FilePreviewPaneProps {
file: TripFile;
planner: Planner;
onOpen: () => void;
onStar: () => void;
onLinks: () => void;
onMenu: () => void;
}
/**
* The file picked in the grid, shown beside it once the window has room for
* two panes, the way Files on an iPad previews a document: the picture or the
* document itself, then what it is, what it belongs to, and what to do with it.
*/
export function FilePreviewPane({ file, planner, onOpen, onStar, onLinks, onMenu }: FilePreviewPaneProps) {
const { t } = planner;
const { locale } = useTranslation();
const meta = getFileTypeMeta(file);
const showsInPlace = isImage(file.mime_type) || isPdf(file);
const src = useSignedUrl(file.url, showsInPlace);
const linked = linkedFileTargets(file, planner.places, planner.reservations, planner.TRANSPORT_TYPES);
const canEdit = planner.can('file_edit', planner.trip);
const starred = !!file.starred;
const actionBtn =
'flex h-10 flex-1 items-center justify-center gap-[6px] rounded-full border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] text-caption font-semibold text-m-ink outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--m-act)]';
return (
<div className="flex flex-col gap-3">
<div className="relative aspect-[4/5] max-h-[56dvh] w-full overflow-hidden rounded-2xl border border-[color:var(--m-rowbr)] bg-m-sheetop">
{isImage(file.mime_type) && src ? (
<img src={src} alt={file.original_name} className="absolute inset-0 h-full w-full object-contain" />
) : isPdf(file) && src ? (
<object
data={`${src}#view=FitH`}
type="application/pdf"
title={file.original_name}
className="absolute inset-0 h-full w-full"
>
<TypeArt file={file} large />
</object>
) : (
<TypeArt file={file} large />
)}
</div>
<div>
<h3 className="break-words text-[length:calc(16px*var(--fs-scale-subtitle,1))] font-extrabold leading-[1.3] text-m-ink">
{file.original_name}
</h3>
<div className="mt-[6px] flex flex-wrap items-center gap-1">
<span
className="rounded-full px-[7px] py-[2px] font-geist text-[length:calc(9px*var(--fs-scale-caption,1))] font-extrabold tracking-[.06em]"
style={{ background: `${meta.color}1f`, color: meta.color }}
>
{meta.label}
</span>
{isWalletPass(file.mime_type, file.original_name) && (
<span className="flex items-center gap-[3px] rounded-full bg-m-act px-[7px] py-[2px] font-geist text-[length:calc(9px*var(--fs-scale-caption,1))] font-bold text-m-actfg">
<Wallet size={9} strokeWidth={2.4} aria-hidden="true" />
{APPLE_WALLET}
</span>
)}
{!!file.file_size && <Badge>{formatSize(file.file_size)}</Badge>}
<Badge>{formatFileDate(file.created_at, locale)}</Badge>
{file.uploaded_by_name && <Badge>{file.uploaded_by_name}</Badge>}
</div>
</div>
{(linked.places.length > 0 || linked.reservations.length > 0 || file.note_id != null) && (
<div className="flex flex-wrap gap-[6px]">
{linked.places.map((place) => (
<LinkChip key={`p${place.id}`} kind={t('files.sourcePlan')} name={place.name} />
))}
{linked.reservations.map(({ reservation, transport }) => (
<LinkChip
key={`r${reservation.id}`}
kind={t(transport ? 'files.sourceTransport' : 'files.sourceBooking')}
name={reservation.title}
/>
))}
{file.note_id != null && <LinkChip kind={t('files.sourceCollab')} />}
</div>
)}
{file.description && (
<p className="whitespace-pre-wrap break-words rounded-2xl border border-[color:var(--m-rowbr)] bg-m-sheetop px-[13px] py-[11px] text-[length:calc(12.5px*var(--fs-scale-body,1))] text-m-ink">
{file.description}
</p>
)}
<div className="flex items-center gap-2">
<button
type="button"
onClick={onOpen}
className="flex h-10 flex-[1.4] items-center justify-center gap-[6px] rounded-full bg-m-act text-caption font-semibold text-m-actfg outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--m-act)] focus-visible:ring-offset-2"
>
<ExternalLink size={14} strokeWidth={2.2} />
{t('common.open')}
</button>
{canEdit && (
<button type="button" onClick={onStar} aria-pressed={starred} className={actionBtn}>
<Star size={14} strokeWidth={2.2} fill={starred ? 'currentColor' : 'none'} />
{starred ? t('files.unstar') : t('files.star')}
</button>
)}
{canEdit && (
<button type="button" onClick={onLinks} className={actionBtn}>
<Link2 size={14} strokeWidth={2.2} />
{t('files.link')}
</button>
)}
<button
type="button"
onClick={onMenu}
aria-label={t('files.menu')}
className={`${actionBtn} w-10 flex-none px-0`}
>
<MoreHorizontal size={15} strokeWidth={2.2} />
</button>
</div>
</div>
);
}
/** What a file belongs to: the kind as the outer badge, the name inside it. */
function LinkChip({ kind, name }: { kind: string; name?: string }) {
return (
<span
className={`flex max-w-full items-center gap-[6px] rounded-full border border-[color:var(--m-rowbr)] bg-m-sheetop py-[3px] ps-[10px] ${
name ? 'pe-[3px]' : 'pe-[10px]'
}`}
>
<span className="flex-none font-geist text-[length:calc(9px*var(--fs-scale-caption,1))] font-bold uppercase tracking-[.08em] text-m-faint">
{kind}
</span>
{name && (
<span className="min-w-0 truncate rounded-full bg-[color:var(--m-ic)] px-[9px] py-[2px] text-[length:calc(11px*var(--fs-scale-caption,1))] font-semibold text-m-ink">
{name}
</span>
)}
</span>
);
}
@@ -16,6 +16,8 @@ import { canManageDocSync } from '../../../../components/Files/docsync/useDocSyn
import { filesFromClipboard } from '../../../../components/Files/fileActions'
import { useAuthStore } from '../../../../store/authStore'
import MFileLightbox from './MFileLightbox'
import { FilePreviewPane, FileTile } from './MFilesBoard'
import { useIsSplit, useIsWide } from '../../../../hooks/sizeClass'
import {
FILE_FILTERS, buildFileLinkLabels, formatFileDate, getFileTypeMeta,
matchesFileFilter, sortFilesStarredFirst, type FileFilterId,
@@ -43,6 +45,11 @@ export default function MFilesTab({ planner, shell }: MTabScreenProps) {
const [docSyncOpen, setDocSyncOpen] = useState(false)
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null)
const [uploading, setUploading] = useState(false)
// Wide: the files as a grid of tiles. Split: the picked one previewed beside
// the grid (MFilesBoard), so a tap picks a file there instead of opening it.
const wide = useIsWide()
const split = useIsSplit()
const [pickedId, setPickedId] = useState<number | null>(null)
const menuFile = menuFileId != null ? files.find(f => f.id === menuFileId) ?? null : null
const linkFile = linkFileId != null ? files.find(f => f.id === linkFileId) ?? null : null
@@ -143,11 +150,12 @@ export default function MFilesTab({ planner, shell }: MTabScreenProps) {
}
}
const picked = split ? (visible.find(f => f.id === pickedId) ?? visible[0] ?? null) : null
const visibleFilters = FILE_FILTERS.filter(f => f.id !== 'collab' || files.some(x => x.note_id != null))
const isEmpty = files.length === 0
return (
<TabScroller>
const body = (
<div onPaste={onPaste} tabIndex={-1} className="flex min-h-full flex-col">
<input ref={inputRef} type="file" multiple className="hidden" onChange={onPickFiles} />
@@ -191,6 +199,21 @@ export default function MFilesTab({ planner, shell }: MTabScreenProps) {
{visible.length === 0 ? (
<div className="pt-14 text-center font-geist text-[0.8125rem] text-m-faint">{t('mobileTrip.filesEmpty')}</div>
) : wide ? (
<div className="mt-3 grid grid-cols-[repeat(auto-fill,minmax(156px,1fr))] gap-3">
{visible.map(file => (
<FileTile
key={file.id}
file={file}
selected={picked?.id === file.id}
canStar={planner.can('file_edit', planner.trip)}
onPick={() => (split ? setPickedId(file.id) : openRow(file))}
onStar={() => void toggleStar(file)}
onMenu={() => setMenuFileId(file.id)}
t={t}
/>
))}
</div>
) : (
<div className="flex flex-col">
{visible.map(file => (
@@ -208,7 +231,10 @@ export default function MFilesTab({ planner, shell }: MTabScreenProps) {
</>
)}
</div>
)
const sheets = (
<>
<MFileMenuSheet
planner={planner}
file={menuFile}
@@ -233,6 +259,36 @@ export default function MFilesTab({ planner, shell }: MTabScreenProps) {
t={t}
/>
)}
</>
)
if (split && picked) {
return (
<div className="flex h-full">
<div className="min-h-0 min-w-0 flex-1 overflow-y-auto overflow-x-hidden pb-[calc(var(--bottom-nav-h,84px)+22px)] pe-3 ps-6 pt-[calc(var(--m-safe-top,12px)+58px)]">
{body}
</div>
<aside
aria-label={t('common.preview')}
className="m-pane-trail min-h-0 flex-none overflow-y-auto overflow-x-hidden border-s border-[color:var(--m-rowbr)] px-5 pb-[calc(var(--bottom-nav-h,84px)+22px)] pt-[calc(var(--m-safe-top,12px)+58px)]"
>
<FilePreviewPane
file={picked}
planner={planner}
onOpen={() => openRow(picked)}
onStar={() => void toggleStar(picked)}
onLinks={() => setLinkFileId(picked.id)}
onMenu={() => setMenuFileId(picked.id)}
/>
</aside>
{sheets}
</div>
)
}
return (
<TabScroller wide={wide ? 'full' : 'readable'}>
{body}
{sheets}
</TabScroller>
)
}
@@ -106,7 +106,7 @@ export default function MPackingListTab({ planner }: { planner: TripPlanner }) {
}
const filterPillCls = (active: boolean) =>
`flex-1 whitespace-nowrap rounded-full px-2 py-[6px] text-center text-[0.71875rem] font-semibold ${
`flex-1 whitespace-nowrap rounded-full px-2 py-[6px] text-center text-[0.71875rem] font-semibold wide:flex-none wide:px-[14px] ${
active ? 'bg-m-act text-m-actfg' : 'bg-[color:var(--m-ic)] text-m-ink'
}`
@@ -114,7 +114,7 @@ export default function MPackingListTab({ planner }: { planner: TripPlanner }) {
const viewEmpty = viewItems.length === 0
return (
<TabScroller>
<TabScroller wide="full">
{/* ── Progress card (spec §4.1) ── */}
<div className="rounded-2xl border border-[color:var(--m-rowbr)] bg-m-sheetop px-[14px] py-[13px]">
<div className="flex items-baseline gap-[7px]">
@@ -164,7 +164,9 @@ export default function MPackingListTab({ planner }: { planner: TripPlanner }) {
{/* ── Action menu (spec §4.2) ── */}
{actionsOpen && hasActions && (
<div className="mt-[6px] overflow-hidden rounded-2xl border border-[color:var(--m-rowbr)] bg-[color:var(--m-glass)] backdrop-blur-[24px]">
// On a wide window the menu drops from the button that opened it, at the
// width of a popover, instead of running across the whole page.
<div className="mt-[6px] overflow-hidden rounded-2xl border border-[color:var(--m-rowbr)] bg-[color:var(--m-glass)] backdrop-blur-[24px] wide:ms-auto wide:max-w-[380px]">
{actionView === 'menu' && (
<>
{canEdit && checkedCount > 0 && (
@@ -303,7 +305,10 @@ export default function MPackingListTab({ planner }: { planner: TripPlanner }) {
{t('packing.emptyFiltered')}
</div>
) : (
groups.map(group => (
// Categories stand in columns on a wide window, as many as the page
// holds, each card whole in its column like a masonry board.
<div className="wide:mt-[10px] wide:columns-[300px] wide:gap-3">
{groups.map(group => (
<PackingCategoryCard
key={group.category}
group={group}
@@ -328,7 +333,8 @@ export default function MPackingListTab({ planner }: { planner: TripPlanner }) {
bags={bags}
onCreateBag={createBag}
/>
))
))}
</div>
)}
<MBagsSheet
@@ -467,7 +473,7 @@ function PackingCategoryCard({
}
return (
<div className="mt-[10px] overflow-hidden rounded-2xl border border-[color:var(--m-rowbr)] bg-m-sheetop">
<div className="mt-[10px] overflow-hidden rounded-2xl border border-[color:var(--m-rowbr)] bg-m-sheetop wide:mb-[10px] wide:mt-0 wide:break-inside-avoid">
{/* A <div role="button"> rather than a real <button type="button"> — the rename input and
the assign/menu triggers below are interactive elements a <button type="button"> may
not contain (invalid HTML); this stays keyboard-operable via onKeyDown. */}
@@ -7,7 +7,9 @@ import { avatarSrc } from '../../../../utils/avatarSrc'
import { formatDate } from '../../../../utils/formatters'
import type { TodoItem, TripMember } from '../../../../types'
import type { TripPlanner } from '../MTripShell'
import { TabScroller } from './tabChrome'
import { CardGrid, TabPanes } from './tabChrome'
import MTodoSidebar from './MTodoSidebar'
import { useIsSplit } from '../../../../hooks/sizeClass'
import { PRIORITY_COLOR, PRIORITY_LABEL } from './listsModel'
import { TODO_SMART_FILTERS, isTodoOverdue, type TodoSmartFilter } from '../../../../components/Todo/todoListModel'
import { useTodoView } from '../../../../components/Todo/useTodoList'
@@ -33,6 +35,8 @@ export default function MTodoListTab({ planner }: { planner: TripPlanner }) {
useTodoView(items, currentUserId, { rankByStatus: true, pinToday: true })
const [editingItemId, setEditingItemId] = useState<number | null>(null)
const [creatingTask, setCreatingTask] = useState(false)
// With room for two panes the filters become a pane of their own (MTodoSidebar).
const split = useIsSplit()
const pct = items.length > 0 ? Math.round((counts.done / items.length) * 100) : 0
const defaultCategoryForNew = active.kind === 'category' ? active.name : null
@@ -52,9 +56,31 @@ export default function MTodoListTab({ planner }: { planner: TripPlanner }) {
const filterCount = (f: TodoSmartFilter) =>
f === 'all' ? counts.open : f === 'my' ? counts.my : f === 'overdue' ? counts.overdue : counts.done
return (
<TabScroller>
{/* ── Progress card (spec §4.5) ── */}
const sortPills = (
<>
<button
type="button"
onClick={() => toggleSort('priority')}
aria-pressed={sortBy === 'priority'}
className={filterPill(sortBy === 'priority')}
>
<Flag size={11} strokeWidth={2.2} />
{t('todo.priority')}
</button>
<button
type="button"
onClick={() => toggleSort('due')}
aria-pressed={sortBy === 'due'}
className={filterPill(sortBy === 'due')}
>
<Calendar size={11} strokeWidth={2.2} />
{t('todo.detail.dueDate')}
</button>
</>
)
const progressCard = (
/* ── Progress card (spec §4.5) ── */
<div className="rounded-2xl border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-[14px] py-[13px]">
<div className="flex items-baseline gap-[7px]">
<span className="font-geist text-[1rem] font-extrabold tabular-nums text-m-ink">{counts.done}/{items.length}</span>
@@ -74,9 +100,42 @@ export default function MTodoListTab({ planner }: { planner: TripPlanner }) {
<div className="h-full rounded-full" style={{ width: `${pct}%`, background: 'var(--m-st-confirmed)' }} />
</div>
</div>
)
return (
<>
<TabPanes
asideLabel={t('todo.sidebar.tasks')}
aside={
<>
{progressCard}
{split && items.length > 0 && (
<MTodoSidebar
active={active}
onPick={setActive}
counts={counts}
categories={categories}
catCount={catCount}
smartLabel={filterLabel}
listsLabel={t('todo.sidebar.categories')}
/>
)}
</>
}
>
{/* The list's own head beside the filters pane: what it shows, and its order. */}
{split && items.length > 0 && (
<div className="flex items-center gap-[6px]">
<h2 className="min-w-0 flex-1 truncate text-subtitle font-extrabold text-m-ink">
{active.kind === 'smart' ? filterLabel(active.id) : active.name}
</h2>
<span className="flex-none font-geist text-[length:calc(10px*var(--fs-scale-caption,1))] font-bold uppercase tracking-[.09em] text-m-faint">{t('todo.sidebar.sortBy')}</span>
{sortPills}
</div>
)}
{/* ── Filters (spec §4.6) ── */}
{items.length > 0 && (
{!split && items.length > 0 && (
<div className="mt-[10px] flex items-center gap-[6px] overflow-x-auto whitespace-nowrap">
{TODO_SMART_FILTERS.map(f => (
<button
@@ -89,24 +148,7 @@ export default function MTodoListTab({ planner }: { planner: TripPlanner }) {
<span className="font-geist text-[0.5625rem] opacity-70">{filterCount(f)}</span>
</button>
))}
<button
type="button"
onClick={() => toggleSort('priority')}
aria-pressed={sortBy === 'priority'}
className={filterPill(sortBy === 'priority')}
>
<Flag size={11} strokeWidth={2.2} />
{t('todo.priority')}
</button>
<button
type="button"
onClick={() => toggleSort('due')}
aria-pressed={sortBy === 'due'}
className={filterPill(sortBy === 'due')}
>
<Calendar size={11} strokeWidth={2.2} />
{t('todo.detail.dueDate')}
</button>
{sortPills}
{categories.length > 0 && <span className="h-4 w-px flex-none bg-[color:var(--m-rowbr)]" />}
{categories.map(cat => (
<button
@@ -133,17 +175,20 @@ export default function MTodoListTab({ planner }: { planner: TripPlanner }) {
{t('todo.emptyFiltered')}
</div>
) : (
rows.map(item => (
<TaskCard
key={item.id}
item={item}
members={tripMembers}
today={today}
onToggle={toggleItem}
onOpen={openEdit}
/>
))
<CardGrid>
{rows.map(item => (
<TaskCard
key={item.id}
item={item}
members={tripMembers}
today={today}
onToggle={toggleItem}
onOpen={openEdit}
/>
))}
</CardGrid>
)}
</TabPanes>
<MTaskSheet
planner={planner}
@@ -154,7 +199,7 @@ export default function MTodoListTab({ planner }: { planner: TripPlanner }) {
defaultCategory={defaultCategoryForNew}
onClose={closeSheet}
/>
</TabScroller>
</>
)
}
@@ -0,0 +1,108 @@
import type { LucideIcon } from 'lucide-react';
import { AlertCircle, CheckCircle2, Inbox, List, UserRound } from 'lucide-react';
import type { TodoCounts, TodoSmartFilter } from '../../../../components/Todo/todoListModel';
import { TODO_SMART_FILTERS } from '../../../../components/Todo/todoListModel';
import type { TodoActiveFilter } from '../../../../components/Todo/useTodoList';
const SMART_META: Record<TodoSmartFilter, { icon: LucideIcon; color: string; countOf: keyof TodoCounts }> = {
all: { icon: Inbox, color: 'var(--m-act)', countOf: 'open' },
my: { icon: UserRound, color: 'var(--m-st-info)', countOf: 'my' },
overdue: { icon: AlertCircle, color: 'var(--m-st-danger)', countOf: 'overdue' },
done: { icon: CheckCircle2, color: 'var(--m-st-confirmed)', countOf: 'done' },
};
interface MTodoSidebarProps {
active: TodoActiveFilter;
onPick: (filter: TodoActiveFilter) => void;
counts: TodoCounts;
categories: string[];
catCount: (name: string) => number;
smartLabel: (filter: TodoSmartFilter) => string;
listsLabel: string;
}
/**
* The to-do filters as the leading pane of the lists tab on a window with room
* for two (split size class), the way Reminders lays out its lists on an iPad:
* the four smart filters as tiles with their counts, the trip's own lists
* underneath. The phone keeps them as the pill row above the tasks.
*/
export default function MTodoSidebar({
active,
onPick,
counts,
categories,
catCount,
smartLabel,
listsLabel,
}: MTodoSidebarProps) {
return (
<div className="mt-[10px]">
<div className="grid grid-cols-2 gap-2">
{TODO_SMART_FILTERS.map((id) => {
const meta = SMART_META[id];
const on = active.kind === 'smart' && active.id === id;
return (
<button
key={id}
type="button"
onClick={() => onPick({ kind: 'smart', id })}
aria-pressed={on}
className={`flex flex-col gap-2 rounded-2xl border px-3 py-[11px] text-start outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--m-act)] ${
on
? 'border-[color:var(--m-act)] bg-m-act text-m-actfg'
: 'border-[color:var(--m-rowbr)] bg-m-sheetop text-m-ink'
}`}
>
<span className="flex items-center justify-between">
<span
className="flex h-7 w-7 items-center justify-center rounded-full text-[color:var(--m-sheetop)]"
style={{ background: on ? 'var(--m-actfg)' : meta.color, color: on ? 'var(--m-act)' : undefined }}
>
<meta.icon size={15} strokeWidth={2.3} />
</span>
<span className="font-geist text-subtitle font-extrabold tabular-nums">{counts[meta.countOf]}</span>
</span>
<span className={`truncate text-caption font-bold ${on ? '' : 'text-m-muted'}`}>{smartLabel(id)}</span>
</button>
);
})}
</div>
{categories.length > 0 && (
<>
<div className="mb-[4px] mt-[18px] px-[2px] font-geist text-[length:calc(10px*var(--fs-scale-caption,1))] font-bold uppercase tracking-[.09em] text-m-faint">
{listsLabel}
</div>
<div className="overflow-hidden rounded-2xl border border-[color:var(--m-rowbr)] bg-m-sheetop">
{categories.map((name) => {
const on = active.kind === 'category' && active.name === name;
return (
<button
key={name}
type="button"
onClick={() => onPick({ kind: 'category', name })}
aria-pressed={on}
className={`flex w-full items-center gap-[10px] border-b border-[color:var(--m-rowbr)] px-3 py-[10px] text-start outline-none last:border-b-0 focus-visible:bg-[color:var(--m-ic)] ${
on ? 'bg-[color:var(--m-ic)]' : ''
}`}
>
<List size={14} strokeWidth={2.2} className="flex-none text-m-muted" />
<span
className={`min-w-0 flex-1 truncate text-[length:calc(12.5px*var(--fs-scale-body,1))] ${on ? 'font-bold' : 'font-semibold'} text-m-ink`}
>
{name}
</span>
<span className="flex-none font-geist text-[length:calc(11px*var(--fs-scale-caption,1))] font-bold tabular-nums text-m-faint">
{catCount(name)}
</span>
</button>
);
})}
</div>
</>
)}
</div>
);
}
@@ -4,7 +4,7 @@ import { formatPriceText } from '../../../../utils/formatters'
import { useTranslation } from '../../../../i18n'
import type { Reservation } from '../../../../types'
import { CardActions, CardDeleteSheet, CardFiles, CardNotes, CardTitleButton, CardWhenAndCode } from './MReservationCardParts'
import { Field, ReservationPluginSlots, SectionHeader, StatusDot, TabScroller, TravelerAvatars, TravelerFilterRow } from './tabChrome'
import { CardGrid, Field, ReservationPluginSlots, SectionHeader, StatusDot, TabScroller, TravelerAvatars, TravelerFilterRow } from './tabChrome'
import { STATUS_COLOR, type MTabScreenProps } from './tabModel'
import { cardWhen, parseTransportMeta } from './transportsModel'
import { TRANSPORT_TYPE_COLOR } from '../../../../components/Planner/bookings/bookingsModel'
@@ -36,7 +36,7 @@ export default function MTransportsTab({ planner, shell }: MTabScreenProps) {
].filter(s => s.rows.length > 0)
return (
<TabScroller>
<TabScroller wide="full">
{showTravelerFilter && (
<TravelerFilterRow
members={planner.tripMembers}
@@ -60,8 +60,9 @@ export default function MTransportsTab({ planner, shell }: MTabScreenProps) {
open={!collapsed[section.id]}
onToggle={() => toggle(section.id)}
/>
{!collapsed[section.id] &&
section.rows.map(res => (
{!collapsed[section.id] && (
<CardGrid>
{section.rows.map(res => (
<TransportCard
key={res.id}
res={res}
@@ -70,7 +71,9 @@ export default function MTransportsTab({ planner, shell }: MTabScreenProps) {
canEdit={canEdit}
compact={shell.transportsCompact}
/>
))}
))}
</CardGrid>
)}
</div>
))}
</TabScroller>
@@ -0,0 +1,40 @@
import type { MTripCollabTab } from '../MTripShell';
/** The collab boards that can stand beside the chat: everything but the chat itself. */
const BOARD_TABS = ['notes', 'links', 'polls'] as const;
export type CollabBoardTab = (typeof BOARD_TABS)[number];
/** Whether a collab tab is one of the boards (not the chat, nor a tab this screen does not know). */
export function isCollabBoard(tab: string): tab is CollabBoardTab {
return (BOARD_TABS as readonly string[]).includes(tab);
}
interface CollabFeatureFlags {
chat: boolean;
notes: boolean;
links: boolean;
polls: boolean;
}
/** The boards this trip has switched on, in the order the header lists them. */
export function collabBoards(features: CollabFeatureFlags): CollabBoardTab[] {
return BOARD_TABS.filter((tab) => (tab === 'links' ? features.links !== false : !!features[tab]));
}
/**
* Whether the collab tab shows the chat beside a board: a window with room for
* two panes (split size class), with the chat and at least one board on.
*/
export function collabSideBySide(split: boolean, features: CollabFeatureFlags): boolean {
return split && !!features.chat && collabBoards(features).length > 0;
}
/**
* The board shown beside the chat. The chat is always on screen there, so a
* tab still set to it (the default, or chosen on a narrower window before a
* rotation) shows the first board instead.
*/
export function collabBoardFor(tab: MTripCollabTab, features: CollabFeatureFlags): CollabBoardTab {
const boards = collabBoards(features);
return tab !== 'chat' && boards.includes(tab) ? tab : (boards[0] ?? 'notes');
}
@@ -5,6 +5,7 @@ import { avatarSrc } from '../../../../utils/avatarSrc'
import GuestBadge from '../../../../components/shared/GuestBadge'
import PluginFrame from '../../../../components/Plugins/PluginFrame'
import { useReservationDetailPlugins } from '../../../../components/Planner/bookings/useReservationDetailPlugins'
import { useIsSplit } from '../../../../hooks/sizeClass'
/**
* Scroll body shared by the list-style trip tabs (transports, bookings, costs,
@@ -21,7 +22,7 @@ export function TabScroller({ children, wide = 'readable' }: { children: ReactNo
return (
<div className="flex h-full flex-col">
<div
className={`min-h-0 flex-1 overflow-x-hidden overflow-y-auto px-4 pb-[calc(var(--bottom-nav-h,84px)+22px)] pt-[calc(var(--m-safe-top,12px)+58px)] wide:px-6 ${
className={`min-h-0 flex-1 overflow-x-hidden overflow-y-auto ${TAB_INSETS} ${
wide === 'readable' ? 'wide:[&>*]:mx-auto wide:[&>*]:max-w-[760px]' : ''
}`}
>
@@ -31,6 +32,52 @@ export function TabScroller({ children, wide = 'readable' }: { children: ReactNo
)
}
/** The room a tab's scroll body keeps clear: the floating top controls above, the dock (or the home indicator) below. */
const TAB_INSETS = 'px-4 pb-[calc(var(--bottom-nav-h,84px)+22px)] pt-[calc(var(--m-safe-top,12px)+58px)] wide:px-6'
const PANE_INSETS = 'pb-[calc(var(--bottom-nav-h,84px)+22px)] pt-[calc(var(--m-safe-top,12px)+58px)]'
/**
* The cards of a list tab (transports, bookings, tasks): one column on a phone;
* on a wide window as many columns of at least a phone's width as the page
* holds, three on a 13" iPad on its side, two upright or on an unfolded
* foldable on its side, one in a narrow Split View. A row's cards share its
* height, so the grid reads as rows rather than as a ragged wall.
*/
export function CardGrid({ children }: { children: ReactNode }) {
return <div className="wide:grid wide:grid-cols-[repeat(auto-fill,minmax(min(100%,340px),1fr))] wide:gap-x-3">{children}</div>
}
/**
* A list tab in two panes once the window has room for them (split size
* class), the iPad Mail and Settings arrangement: `aside` (a summary, the
* filters) in a column of its own on the leading side, `children` (the list)
* beside it, each scrolling on its own so the summary stays in view while the
* list moves. On a foldable that reports its fold, the aside ends at the fold.
* Narrower windows stack the two in one scroll body, the aside first, exactly
* as the phone shows them.
*/
export function TabPanes({ aside, asideLabel, children }: { aside: ReactNode; asideLabel: string; children: ReactNode }) {
const split = useIsSplit()
if (!split) {
return (
<TabScroller>
{aside}
{children}
</TabScroller>
)
}
return (
<div className="flex h-full">
<aside aria-label={asideLabel} className={`m-pane-aside min-h-0 flex-none overflow-y-auto overflow-x-hidden ps-6 pe-3 ${PANE_INSETS}`}>
{aside}
</aside>
<div className={`min-h-0 min-w-0 flex-1 overflow-y-auto overflow-x-hidden pe-6 ps-3 ${PANE_INSETS}`}>
<div className="mx-auto max-w-[760px]">{children}</div>
</div>
</div>
)
}
/** Small count badge next to a section label / settle row (spec 03 §1.1). */
export function CountPill({ children }: { children: ReactNode }) {
return (
+19 -1
View File
@@ -1,4 +1,4 @@
// FE-REPO-FILE-OFF-001 to FE-REPO-FILE-OFF-008
// FE-REPO-FILE-OFF-001 to FE-REPO-FILE-OFF-009
// Offline write paths (#2601), fileRepo.test.ts covers the reads.
import 'fake-indexeddb/auto';
import { http, HttpResponse } from 'msw';
@@ -160,3 +160,21 @@ describe('fileRepo — online', () => {
expect(await offlineDb.mutationQueue.count()).toBe(0);
});
});
describe('fileRepo.previewUrl', () => {
it('FE-REPO-FILE-OFF-009: online a preview gets a signed address, offline the plain one the cache may serve', async () => {
let asked = 0;
server.use(
http.post('/api/auth/resource-token', () => {
asked++;
return HttpResponse.json({ token: 'abc' });
})
);
expect(await fileRepo.previewUrl('/api/trips/1/files/6/download')).toBe('/api/trips/1/files/6/download');
expect(asked).toBe(0);
setOnline(true);
expect(await fileRepo.previewUrl('/api/trips/1/files/6/download')).toBe('/api/trips/1/files/6/download?token=abc');
expect(asked).toBe(1);
});
});
+11
View File
@@ -1,4 +1,5 @@
import { filesApi } from '../api/client'
import { getAuthUrl } from '../api/authUrl'
import { offlineDb, replaceTripRows } from '../db/offlineDb'
import { mutationQueue, generateUUID } from '../sync/mutationQueue'
import { isEffectivelyOffline } from '../sync/networkMode'
@@ -198,4 +199,14 @@ export const fileRepo = {
const link = links.find(matches)
if (link) await filesApi.removeLink(tripId, id, link.id)
},
/**
* A file's address signed for showing it in place (an image tile, a document
* preview), good for one download. Online only: offline it answers the plain
* address, which the service worker serves from its cache when it has the file.
*/
async previewUrl(url: string): Promise<string> {
if (isEffectivelyOffline()) return url
return getAuthUrl(url, 'download')
},
}
+4 -2
View File
@@ -62,10 +62,12 @@ describe('MNavRail', () => {
expect(screen.getByTestId('loc').textContent).toBe('/settings');
});
it('FE-MOB-RAIL-003: administration sits at the foot for an admin', () => {
it('FE-MOB-RAIL-003: administration sits at the foot for an admin, under its short name', () => {
useAuthStore.setState({ user: buildUser({ role: 'admin' }) });
render(<MNavRail />, { initialEntries: ['/dashboard'] });
expect(within(rail()).getByRole('button', { name: 'Admin Settings' })).toBeInTheDocument();
// The rail names it "Admin" (nav.admin): the avatar menu's "Admin Settings"
// ran to two lines in English and was cut in German.
expect(within(rail()).getByRole('button', { name: 'Admin' })).toBeInTheDocument();
});
it("FE-MOB-RAIL-004: the create button keeps the dock's meaning and its exceptions", () => {
+20 -1
View File
@@ -2,7 +2,8 @@ import React from 'react';
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { Route, Routes } from 'react-router';
import { http, HttpResponse } from 'msw';
import { fireEvent, render, screen, waitFor } from '../../../helpers/render';
import { fireEvent, render, screen, waitFor, within } from '../../../helpers/render';
import { setSizeClass } from '../../../helpers/sizeClass';
import { server } from '../../../helpers/msw/server';
import { resetAllStores, seedStore } from '../../../helpers/store';
import { buildAdmin } from '../../../helpers/factories';
@@ -429,4 +430,22 @@ describe('MAdmin', () => {
fireEvent.click(await screen.findByRole('button', { name: 'Administration' }));
expect(screen.queryByRole('button', { name: 'Storage' })).not.toBeInTheDocument();
});
it('FE-MOB-ADMIN-019: with room for two panes the sections stand in a list beside the page', async () => {
setSizeClass('split');
render(<MAdmin />);
await waitFor(() => expect(screen.getByText('alice')).toBeInTheDocument());
const sections = screen.getByRole('navigation', { name: 'Administration' });
expect(within(sections).getByRole('button', { name: 'Users' })).toHaveAttribute('aria-current', 'page');
// No dropdown pill on top of the page: the list beside it is the switcher.
expect(screen.queryByRole('button', { name: 'Administration', expanded: false })).not.toBeInTheDocument();
fireEvent.click(within(sections).getByRole('button', { name: 'Settings' }));
await waitFor(() => expect(screen.getByText('Authentication Methods')).toBeInTheDocument());
expect(within(sections).getByRole('button', { name: 'Settings' })).toHaveAttribute('aria-current', 'page');
fireEvent.click(within(sections).getByRole('button', { name: 'Create User' }));
expect(screen.getByRole('dialog', { name: 'Create User' })).toBeInTheDocument();
});
});
@@ -1,7 +1,8 @@
// FE-MOB-COLSCR-001 to FE-MOB-COLSCR-033
// FE-MOB-COLSCR-001 to FE-MOB-COLSCR-034
import React from 'react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent, waitFor } from '../../../helpers/render'
import { render, screen, fireEvent, waitFor, within } from '../../../helpers/render'
import { setSizeClass } from '../../../helpers/sizeClass'
import MCollections from '../../../../src/mobile/screens/collections/MCollections'
import { ALL_SAVED } from '../../../../src/store/collectionStore'
@@ -695,4 +696,17 @@ describe('MCollections: invites (#2645)', () => {
expect(names).toEqual(['Lisbon', 'Rome'])
expect(screen.getByPlaceholderText('collections.search')).toBeInTheDocument()
})
it('FE-MOB-COLSCR-034: with room for two panes the lists stand beside the places and the head names the list', () => {
setSizeClass('split')
render(<MCollections />)
const lists = screen.getByRole('navigation', { name: 'collections.title' })
expect(within(lists).getByRole('button', { name: /Hamburg/ })).toHaveAttribute('aria-current', 'page')
expect(screen.getByRole('heading', { name: 'Hamburg' })).toBeInTheDocument()
fireEvent.click(within(lists).getByRole('button', { name: /collections.allSaved/ }))
expect(fn('handleSelectList')).toHaveBeenCalledWith(ALL_SAVED)
fireEvent.click(within(lists).getByRole('button', { name: /collections.newList/ }))
expect(fn('setEditorTarget')).toHaveBeenCalledWith('new')
})
})
@@ -1,6 +1,7 @@
// FE-MOB-JDET-001 to FE-MOB-JDET-046
// FE-MOB-JDET-001 to FE-MOB-JDET-048
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { render, screen, fireEvent, waitFor, act } from '../../../helpers/render';
import { setSizeClass } from '../../../helpers/sizeClass';
import MJourneyDetail from '../../../../src/mobile/screens/journey/MJourneyDetail';
import { addonsApi, journeyApi, memoriesApi } from '../../../../src/api/client';
import { useJourneyStore } from '../../../../src/store/journeyStore';
@@ -782,4 +783,24 @@ describe('MJourneyDetail', () => {
expect(screen.getByTestId('day-import-sheet')).toBeInTheDocument();
expect(mocks.captured.dayImport.prep).toBe(recordingPrep);
});
it('FE-MOB-JDET-047: with room for two panes the entries stand as a feed beside the map, in step with it', () => {
setSizeClass('split');
setup();
// Each entry once, in the feed: the strip of cards over the map is gone.
expect(screen.getAllByText('Arrival')).toHaveLength(1);
// The test's translator returns the key, so both day heads read the same.
expect(screen.getAllByText('journey.detail.day')).toHaveLength(2);
// A tap brings the entry to the map, a second one opens it.
fireEvent.click(screen.getByRole('button', { name: /Kyoto/ }));
expect(mocks.focusMarker).toHaveBeenCalledWith('2');
expect(screen.getByRole('button', { name: /Kyoto/ })).toHaveAttribute('aria-current', 'true');
});
it('FE-MOB-JDET-048: the gallery takes the page, feed or not', () => {
setSizeClass('split');
setup({ view: 'gallery' });
expect(screen.queryByText('journey.detail.day')).not.toBeInTheDocument();
});
});
@@ -0,0 +1,417 @@
import { Settings2, Users } from 'lucide-react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { MRailButton } from '../../../src/mobile/components/MRail';
import MSectionNav from '../../../src/mobile/components/MSectionNav';
import MCollListNav from '../../../src/mobile/screens/collections/MCollListNav';
import MJourneyFeed from '../../../src/mobile/screens/journey/MJourneyFeed';
import { storyExcerpt } from '../../../src/mobile/screens/journey/mobileJourneyMeta';
import { CollabSegment } from '../../../src/mobile/screens/trip/MTripShellParts';
import { collabBoardFor, collabBoards, collabSideBySide } from '../../../src/mobile/screens/trip/tabs/collabPanes';
import { FilePreviewPane, FileTile } from '../../../src/mobile/screens/trip/tabs/MFilesBoard';
import MTodoSidebar from '../../../src/mobile/screens/trip/tabs/MTodoSidebar';
import { CardGrid, TabPanes } from '../../../src/mobile/screens/trip/tabs/tabChrome';
import { fileRepo } from '../../../src/repo/fileRepo';
import { ALL_SAVED } from '../../../src/store/collectionStore';
import type { JourneyEntry } from '../../../src/store/journeyStore';
import type { Reservation, TripFile } from '../../../src/types';
import { buildPlanner } from '../../helpers/mobileTrip';
import { fireEvent, render, screen, waitFor, within } from '../../helpers/render';
import { setSizeClass } from '../../helpers/sizeClass';
// FE-MOB-PANES-001 onwards: the panes, grids and labels the touch shell uses on a wide window.
const echo = (key: string, params?: Record<string, string | number>) =>
params ? `${key}:${Object.values(params).join(',')}` : key;
describe('rail labels that fit', () => {
// jsdom lays nothing out, so the label measures what these getters say: a word
// `width` px wide at full size in a 64px box, narrower once it may hyphenate.
function measureLike(width: number, hyphenated: number) {
const proto = HTMLSpanElement.prototype;
vi.spyOn(proto, 'scrollWidth', 'get').mockImplementation(function (this: HTMLElement) {
const fit = Number(this.style.getPropertyValue('--fit') || 1);
return (this.style.hyphens === 'auto' ? hyphenated : width) * fit;
});
vi.spyOn(proto, 'clientWidth', 'get').mockReturnValue(64);
}
afterEach(() => vi.restoreAllMocks());
function label() {
return screen.getByText('Einstellungen');
}
it('FE-MOB-PANES-001: a name that fits keeps its size and its words whole', () => {
measureLike(60, 60);
render(
<MRailButton label="Einstellungen" active={false} onClick={() => {}}>
x
</MRailButton>
);
expect(label().style.getPropertyValue('--fit')).toBe('1');
expect(label().style.hyphens).toBe('manual');
expect(label().style.overflowWrap).toBe('normal');
});
it('FE-MOB-PANES-002: a word a little too wide shrinks the name instead of splitting it', () => {
measureLike(72, 40);
render(
<MRailButton label="Einstellungen" active={false} onClick={() => {}}>
x
</MRailButton>
);
const fit = Number(label().style.getPropertyValue('--fit'));
expect(fit).toBeLessThan(1);
expect(72 * fit).toBeLessThanOrEqual(64.5);
expect(label().style.hyphens).toBe('manual');
});
it('FE-MOB-PANES-003: a word too long even when small is hyphenated, and broken only past that', () => {
measureLike(140, 60);
const { unmount } = render(
<MRailButton label="Einstellungen" active={false} onClick={() => {}}>
x
</MRailButton>
);
expect(label().style.hyphens).toBe('auto');
expect(label().style.overflowWrap).toBe('normal');
unmount();
vi.restoreAllMocks();
measureLike(140, 140);
render(
<MRailButton label="Einstellungen" active={false} onClick={() => {}}>
x
</MRailButton>
);
expect(label().style.overflowWrap).toBe('anywhere');
});
});
describe('collab panes', () => {
const on = { chat: true, notes: true, links: true, polls: true };
it('FE-MOB-PANES-004: the boards beside the chat follow the trip switches, in header order', () => {
expect(collabBoards(on)).toEqual(['notes', 'links', 'polls']);
expect(collabBoards({ ...on, notes: false, links: false })).toEqual(['polls']);
expect(collabSideBySide(true, on)).toBe(true);
expect(collabSideBySide(false, on)).toBe(false);
expect(collabSideBySide(true, { ...on, chat: false })).toBe(false);
expect(collabSideBySide(true, { chat: true, notes: false, links: false, polls: false })).toBe(false);
});
it('FE-MOB-PANES-005: a tab still set to the chat shows the first board beside it', () => {
expect(collabBoardFor('chat', on)).toBe('notes');
expect(collabBoardFor('polls', on)).toBe('polls');
expect(collabBoardFor('notes', { ...on, notes: false })).toBe('links');
});
it('FE-MOB-PANES-006: the header switches the board alone and names the chat over its pane', () => {
const onPick = vi.fn();
const { rerender } = render(<CollabSegment active="chat" onPick={onPick} features={on} split={false} t={echo} />);
expect(screen.getAllByRole('button').map((b) => b.textContent)).toEqual([
'collab.tabs.chat',
'collab.tabs.notes',
'collab.tabs.links',
'collab.tabs.polls',
]);
rerender(<CollabSegment active="chat" onPick={onPick} features={on} split t={echo} />);
expect(screen.getAllByRole('button').map((b) => b.textContent)).toEqual([
'collab.tabs.notes',
'collab.tabs.links',
'collab.tabs.polls',
]);
expect(screen.getByRole('button', { name: 'collab.tabs.notes' })).toHaveAttribute('aria-pressed', 'true');
expect(screen.getByText('collab.tabs.chat').tagName).toBe('SPAN');
fireEvent.click(screen.getByRole('button', { name: 'collab.tabs.polls' }));
expect(onPick).toHaveBeenCalledWith('polls');
});
});
describe('section lists beside the page', () => {
const sections = [
{ id: 'users', label: 'Users', icon: Users },
{ id: 'settings', label: 'Settings', icon: Settings2 },
];
it('FE-MOB-PANES-007: names the screen, leads back and marks the section on show', () => {
const onBack = vi.fn();
const onPick = vi.fn();
render(
<MSectionNav
title="Administration"
backLabel="Back"
onBack={onBack}
sections={sections}
activeId="settings"
onPick={onPick}
action={<button type="button">New user</button>}
/>
);
const nav = screen.getByRole('navigation', { name: 'Administration' });
expect(within(nav).getByRole('button', { name: 'Settings' })).toHaveAttribute('aria-current', 'page');
expect(within(nav).getByRole('button', { name: 'Users' })).not.toHaveAttribute('aria-current');
fireEvent.click(within(nav).getByRole('button', { name: 'Back' }));
fireEvent.click(within(nav).getByRole('button', { name: 'Users' }));
expect(onBack).toHaveBeenCalled();
expect(onPick).toHaveBeenCalledWith('users');
expect(within(nav).getByRole('button', { name: 'New user' })).toBeInTheDocument();
});
it("FE-MOB-PANES-008: the collections' lists, with their counts, the one on show and a new one", () => {
const onSelect = vi.fn();
const onNewList = vi.fn();
render(
<MCollListNav
lists={[
{ id: 1, name: 'Coffee in Kyoto', color: '#a16207', place_count: 4 },
{ id: 2, name: 'Tokyo next time', color: '#2563eb', place_count: 5 },
]}
allSaved={false}
activeId={2}
onSelect={onSelect}
onNewList={onNewList}
t={echo}
/>
);
const tokyo = screen.getByRole('button', { name: /Tokyo next time/ });
expect(tokyo).toHaveAttribute('aria-current', 'page');
expect(tokyo).toHaveTextContent('5');
fireEvent.click(screen.getByRole('button', { name: /collections.allSaved/ }));
fireEvent.click(screen.getByRole('button', { name: /Coffee in Kyoto/ }));
fireEvent.click(screen.getByRole('button', { name: /collections.newList/ }));
expect(onSelect).toHaveBeenNthCalledWith(1, ALL_SAVED);
expect(onSelect).toHaveBeenNthCalledWith(2, 1);
expect(onNewList).toHaveBeenCalled();
});
});
describe('list tabs on a wide window', () => {
it('FE-MOB-PANES-009: cards take as many columns of a phone width as the page holds', () => {
const { container } = render(
<CardGrid>
<div>a</div>
</CardGrid>
);
expect((container.firstChild as HTMLElement).className).toContain(
'wide:grid-cols-[repeat(auto-fill,minmax(min(100%,340px),1fr))]'
);
});
it('FE-MOB-PANES-010: the summary stacks above the list, and stands beside it with room for two panes', () => {
const { rerender } = render(
<TabPanes aside={<p>summary</p>} asideLabel="Budget">
<p>list</p>
</TabPanes>
);
expect(screen.queryByRole('complementary')).not.toBeInTheDocument();
expect(screen.getByText('summary').parentElement).toBe(screen.getByText('list').parentElement);
setSizeClass('split');
rerender(
<TabPanes aside={<p>summary</p>} asideLabel="Budget">
<p>list</p>
</TabPanes>
);
const aside = screen.getByRole('complementary', { name: 'Budget' });
expect(aside).toHaveClass('m-pane-aside');
expect(within(aside).getByText('summary')).toBeInTheDocument();
expect(within(aside).queryByText('list')).not.toBeInTheDocument();
});
it('FE-MOB-PANES-011: the to-do filters as tiles and lists, each one a filter to pick', () => {
const onPick = vi.fn();
render(
<MTodoSidebar
active={{ kind: 'smart', id: 'all' }}
onPick={onPick}
counts={{ total: 6, open: 4, done: 2, overdue: 1, my: 3 }}
categories={['Tickets', 'Money']}
catCount={(name) => (name === 'Tickets' ? 2 : 1)}
smartLabel={(id) => `filter.${id}`}
listsLabel="Lists"
/>
);
const all = screen.getByRole('button', { name: /filter\.all/ });
expect(all).toHaveAttribute('aria-pressed', 'true');
expect(all).toHaveTextContent('4');
expect(screen.getByRole('button', { name: /filter\.overdue/ })).toHaveTextContent('1');
fireEvent.click(screen.getByRole('button', { name: /filter\.done/ }));
fireEvent.click(screen.getByRole('button', { name: /Tickets/ }));
expect(onPick).toHaveBeenNthCalledWith(1, { kind: 'smart', id: 'done' });
expect(onPick).toHaveBeenNthCalledWith(2, { kind: 'category', name: 'Tickets' });
});
});
describe('the journey as a feed beside its map', () => {
function entry(id: number, date: string, overrides: Partial<JourneyEntry> = {}): JourneyEntry {
return {
id,
journey_id: 1,
type: 'entry',
entry_date: date,
title: `Entry ${id}`,
photos: [],
...overrides,
} as JourneyEntry;
}
const days = [
{ date: '2026-11-05', color: '#6366f1' },
{ date: '2026-11-06', color: '#f97316' },
];
it('FE-MOB-PANES-012: the start of a story reads as plain text', () => {
expect(storyExcerpt('# Day one\n\nThe **bamboo** grove, see [the map](https://x.y).\n![img](a.jpg)')).toBe(
'Day one The bamboo grove, see the map.'
);
expect(storyExcerpt(null)).toBe('');
});
it('FE-MOB-PANES-013: entries under their day, the active one marked, a tap handed back with its place', () => {
const onCardTap = vi.fn();
const cardRefs = { current: new Map<number, HTMLDivElement>() };
const entries = [
entry(1, '2026-11-05', { story: 'Landed **late**', mood: 'amazing' }),
entry(2, '2026-11-05'),
entry(3, '2026-11-06', { type: 'skeleton' }),
];
render(
<MJourneyFeed
entries={entries}
days={days}
activeIndex={1}
cardRefs={cardRefs}
onCardTap={onCardTap}
showMood
showWeather
t={echo}
locale="en-US"
/>
);
expect(screen.getAllByText(/journey\.detail\.day:/).map((n) => n.textContent)).toEqual([
'journey.detail.day:1',
'journey.detail.day:2',
]);
expect(screen.getByText('Landed late')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Entry 2/ })).toHaveAttribute('aria-current', 'true');
fireEvent.click(screen.getByRole('button', { name: /Entry 3/ }));
expect(onCardTap).toHaveBeenCalledWith(entries[2], 2);
expect(cardRefs.current.size).toBe(3);
});
});
describe('files as tiles and a preview', () => {
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
// The setup's observer never reports; a tile asks for its picture once it is near the screen.
class InView {
constructor(private readonly report: IntersectionObserverCallback) {}
observe(target: Element) {
this.report(
[{ isIntersecting: true, target } as IntersectionObserverEntry],
this as unknown as IntersectionObserver
);
}
disconnect() {}
unobserve() {}
takeRecords() {
return [];
}
}
const photo = {
id: 6,
trip_id: 1,
original_name: 'Fushimi Inari gates.jpg',
mime_type: 'image/jpeg',
file_size: 402200,
created_at: '2026-11-07T10:00:00Z',
url: '/api/trips/1/files/6/download',
} as unknown as TripFile;
const pass = {
id: 1,
trip_id: 1,
original_name: 'Boarding pass LH 740.pdf',
mime_type: 'application/pdf',
file_size: 46100,
created_at: '2026-11-07T10:00:00Z',
url: '/api/trips/1/files/1/download',
reservation_id: 6,
uploaded_by_name: 'Alex',
} as unknown as TripFile;
it('FE-MOB-PANES-014: a tile shows its picture, and picks, stars and opens its menu', async () => {
vi.stubGlobal('IntersectionObserver', InView);
const preview = vi.spyOn(fileRepo, 'previewUrl').mockResolvedValue('/signed/6');
const onPick = vi.fn();
const onStar = vi.fn();
const onMenu = vi.fn();
const { container } = render(
<FileTile file={photo} selected canStar onPick={onPick} onStar={onStar} onMenu={onMenu} t={echo} />
);
await waitFor(() => expect(container.querySelector('img')).toHaveAttribute('src', '/signed/6'));
expect(preview).toHaveBeenCalledWith('/api/trips/1/files/6/download');
fireEvent.click(screen.getByRole('button', { name: /Fushimi Inari gates\.jpg/ }));
fireEvent.click(screen.getByRole('button', { name: 'files.star' }));
fireEvent.click(screen.getByRole('button', { name: 'files.menu' }));
expect(onPick).toHaveBeenCalled();
expect(onStar).toHaveBeenCalled();
expect(onMenu).toHaveBeenCalled();
expect(screen.getByRole('button', { name: /Fushimi Inari gates\.jpg/ })).toHaveAttribute('aria-pressed', 'true');
});
it('FE-MOB-PANES-015: the preview names the file, what it belongs to, and what to do with it', () => {
vi.spyOn(fileRepo, 'previewUrl').mockResolvedValue('/signed/1');
const reservations = [{ id: 6, title: 'LH 740', type: 'flight' }] as unknown as Reservation[];
const planner = buildPlanner({ reservations, TRANSPORT_TYPES: new Set(['flight']) } as never);
const onOpen = vi.fn();
const onStar = vi.fn();
const onLinks = vi.fn();
const onMenu = vi.fn();
render(
<FilePreviewPane
file={pass}
planner={planner}
onOpen={onOpen}
onStar={onStar}
onLinks={onLinks}
onMenu={onMenu}
/>
);
expect(screen.getByRole('heading', { name: 'Boarding pass LH 740.pdf' })).toBeInTheDocument();
expect(screen.getByText('files.sourceTransport')).toBeInTheDocument();
expect(screen.getByText('LH 740')).toBeInTheDocument();
expect(screen.getByText('Alex')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /common\.open/ }));
fireEvent.click(screen.getByRole('button', { name: /files\.star/ }));
fireEvent.click(screen.getByRole('button', { name: /files\.link/ }));
fireEvent.click(screen.getByRole('button', { name: 'files.menu' }));
expect(onOpen).toHaveBeenCalled();
expect(onStar).toHaveBeenCalled();
expect(onLinks).toHaveBeenCalled();
expect(onMenu).toHaveBeenCalled();
});
it('FE-MOB-PANES-016: without the right to edit files the preview only opens', () => {
vi.spyOn(fileRepo, 'previewUrl').mockResolvedValue('/signed/1');
const planner = buildPlanner({ can: vi.fn(() => false) } as never);
render(
<FilePreviewPane
file={pass}
planner={planner}
onOpen={vi.fn()}
onStar={vi.fn()}
onLinks={vi.fn()}
onMenu={vi.fn()}
/>
);
expect(screen.getByRole('button', { name: /common\.open/ })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /files\.star/ })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: /files\.link/ })).not.toBeInTheDocument();
});
});
@@ -7,8 +7,9 @@ import MAssistantScreen from '../../../../src/mobile/screens/trip/assistant/MAss
import { chatOf, useAssistantStore } from '../../../../src/store/assistantStore';
import { buildPlanner, buildShell } from '../../../helpers/mobileTrip';
import { act, cleanup, fireEvent, render, screen } from '../../../helpers/render';
import { setSizeClass } from '../../../helpers/sizeClass';
// FE-MOB-ASSIST-001 to FE-MOB-ASSIST-017
// FE-MOB-ASSIST-001 to FE-MOB-ASSIST-018
//
// The phone's full-screen chat around the shared chat surface, which is
// replaced here: what is tested is the frame, the header and the way to the
@@ -322,4 +323,16 @@ describe('MAssistantScreen', () => {
});
expect(useAssistantStore.getState().window.open).toBe(false);
});
it('FE-MOB-ASSIST-018: on a wide window the chat is a panel at the trailing edge over a dimmed page, which closes it', () => {
setSizeClass('wide');
renderScreen();
const panel = screen.getByRole('dialog', { name: 'Assistant' });
expect(panel.className).toContain('wide:end-3');
expect(panel.className).toContain('m-panel-in');
const scrim = panel.previousElementSibling as HTMLElement;
expect(scrim).toHaveAttribute('aria-hidden', 'true');
fireEvent.click(scrim);
expect(useAssistantStore.getState().window.open).toBe(false);
});
});
@@ -327,4 +327,12 @@ describe('MBookingsTab', () => {
expect(screen.getByText('Hotel Granvia')).toBeInTheDocument()
expect(screen.queryByTestId('plugin-frame')).not.toBeInTheDocument()
})
it('FE-MOB-BKTAB-024: a section lays its cards in a grid that takes as many columns as the page holds', () => {
renderTab()
const header = screen.getByRole('button', { name: 'reservations.confirmed2' })
const grid = header.nextElementSibling as HTMLElement
expect(grid.className).toContain('wide:grid-cols-[repeat(auto-fill,minmax(min(100%,340px),1fr))]')
expect(within(grid).getByText('Hotel Granvia')).toBeInTheDocument()
})
})
@@ -3,8 +3,9 @@ import MCollabTab from '../../../../src/mobile/screens/trip/tabs/MCollabTab'
import type { MTripCollabTab, TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
import { buildPlanner, buildShell } from '../../../helpers/mobileTrip'
import { render, screen } from '../../../helpers/render'
import { setSizeClass } from '../../../helpers/sizeClass'
// FE-MOB-CTAB-001 to FE-MOB-CTAB-009
// FE-MOB-CTAB-001 to FE-MOB-CTAB-012
// The three sub-panels are self-contained (own API calls + WebSocket listener)
// and covered by their own suites — here only the routing is under test.
@@ -82,4 +83,39 @@ describe('MCollabTab', () => {
expect(container).toBeEmptyDOMElement()
expect(screen.queryByText('mobileTrip.collabFeatureDisabled')).not.toBeInTheDocument()
})
it('FE-MOB-CTAB-010: with room for two panes the chat stays beside the board, and the chat tab shows the first board', () => {
setSizeClass('split')
const { rerender } = renderTab('chat')
expect(screen.getByTestId('chat')).toBeInTheDocument()
expect(screen.getByTestId('notes')).toBeInTheDocument()
rerender(
<MCollabTab
planner={buildPlanner({ collabFeatures: { chat: true, notes: true, polls: true } as TripPlanner['collabFeatures'] })}
shell={buildShell({ collabTab: 'polls' })}
/>,
)
expect(screen.getByTestId('chat')).toBeInTheDocument()
expect(screen.getByTestId('polls')).toBeInTheDocument()
expect(screen.queryByTestId('notes')).not.toBeInTheDocument()
})
it('FE-MOB-CTAB-011: without the chat there is nothing to stand beside, so the board takes the page', () => {
setSizeClass('split')
renderTab('polls', { chat: false })
expect(screen.getByTestId('polls')).toBeInTheDocument()
expect(screen.queryByTestId('chat')).not.toBeInTheDocument()
})
it('FE-MOB-CTAB-012: turning into two panes and back keeps the same chat, draft and scroll included', () => {
renderTab('chat')
const before = screen.getByTestId('chat')
setSizeClass('split')
expect(screen.getByTestId('notes')).toBeInTheDocument()
expect(screen.getByTestId('chat')).toBe(before)
setSizeClass('compact')
expect(screen.queryByTestId('notes')).not.toBeInTheDocument()
expect(screen.getByTestId('chat')).toBe(before)
})
})
@@ -16,6 +16,7 @@ import { buildPlanner, buildShell } from '../../../helpers/mobileTrip'
import { resetAllStores, seedStore } from '../../../helpers/store'
import { server } from '../../../helpers/msw/server'
import { fireEvent, render, screen, waitFor, within } from '../../../helpers/render'
import { setSizeClass } from '../../../helpers/sizeClass'
// FE-MOB-COSTT-001 to FE-MOB-COSTT-053
@@ -931,4 +932,16 @@ describe('MCostsTab', () => {
expect(screen.queryByText('sheet editing 12')).not.toBeInTheDocument()
expect(reload).not.toHaveBeenCalled()
})
it('FE-MOB-COSTT-054: with room for two panes the summary stands beside the expenses, and the search keeps to their column', async () => {
setSizeClass('split')
await renderTab()
const summary = screen.getByRole('complementary', { name: 'budget.title' })
expect(within(summary).getByText('costs.totalSpend')).toBeInTheDocument()
expect(within(summary).queryByPlaceholderText('costs.searchPlaceholder')).not.toBeInTheDocument()
const expenses = screen.getByRole('region', { name: 'costs.expenses' })
expect(within(expenses).getByPlaceholderText('costs.searchPlaceholder')).toBeInTheDocument()
expect(within(expenses).getByRole('heading', { name: 'costs.expenses' })).toBeInTheDocument()
})
})
@@ -6,9 +6,11 @@ import { openFile } from '../../../../src/utils/fileDownload'
import type { MTripShellApi, TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
import type { Place, TripFile } from '../../../../src/types'
import { buildPlanner, buildShell, buildTripActions } from '../../../helpers/mobileTrip'
import { act, fireEvent, render, screen, waitFor } from '../../../helpers/render'
import { setSizeClass } from '../../../helpers/sizeClass'
import { fileRepo } from '../../../../src/repo/fileRepo'
import { act, fireEvent, render, screen, waitFor, within } from '../../../helpers/render'
// FE-MOB-FTAB-001 to FE-MOB-FTAB-025
// FE-MOB-FTAB-001 to FE-MOB-FTAB-027
vi.mock('../../../../src/utils/fileDownload', () => ({
downloadFile: vi.fn(async () => undefined),
@@ -391,6 +393,34 @@ describe('MFilesTab', () => {
expect(planner.tripActions.addFile).not.toHaveBeenCalled()
})
it('FE-MOB-FTAB-026: a wide window shows the files as tiles, and a tile opens its file', () => {
setSizeClass('wide')
vi.spyOn(fileRepo, 'previewUrl').mockResolvedValue('/signed')
renderTab()
expect(screen.queryByRole('complementary')).not.toBeInTheDocument()
expect(rowNames()).toEqual(['beach.jpg', 'guide.pdf', 'notes.txt', 'board.pkpass', 'idea.png'])
fireEvent.click(screen.getByText('guide.pdf').closest('button') as HTMLButtonElement)
expect(openFile).toHaveBeenCalledWith('/api/trips/1/files/1/download', 'guide.pdf')
})
it('FE-MOB-FTAB-027: with room for two panes a tap picks the file and the pane beside the tiles previews it', () => {
setSizeClass('split')
vi.spyOn(fileRepo, 'previewUrl').mockResolvedValue('/signed')
renderTab()
const pane = screen.getByRole('complementary', { name: 'common.preview' })
// The first file in the grid's order is on show until another is picked.
expect(within(pane).getByRole('heading', { name: 'beach.jpg' })).toBeInTheDocument()
fireEvent.click(screen.getAllByText('guide.pdf')[0].closest('button') as HTMLButtonElement)
expect(within(pane).getByRole('heading', { name: 'guide.pdf' })).toBeInTheDocument()
expect(openFile).not.toHaveBeenCalled()
fireEvent.click(within(pane).getByRole('button', { name: /common\.open/ }))
expect(openFile).toHaveBeenCalledWith('/api/trips/1/files/1/download', 'guide.pdf')
fireEvent.click(within(pane).getByRole('button', { name: /files\.link/ }))
expect(screen.getByTestId('link-sheet')).toHaveAttribute('data-file', '1')
})
it('FE-MOB-FTAB-025: the header trash signal opens the trash sheet', () => {
const { rerenderWith } = renderTab()
expect(screen.queryByTestId('trash-sheet')).not.toBeInTheDocument()
@@ -994,4 +994,11 @@ describe('MPackingListTab', () => {
expect(within(itemRow('Socks')).getByText('1/3')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'packing.packedMore' })).not.toBeInTheDocument()
})
it('FE-MOB-PACKTAB-070: on a wide window the categories stand in columns, each card whole', async () => {
await setup()
const card = screen.getAllByRole('button', { expanded: true })[0].parentElement as HTMLElement
expect(card.className).toContain('wide:break-inside-avoid')
expect((card.parentElement as HTMLElement).className).toContain('wide:columns-[300px]')
})
})
@@ -7,7 +7,8 @@ import type { TodoItem, TripMember } from '../../../../src/types'
import { buildUser } from '../../../helpers/factories'
import { buildPlanner, buildTripActions } from '../../../helpers/mobileTrip'
import { resetAllStores, seedStore } from '../../../helpers/store'
import { fireEvent, render, screen } from '../../../helpers/render'
import { fireEvent, render, screen, within } from '../../../helpers/render'
import { setSizeClass } from '../../../helpers/sizeClass'
// FE-MOB-TODO-001 to FE-MOB-TODO-019
@@ -337,4 +338,25 @@ describe('MTodoListTab', () => {
expect(screen.getByText('sometime')).toBeInTheDocument()
})
it('FE-MOB-TODO-021: with room for two panes the filters stand beside the tasks, which take their name as a head', () => {
setSizeClass('split')
setup([
todo({ id: 1, name: 'Visa', category: 'Before' }),
todo({ id: 2, name: 'Insurance', checked: 1 }),
])
const filters = screen.getByRole('complementary', { name: 'todo.sidebar.tasks' })
expect(within(filters).getByText('todo.sidebar.categories')).toBeInTheDocument()
expect(screen.getByRole('heading', { name: 'todo.filter.all' })).toBeInTheDocument()
expect(screen.getByText('Visa')).toBeInTheDocument()
expect(screen.queryByText('Insurance')).not.toBeInTheDocument()
fireEvent.click(within(filters).getByRole('button', { name: /todo.filter.done/ }))
expect(screen.getByRole('heading', { name: 'todo.filter.done' })).toBeInTheDocument()
expect(screen.getByText('Insurance')).toBeInTheDocument()
fireEvent.click(within(filters).getByRole('button', { name: /Before/ }))
expect(screen.getByRole('heading', { name: 'Before' })).toBeInTheDocument()
expect(screen.getByText('Visa')).toBeInTheDocument()
})
})
@@ -194,4 +194,24 @@ describe('MTripShell on a wide window', () => {
expect(shellApi().mapFront).toBe(false);
expect(screen.getByRole('button', { name: 'mobileTrip.mapView' })).toBeInTheDocument();
});
it('FE-MOB-SHELLW-008: beside the plan there is no all-days switch to empty it, on the bare map and beside the places there is', () => {
setSizeClass('split');
renderShell();
expect(screen.queryByRole('button', { name: 'mobileTrip.allDays' })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'trip.mobilePlaces' }));
expect(screen.getByRole('button', { name: 'mobileTrip.allDays' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'mobileTrip.hidePane' }));
expect(screen.getByRole('button', { name: 'mobileTrip.allDays' })).toBeInTheDocument();
});
it('FE-MOB-SHELLW-009: leaving the places for the plan after an all-days deselect brings a day back', () => {
setSizeClass('split');
renderShell({ selectedDayId: null });
fireEvent.click(screen.getByRole('button', { name: 'trip.mobilePlaces' }));
fireEvent.click(screen.getByRole('button', { name: 'mobileTrip.travel' }));
expect(mocks.planner.handleSelectDay).toHaveBeenCalledWith(expect.any(Number), true);
});
});