From 0b7cee0b0d6a06bcd0fdb79c237304cd7eba5713 Mon Sep 17 00:00:00 2001 From: Maurice Date: Sat, 7 Nov 2026 16:37:58 +0100 Subject: [PATCH] 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. --- client/native-shell/shell.css | 43 ++ client/scripts/feature-pairs.json | 15 +- client/scripts/i18n-unused-baseline.json | 2 +- client/scripts/rtl-baseline.json | 1 - client/scripts/theme-baseline.json | 10 +- .../Collab/useCollabChatData.test.ts | 59 ++- .../components/Collab/useCollabChatData.ts | 13 + .../Journey/useJourneyCarouselSync.test.ts | 31 +- .../Journey/useJourneyCarouselSync.ts | 92 ++-- client/src/components/Todo/useTodoList.ts | 2 +- client/src/mobile/components/MNavRail.tsx | 2 +- client/src/mobile/components/MRail.tsx | 30 +- client/src/mobile/components/MSectionNav.tsx | 90 ++++ client/src/mobile/components/useFitLabel.ts | 62 +++ client/src/mobile/mobile.css | 39 ++ client/src/mobile/screens/admin/MAdmin.tsx | 53 ++- .../screens/collections/MCollListNav.tsx | 74 ++++ .../screens/collections/MCollections.tsx | 82 ++-- .../src/mobile/screens/journey/MJourney.tsx | 6 +- .../mobile/screens/journey/MJourneyDetail.tsx | 65 ++- .../mobile/screens/journey/MJourneyFeed.tsx | 206 +++++++++ .../screens/journey/mobileJourneyMeta.ts | 14 + .../screens/notifications/MNotifications.tsx | 3 +- .../src/mobile/screens/settings/MSettings.tsx | 64 +-- client/src/mobile/screens/trip/MTripShell.tsx | 41 +- .../mobile/screens/trip/MTripShellParts.tsx | 58 ++- .../trip/assistant/MAssistantScreen.tsx | 274 ++++++------ .../mobile/screens/trip/tabs/MBookingsTab.tsx | 13 +- .../mobile/screens/trip/tabs/MCollabNotes.tsx | 12 +- .../mobile/screens/trip/tabs/MCollabPolls.tsx | 14 +- .../mobile/screens/trip/tabs/MCollabTab.tsx | 47 +- .../screens/trip/tabs/MCostsPayment.tsx | 212 +++++++++ .../mobile/screens/trip/tabs/MCostsTab.tsx | 155 +------ .../mobile/screens/trip/tabs/MFilesBoard.tsx | 311 +++++++++++++ .../mobile/screens/trip/tabs/MFilesTab.tsx | 60 ++- .../screens/trip/tabs/MPackingListTab.tsx | 18 +- .../mobile/screens/trip/tabs/MTodoListTab.tsx | 113 +++-- .../mobile/screens/trip/tabs/MTodoSidebar.tsx | 108 +++++ .../screens/trip/tabs/MTransportsTab.tsx | 13 +- .../mobile/screens/trip/tabs/collabPanes.ts | 40 ++ .../mobile/screens/trip/tabs/tabChrome.tsx | 49 +- client/src/repo/fileRepo.offline.test.ts | 20 +- client/src/repo/fileRepo.ts | 11 + client/tests/unit/mobile/MNavRail.test.tsx | 6 +- .../tests/unit/mobile/admin/MAdmin.test.tsx | 21 +- .../mobile/collections/MCollections.test.tsx | 18 +- .../mobile/journey/MJourneyDetail.test.tsx | 23 +- .../unit/mobile/largeScreenPanes.test.tsx | 417 ++++++++++++++++++ .../mobile/trip/MAssistantScreen.test.tsx | 15 +- .../unit/mobile/trip/MBookingsTab.test.tsx | 8 + .../unit/mobile/trip/MCollabTab.test.tsx | 38 +- .../tests/unit/mobile/trip/MCostsTab.test.tsx | 13 + .../tests/unit/mobile/trip/MFilesTab.test.tsx | 34 +- .../unit/mobile/trip/MPackingListTab.test.tsx | 7 + .../unit/mobile/trip/MTodoListTab.test.tsx | 24 +- .../unit/mobile/trip/MTripShell.wide.test.tsx | 20 + 56 files changed, 2743 insertions(+), 528 deletions(-) create mode 100644 client/src/mobile/components/MSectionNav.tsx create mode 100644 client/src/mobile/components/useFitLabel.ts create mode 100644 client/src/mobile/screens/collections/MCollListNav.tsx create mode 100644 client/src/mobile/screens/journey/MJourneyFeed.tsx create mode 100644 client/src/mobile/screens/trip/tabs/MCostsPayment.tsx create mode 100644 client/src/mobile/screens/trip/tabs/MFilesBoard.tsx create mode 100644 client/src/mobile/screens/trip/tabs/MTodoSidebar.tsx create mode 100644 client/src/mobile/screens/trip/tabs/collabPanes.ts create mode 100644 client/tests/unit/mobile/largeScreenPanes.test.tsx diff --git a/client/native-shell/shell.css b/client/native-shell/shell.css index cff1c917c..c14d2cb86 100644 --- a/client/native-shell/shell.css +++ b/client/native-shell/shell.css @@ -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; + } +} diff --git a/client/scripts/feature-pairs.json b/client/scripts/feature-pairs.json index 8bab0af42..3d334f79f 100644 --- a/client/scripts/feature-pairs.json +++ b/client/scripts/feature-pairs.json @@ -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.", diff --git a/client/scripts/i18n-unused-baseline.json b/client/scripts/i18n-unused-baseline.json index 0539794a9..bd90a9961 100644 --- a/client/scripts/i18n-unused-baseline.json +++ b/client/scripts/i18n-unused-baseline.json @@ -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, diff --git a/client/scripts/rtl-baseline.json b/client/scripts/rtl-baseline.json index c57ded24b..900aa6a77 100644 --- a/client/scripts/rtl-baseline.json +++ b/client/scripts/rtl-baseline.json @@ -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, diff --git a/client/scripts/theme-baseline.json b/client/scripts/theme-baseline.json index 675191e2f..ced157b71 100644 --- a/client/scripts/theme-baseline.json +++ b/client/scripts/theme-baseline.json @@ -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, diff --git a/client/src/components/Collab/useCollabChatData.test.ts b/client/src/components/Collab/useCollabChatData.test.ts index f40c22762..db7fd09a2 100644 --- a/client/src/components/Collab/useCollabChatData.test.ts +++ b/client/src/components/Collab/useCollabChatData.test.ts @@ -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); + }); +}); diff --git a/client/src/components/Collab/useCollabChatData.ts b/client/src/components/Collab/useCollabChatData.ts index bad7e585d..f47807142 100644 --- a/client/src/components/Collab/useCollabChatData.ts +++ b/client/src/components/Collab/useCollabChatData.ts @@ -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); diff --git a/client/src/components/Journey/useJourneyCarouselSync.test.ts b/client/src/components/Journey/useJourneyCarouselSync.test.ts index bf6e7e55c..99752ee07 100644 --- a/client/src/components/Journey/useJourneyCarouselSync.test.ts +++ b/client/src/components/Journey/useJourneyCarouselSync.test.ts @@ -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); + }); }); diff --git a/client/src/components/Journey/useJourneyCarouselSync.ts b/client/src/components/Journey/useJourneyCarouselSync.ts index 78351a828..16cd8dcd9 100644 --- a/client/src/components/Journey/useJourneyCarouselSync.ts +++ b/client/src/components/Journey/useJourneyCarouselSync.ts @@ -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 { 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; + 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({ mapEntries, onOpenEntry, syncMapOnTap = false, + layoutKey, }: JourneyCarouselSyncOptions) { const mapRef = useRef(null); const carouselRef = useRef(null); @@ -106,39 +162,7 @@ export function useJourneyCarouselSync({ }); }, [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' }); diff --git a/client/src/components/Todo/useTodoList.ts b/client/src/components/Todo/useTodoList.ts index f5a9d0a1c..861a3d67f 100644 --- a/client/src/components/Todo/useTodoList.ts +++ b/client/src/components/Todo/useTodoList.ts @@ -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 diff --git a/client/src/mobile/components/MNavRail.tsx b/client/src/mobile/components/MNavRail.tsx index f21fc5304..42b160777 100644 --- a/client/src/mobile/components/MNavRail.tsx +++ b/client/src/mobile/components/MNavRail.tsx @@ -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'), diff --git a/client/src/mobile/components/MRail.tsx b/client/src/mobile/components/MRail.tsx index cb26a1aef..514306845 100644 --- a/client/src/mobile/components/MRail.tsx +++ b/client/src/mobile/components/MRail.tsx @@ -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} - - {label} - + ); } + +function RailLabel({ label, active }: { label: string; active: boolean }) { + const ref = useFitLabel(label); + return ( + + {label} + + ); +} diff --git a/client/src/mobile/components/MSectionNav.tsx b/client/src/mobile/components/MSectionNav.tsx new file mode 100644 index 000000000..6889312c5 --- /dev/null +++ b/client/src/mobile/components/MSectionNav.tsx @@ -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 ( + + ); +} + +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 ( + + ); +} diff --git a/client/src/mobile/components/useFitLabel.ts b/client/src/mobile/components/useFitLabel.ts new file mode 100644 index 000000000..3507dd60f --- /dev/null +++ b/client/src/mobile/components/useFitLabel.ts @@ -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(label: string) { + const ref = useRef(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; +} diff --git a/client/src/mobile/mobile.css b/client/src/mobile/mobile.css index a26f4789d..9610a8c34 100644 --- a/client/src/mobile/mobile.css +++ b/client/src/mobile/mobile.css @@ -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 diff --git a/client/src/mobile/screens/admin/MAdmin.tsx b/client/src/mobile/screens/admin/MAdmin.tsx index ebb3f94cc..e7c47656d 100644 --- a/client/src/mobile/screens/admin/MAdmin.tsx +++ b/client/src/mobile/screens/admin/MAdmin.tsx @@ -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. -
+
+ {split && ( + void navigate('/dashboard')} + sections={sections} + activeId={activeSection.id} + onPick={setActiveTab} + action={ + + } + /> + )} +
{/* Header: back · section switcher · create user */} + {!split && (
+ )} {/* Section dropdown */} - {sectionsOpen && ( + {sectionsOpen && !split && (
{sections.map((section) => ( - + /> ))}
)} @@ -192,7 +215,7 @@ export default function MAdmin() { {/* Stats grid (§6.3) */} {stats && ( -
+
@@ -234,6 +257,8 @@ export default function MAdmin() { activeTab === tab.id ?
{tab.mobileElement ?? tab.element}
: null, )} +
+
) diff --git a/client/src/mobile/screens/collections/MCollListNav.tsx b/client/src/mobile/screens/collections/MCollListNav.tsx new file mode 100644 index 000000000..d617b00f9 --- /dev/null +++ b/client/src/mobile/screens/collections/MCollListNav.tsx @@ -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 ( + <> + +
+ {lists.map((l) => { + const active = activeId === l.id; + return ( + + ); + })} +
+ + + ); +} diff --git a/client/src/mobile/screens/collections/MCollections.tsx b/client/src/mobile/screens/collections/MCollections.tsx index d2d3ca06a..bb271a27e 100644 --- a/client/src/mobile/screens/collections/MCollections.tsx +++ b/client/src/mobile/screens/collections/MCollections.tsx @@ -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 = ( + + ) + 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 && ( + + )} +
+ {/* Header: back · list switcher · edit · share. Beside the lists it names the list on show. */}
+ {split ? ( +

+ {!c.isAllSaved && c.activeCollection && ( + l.id === c.activeId)?.color }} /> + )} + {title} +

+ ) : ( + )} {canEditList && ( -
- {[...c.ownedLists, ...c.sharedLists].map(l => { - const active = c.activeId === l.id - return ( - - ) - })} -
- + {listNav}
)} @@ -429,7 +436,8 @@ export default function MCollections() { ) : c.visiblePlaces.length === 0 ? ( } title={t('collections.empty.noMatchTitle')} /> ) : ( -
+ // Two columns of rows where the page has the room for them. +
{c.visiblePlaces.map(p => ( )} +
+ {/* Sheets */} - {/* Floating header: back + create pill */} -
+ {/* 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. */} +
+

{t('journey.title')}

+ ); +} + +/** 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 ( + + {posterless ? ( + + + + ) : ( + + )} + {morePhotos > 0 && ( + + +{morePhotos} + + )} + + ); +} + +/** 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 ( + + {mood && ( + + + + )} + {weather && ( + + + + )} + {entry.entry_time && ( + + {entry.entry_time.slice(0, 5)} + + )} + + ); +} diff --git a/client/src/mobile/screens/journey/mobileJourneyMeta.ts b/client/src/mobile/screens/journey/mobileJourneyMeta.ts index 9843557ca..fb291699b 100644 --- a/client/src/mobile/screens/journey/mobileJourneyMeta.ts +++ b/client/src/mobile/screens/journey/mobileJourneyMeta.ts @@ -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() +} diff --git a/client/src/mobile/screens/notifications/MNotifications.tsx b/client/src/mobile/screens/notifications/MNotifications.tsx index cf23261bf..2742e6360 100644 --- a/client/src/mobile/screens/notifications/MNotifications.tsx +++ b/client/src/mobile/screens/notifications/MNotifications.tsx @@ -40,7 +40,8 @@ export default function MNotifications() { return ( <> - + {/* On a wide window the bar keeps to the column of the list below it. */} + navigate(-1)}> diff --git a/client/src/mobile/screens/settings/MSettings.tsx b/client/src/mobile/screens/settings/MSettings.tsx index 39e4d55e8..b5fe43db8 100644 --- a/client/src/mobile/screens/settings/MSettings.tsx +++ b/client/src/mobile/screens/settings/MSettings.tsx @@ -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) => ( - - ) - return (
{split && ( - + void navigate('/dashboard')} + sections={tabs} + activeId={active.id} + onPick={setActiveTab} + /> )}
{/* 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 && (
- {tabs.map((tab) => - sectionRow(tab, () => { - setActiveTab(tab.id) - setDropOpen(false) - }), - )} + {tabs.map((tab) => ( + { + setActiveTab(tab.id) + setDropOpen(false) + }} + /> + ))}
)} diff --git a/client/src/mobile/screens/trip/MTripShell.tsx b/client/src/mobile/screens/trip/MTripShell.tsx index fbdb00942..d06f9e4cb 100644 --- a/client/src/mobile/screens/trip/MTripShell.tsx +++ b/client/src/mobile/screens/trip/MTripShell.tsx @@ -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 && ( - ))} - - )} + {trTab === 'collab' && } {/* 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' && ( -
+
} label={t('common.upload')} onClick={() => setUploadFilesSignal(s => s + 1)} /> setOpenDocSyncSignal(s => s + 1)} /> setOpenFilesTrashSignal(s => s + 1)} size={40} className="text-m-muted backdrop-blur-[24px] backdrop-saturate-[1.7]"> diff --git a/client/src/mobile/screens/trip/MTripShellParts.tsx b/client/src/mobile/screens/trip/MTripShellParts.tsx index b0e6c5bb5..f566cc2dc 100644 --- a/client/src/mobile/screens/trip/MTripShellParts.tsx +++ b/client/src/mobile/screens/trip/MTripShellParts.tsx @@ -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 ( -
+
{children}
); } +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 = ( + + {segments.map((seg) => ( + + ))} + + ); + 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 ( + <> +
{pill}
+
+ + {t('collab.tabs.chat')} + +
+ + ); +} + /** * The Files header's sync button, only where there is something behind it. * diff --git a/client/src/mobile/screens/trip/assistant/MAssistantScreen.tsx b/client/src/mobile/screens/trip/assistant/MAssistantScreen.tsx index a07db12ca..807b91d0b 100644 --- a/client/src/mobile/screens/trip/assistant/MAssistantScreen.tsx +++ b/client/src/mobile/screens/trip/assistant/MAssistantScreen.tsx @@ -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 ( - + + ); } @@ -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 ( -
+
))} diff --git a/client/src/mobile/screens/trip/tabs/MCollabNotes.tsx b/client/src/mobile/screens/trip/tabs/MCollabNotes.tsx index f686b0360..292f907d3 100644 --- a/client/src/mobile/screens/trip/tabs/MCollabNotes.tsx +++ b/client/src/mobile/screens/trip/tabs/MCollabNotes.tsx @@ -152,7 +152,7 @@ export default function MCollabNotes({ planner }: MCollabNotesProps) { } return ( - +
{categories.length > 0 ? (
@@ -202,7 +202,10 @@ export default function MCollabNotes({ planner }: MCollabNotesProps) {

{t('collab.notes.empty')}

) : ( - sorted.map(note => ( + // A board on a wide window: the notes in as many columns as fit, each + // card whole in its column. +
+ {sorted.map(note => ( setPendingDeleteId(note.id)} t={t} /> - )) + ))} +
)} +
{!!note.pinned && }