diff --git a/client/scripts/feature-pairs.json b/client/scripts/feature-pairs.json index bc2ef93ff..30922b01f 100644 --- a/client/scripts/feature-pairs.json +++ b/client/scripts/feature-pairs.json @@ -899,6 +899,24 @@ "hooks": ["src/components/Vacay/useVacaySettings.ts"], "desktop": ["src/components/Vacay/VacaySettings.tsx"], "mobile": ["src/mobile/screens/vacay/MVacaySettingsSheet.tsx"] + }, + { + "feature": "Paddle regions admin card", + "hooks": ["src/components/Admin/paddle/usePaddleRegionsAdmin.ts", "src/components/Admin/paddle/paddleRegionsModel.ts"], + "desktop": ["src/components/Admin/paddle/PaddleRegionsPanel.tsx"], + "mobile": ["src/mobile/screens/admin/MAdminPaddleRegionsPanel.tsx"] + }, + { + "feature": "School holiday catalog admin card", + "hooks": ["src/components/Admin/useSchoolHolidayCatalog.ts"], + "desktop": ["src/components/Admin/SchoolHolidayCatalog.tsx"], + "mobile": ["src/mobile/screens/admin/MAdminSchoolHolidayCatalog.tsx"] + }, + { + "feature": "Trip settings: members, guests, public and invite link", + "hooks": ["src/components/Trips/useTripShare.ts"], + "desktop": ["src/components/Trips/TripShareDialog.tsx"], + "mobile": ["src/mobile/screens/trip/sheets/MTripSettingsSheet.tsx"] } ], "mobileOnly": { diff --git a/client/scripts/i18n-unused-baseline.json b/client/scripts/i18n-unused-baseline.json index b5db1317a..1b04bea9f 100644 --- a/client/scripts/i18n-unused-baseline.json +++ b/client/scripts/i18n-unused-baseline.json @@ -1,5 +1,5 @@ { - "admin.ts": 152, + "admin.ts": 126, "atlas.ts": 10, "backup.ts": 10, "budget.ts": 40, diff --git a/client/scripts/layers-baseline.json b/client/scripts/layers-baseline.json index 815ce6cff..bad532aac 100644 --- a/client/scripts/layers-baseline.json +++ b/client/scripts/layers-baseline.json @@ -42,7 +42,6 @@ "src/components/Studio/StudioSidebar.tsx": 1, "src/components/Studio/TravelElements.tsx": 1, "src/components/Todo/useTodoList.ts": 1, - "src/components/Trips/TripMembersModal.tsx": 1, "src/components/shared/ConfirmDialog.tsx": 1, "src/components/shared/CountryFlag.tsx": 1, "src/components/shared/EmptyState.tsx": 1, diff --git a/client/scripts/strict-baseline.json b/client/scripts/strict-baseline.json index 31b55fa95..63f2ed0f5 100644 --- a/client/scripts/strict-baseline.json +++ b/client/scripts/strict-baseline.json @@ -90,7 +90,6 @@ "src/components/Tours/planner/TourPlannerPanels.tsx": 2, "src/components/Tours/planner/useTourPlanner.ts": 2, "src/components/Trips/TripFormModal.tsx": 3, - "src/components/Trips/TripMembersModal.tsx": 1, "src/components/Vacay/VacayCalendar.tsx": 1, "src/components/Vacay/VacayPersons.tsx": 4, "src/components/Vacay/VacaySettings.tsx": 2, @@ -110,14 +109,12 @@ "src/mobile/screens/trip/MTripShell.tsx": 1, "src/mobile/screens/trip/map/MMapArea.tsx": 1, "src/mobile/screens/trip/places/MPlacesBrowser.tsx": 2, - "src/mobile/screens/trip/places/MToursSelectionList.tsx": 6, "src/mobile/screens/trip/plan/planTimelineModel.ts": 1, "src/mobile/screens/trip/roadtrip/MRoadtripTab.tsx": 2, "src/mobile/screens/trip/roadtrip/MRtAlternativesBar.tsx": 1, "src/mobile/screens/trip/roadtrip/MRtCorridorBar.tsx": 1, "src/mobile/screens/trip/roadtrip/MRtCorridorSheet.tsx": 2, "src/mobile/screens/trip/sheets/MReservationSheet.tsx": 1, - "src/mobile/screens/trip/sheets/MTripSheets.tsx": 1, "src/mobile/screens/trip/tabs/transportsModel.ts": 1, "src/mobile/screens/vacay/MVacaySettingsSheet.tsx": 1, "src/pages/AtlasPage.tsx": 6, diff --git a/client/scripts/theme-baseline.json b/client/scripts/theme-baseline.json index 5c871f962..dfd366398 100644 --- a/client/scripts/theme-baseline.json +++ b/client/scripts/theme-baseline.json @@ -116,7 +116,6 @@ "components/Todo/TodoListPanel.tsx": 6, "components/Todo/todoListModel.ts": 3, "components/Trips/TripFormModal.tsx": 9, - "components/Trips/TripMembersModal.tsx": 6, "components/Vacay/VacayBadge.tsx": 3, "components/Vacay/VacayCalendar.tsx": 20, "components/Vacay/VacayMonthCard.tsx": 5, @@ -156,7 +155,7 @@ "mobile/components/MToastHost.tsx": 2, "mobile/components/MUserPickerList.tsx": 1, "mobile/screens/admin/MAdmin.tsx": 9, - "mobile/screens/admin/MAdminAddonManager.tsx": 26, + "mobile/screens/admin/MAdminAddonManager.tsx": 22, "mobile/screens/admin/MAdminAuditLogPanel.tsx": 15, "mobile/screens/admin/MAdminBackupPanel.tsx": 8, "mobile/screens/admin/MAdminCategoryManager.tsx": 8, @@ -166,7 +165,7 @@ "mobile/screens/admin/MAdminMcpTokensPanel.tsx": 14, "mobile/screens/admin/MAdminNotifyMatrix.tsx": 5, "mobile/screens/admin/MAdminPackingTemplateManager.tsx": 8, - "mobile/screens/admin/MAdminPermissionsPanel.tsx": 7, + "mobile/screens/admin/MAdminPermissionsPanel.tsx": 5, "mobile/screens/admin/MAdminPluginsPanel.tsx": 129, "mobile/screens/admin/MAdminSettingsSection.tsx": 10, "mobile/screens/admin/MAdminSheets.tsx": 11, @@ -193,7 +192,7 @@ "mobile/screens/collections/MCollections.tsx": 21, "mobile/screens/dashboard/MDashWidgets.tsx": 22, "mobile/screens/dashboard/MDashboard.tsx": 36, - "mobile/screens/dashboard/MNewTripSheet.tsx": 21, + "mobile/screens/dashboard/MNewTripSheet.tsx": 20, "mobile/screens/dashboard/MUserMenu.tsx": 7, "mobile/screens/journey/MJourney.tsx": 10, "mobile/screens/journey/MJourneyCreateSheet.tsx": 10, @@ -230,11 +229,10 @@ "mobile/screens/trip/places/MPlacesFilterSheet.tsx": 3, "mobile/screens/trip/places/MPlacesSaveToCollectionSheet.tsx": 7, "mobile/screens/trip/places/MPlacesToursModeSwitch.tsx": 1, - "mobile/screens/trip/places/MToursSelectionList.tsx": 9, "mobile/screens/trip/plan/MPlanTimeline.tsx": 14, "mobile/screens/trip/plan/MPlanTimelineRows.tsx": 26, "mobile/screens/trip/roadtrip/MRoadtripRows.tsx": 31, - "mobile/screens/trip/roadtrip/MRoadtripTab.tsx": 17, + "mobile/screens/trip/roadtrip/MRoadtripTab.tsx": 16, "mobile/screens/trip/roadtrip/MRtAlternativesBar.tsx": 8, "mobile/screens/trip/roadtrip/MRtCorridorBar.tsx": 2, "mobile/screens/trip/roadtrip/MRtCorridorSheet.tsx": 7, diff --git a/client/src/components/Admin/addons/addonModel.ts b/client/src/components/Admin/addons/addonModel.ts index a2e2da0e7..75a6e4070 100644 --- a/client/src/components/Admin/addons/addonModel.ts +++ b/client/src/components/Admin/addons/addonModel.ts @@ -49,14 +49,16 @@ export function isPhotosAddon(addon: Addon): boolean { /** The catalog name and description for an addon, falling back to what the server sent. */ export function getAddonLabel(t: (key: string) => string, addon: Addon): { name: string; description: string } { - const nameKey = `admin.addons.catalog.${addon.id}.name`; - const descKey = `admin.addons.catalog.${addon.id}.description`; - const translatedName = t(nameKey); - const translatedDescription = t(descKey); + // Spelled out inside t() so the key check sees which catalog keys this reaches. + const translatedName = t(`admin.addons.catalog.${addon.id}.name`); + const translatedDescription = t(`admin.addons.catalog.${addon.id}.description`); return { - name: translatedName !== nameKey ? translatedName : addon.name, - description: translatedDescription !== descKey ? translatedDescription : addon.description, + name: translatedName !== `admin.addons.catalog.${addon.id}.name` ? translatedName : addon.name, + description: + translatedDescription !== `admin.addons.catalog.${addon.id}.description` + ? translatedDescription + : addon.description, }; } diff --git a/client/src/components/Admin/paddle/PaddleAutoLoadParts.tsx b/client/src/components/Admin/paddle/PaddleAutoLoadParts.tsx index 45205245b..729bc318f 100644 --- a/client/src/components/Admin/paddle/PaddleAutoLoadParts.tsx +++ b/client/src/components/Admin/paddle/PaddleAutoLoadParts.tsx @@ -5,24 +5,17 @@ import { ChoiceChips, SettingRows, SETTINGS_BUTTON, SettingsHint, StatusPill } f import { LABEL, Segmented } from '../../shared/dialogParts'; import { fs } from '../../shared/DialogShell'; import { Tooltip } from '../../shared/Tooltip'; +import { AUTO_LOAD_MODES, autoLoadSizes, formatMegabytes } from './paddleRegionsModel'; import type { usePaddleRegionsAdmin } from './usePaddleRegionsAdmin'; type Admin = ReturnType; -const MODES: readonly PaddleRegionAutoMode[] = ['auto', 'ask', 'off']; const MODE_ICONS = { auto: CloudDownload, ask: Hand, off: Power } as const; -/** The largest region TREK loads by itself, in megabytes: a district, a state, a small country. */ -const SIZES = [100, 250, 500, 1000, 2000] as const; -/** A size in megabytes as the reader's language writes it, from 1000 on in gigabytes. */ +/** `formatMegabytes` in the reader's language. */ function useMegabytes() { const { language } = useTranslation(); - return (mb: number) => - new Intl.NumberFormat(language, { - style: 'unit', - unit: mb >= 1000 ? 'gigabyte' : 'megabyte', - maximumFractionDigits: mb >= 1000 ? 1 : 0, - }).format(mb >= 1000 ? mb / 1000 : mb); + return (mb: number) => formatMegabytes(mb, language); } const MB = 1024 * 1024; @@ -36,9 +29,7 @@ export function AutoLoadSettings({ admin }: { admin: Admin }) { const megabytes = useMegabytes(); const { settings, saving, save } = admin.autoLoad; if (!settings) return null; - const sizes = SIZES.includes(settings.max_mb as (typeof SIZES)[number]) - ? SIZES - : [...SIZES, settings.max_mb].sort((a, b) => a - b); + const sizes = autoLoadSizes(settings.max_mb); return (
@@ -49,7 +40,7 @@ export function AutoLoadSettings({ admin }: { admin: Admin }) { onChange={(mode) => void save({ mode })} label={t('admin.paddleRegions.auto.title')} fill - options={MODES.map((mode) => { + options={AUTO_LOAD_MODES.map((mode) => { const Icon = MODE_ICONS[mode]; return { value: mode, diff --git a/client/src/components/Admin/paddle/PaddleRegionsPanel.tsx b/client/src/components/Admin/paddle/PaddleRegionsPanel.tsx index eaf16d784..9982a3f7b 100644 --- a/client/src/components/Admin/paddle/PaddleRegionsPanel.tsx +++ b/client/src/components/Admin/paddle/PaddleRegionsPanel.tsx @@ -25,8 +25,8 @@ type Admin = ReturnType; * PAD-05A in the admin area: the regions TREK reads paddle obstacles for, * picked from Geofabrik's list, and (PAD-11C) how TREK loads the region of a * place a paddler plans in by itself, with the regions that wait for an admin. - * Both the desktop and the phone admin show this one card under the addons, - * while the Tours addon is on. + * The desktop admin shows this card under the addons while the Tours addon + * is on; the phone has MAdminPaddleRegionsPanel on the same hook. */ export default function PaddleRegionsPanel() { return ( diff --git a/client/src/components/Admin/paddle/paddleRegionsModel.ts b/client/src/components/Admin/paddle/paddleRegionsModel.ts index fb00acc98..83a512f92 100644 --- a/client/src/components/Admin/paddle/paddleRegionsModel.ts +++ b/client/src/components/Admin/paddle/paddleRegionsModel.ts @@ -1,4 +1,25 @@ -import type { PaddleRegion } from '@trek/shared'; +import type { PaddleRegion, PaddleRegionAutoMode } from '@trek/shared'; + +/** How TREK loads a paddler's region by itself, in the order the switch shows them. */ +export const AUTO_LOAD_MODES: readonly PaddleRegionAutoMode[] = ['auto', 'ask', 'off']; +/** The largest region TREK loads by itself, in megabytes: a district, a state, a small country. */ +export const AUTO_LOAD_SIZES = [100, 250, 500, 1000, 2000] as const; + +/** The size choices, with a stored size outside the presets kept among them in its place. */ +export function autoLoadSizes(current: number): number[] { + return (AUTO_LOAD_SIZES as readonly number[]).includes(current) + ? [...AUTO_LOAD_SIZES] + : [...AUTO_LOAD_SIZES, current].sort((a, b) => a - b); +} + +/** A size in megabytes as the reader's language writes it, from 1000 on in gigabytes. */ +export function formatMegabytes(mb: number, language: string): string { + return new Intl.NumberFormat(language, { + style: 'unit', + unit: mb >= 1000 ? 'gigabyte' : 'megabyte', + maximumFractionDigits: mb >= 1000 ? 1 : 0, + }).format(mb >= 1000 ? mb / 1000 : mb); +} /** A region waiting for its read or being read: the list asks again until none is. */ export function isReading(region: Pick): boolean { diff --git a/client/src/components/Journey/JourneyLayeredMap.test.tsx b/client/src/components/Journey/JourneyLayeredMap.test.tsx index de1876e83..25fde6c8b 100644 --- a/client/src/components/Journey/JourneyLayeredMap.test.tsx +++ b/client/src/components/Journey/JourneyLayeredMap.test.tsx @@ -89,6 +89,12 @@ describe('JourneyLayeredMap', () => { expect(last().photos).toBeUndefined(); }); + it('FE-JOURNEY-LAYEREDMAP-002b: the phone draws the layers without their switches', () => { + render(); + expect(last()).toMatchObject({ marks: [MARK], layerVisibility: ALL_VISIBLE }); + expect(screen.queryByRole('group', { name: 'Map layers' })).toBeNull(); + }); + it('FE-JOURNEY-LAYEREDMAP-003: a picked mark opens its card, which goes when its layer is switched off', () => { const onOpenEntry = vi.fn(); const { rerender } = render( diff --git a/client/src/components/Journey/JourneyLayeredMap.tsx b/client/src/components/Journey/JourneyLayeredMap.tsx index fe2196089..a9223514f 100644 --- a/client/src/components/Journey/JourneyLayeredMap.tsx +++ b/client/src/components/Journey/JourneyLayeredMap.tsx @@ -20,8 +20,8 @@ interface Props extends MapProps { layers?: JourneyMapLayersView | null; /** A shared page and the phone timeline draw with Leaflet whatever a setting says. */ engine?: 'auto' | 'leaflet'; - /** Icon-only switches, for a phone. */ - compactControls?: boolean; + /** No layer switches: the phone keeps its map clear and shows every layer (the picked card still opens). */ + hideLayerControl?: boolean; /** How far from the map's top the switches sit, clear of a bar laid over the map. */ controlsTop?: number | string; /** Leads from a picked place or stay to its entry. */ @@ -40,7 +40,7 @@ export default function JourneyLayeredMap({ ref, layers, engine = 'auto', - compactControls = false, + hideLayerControl = false, controlsTop = 12, onOpenEntry, entryTitleOf, @@ -88,9 +88,11 @@ export default function JourneyLayeredMap({ className="pointer-events-none absolute inset-x-3 z-10 flex flex-col items-start gap-2" style={{ top: controlsTop }} > -
- -
+ {!hideLayerControl && ( +
+ +
+ )} {card && ( { }); }); + it('FE-JOURNEY-MAPLAYERS-HOOK-002b: the phone shows every layer whatever was kept, and keeps no switch', () => { + vi.spyOn(journeyMapRepo, 'layers').mockResolvedValue({ + places: { status: 'ok', items: [] }, + routes: { status: 'ok', items: [] }, + stays: { status: 'ok', items: [] }, + }); + localStorage.setItem('trek:journey-map-layers:8', JSON.stringify({ plan: false, entries: false })); + const { result } = renderHook(() => + useJourneyMapLayers({ + source: { journeyId: 8 }, + entryIdsOnMap: NO_ENTRIES, + photoCount: 0, + colorOfDay, + dateOfEntry, + allLayers: true, + }) + ); + + expect(result.current.visibility).toMatchObject({ plan: true, entries: true, recorded: true, photos: true }); + act(() => result.current.toggle('photos')); + expect(result.current.visibility.photos).toBe(false); + expect(JSON.parse(localStorage.getItem('trek:journey-map-layers:8') ?? '{}')).toEqual({ + plan: false, + entries: false, + }); + }); + it('FE-JOURNEY-MAPLAYERS-HOOK-003: a read that fails says so on the plan and the entries, and the recordings still draw', async () => { vi.spyOn(journeyMapRepo, 'layers').mockRejectedValue(new Error('down')); const recorded: JourneyTrack = { diff --git a/client/src/components/Journey/useJourneyMapLayers.ts b/client/src/components/Journey/useJourneyMapLayers.ts index 020a102b2..69c665b26 100644 --- a/client/src/components/Journey/useJourneyMapLayers.ts +++ b/client/src/components/Journey/useJourneyMapLayers.ts @@ -50,6 +50,12 @@ interface Options { colorOfDay: (date: string) => string | null; /** The day an entry is on, for the colour of its stays. */ dateOfEntry: (entryId: number) => string | null; + /** + * Every layer on, whatever this browser kept: the phone shows no switches, + * so a layer once switched off there would stay off with no way back. A + * switch that is still offered then holds only for the visit. + */ + allLayers?: boolean; } const NO_TRACKS: JourneyTrack[] = []; @@ -119,13 +125,14 @@ export function useJourneyMapLayers({ recordedRead = 'ready', colorOfDay, dateOfEntry, + allLayers = false, }: Options): JourneyMapLayersView { const { t, locale } = useTranslation(); const journeyId = source && 'journeyId' in source ? source.journeyId : null; const token = source && 'token' in source ? source.token : null; const storageKey = journeyId !== null ? layersStorageKey({ journeyId }) : token !== null ? layersStorageKey({ token }) : null; - const { visibility, toggle } = useLayerSwitches(storageKey); + const { visibility, toggle } = useLayerSwitches(allLayers ? null : storageKey); const sourceKey = journeyId !== null ? `j:${journeyId}` : token !== null ? `s:${token}` : null; // A shared page reads once; a journey's own map only while the plan or the entries are shown. diff --git a/client/src/components/Trips/TripMembersModal.test.tsx b/client/src/components/Trips/TripMembersModal.test.tsx index 89e338b88..6abe9e1f8 100644 --- a/client/src/components/Trips/TripMembersModal.test.tsx +++ b/client/src/components/Trips/TripMembersModal.test.tsx @@ -1108,77 +1108,3 @@ describe('TripMembersModal', () => { view.unmount(); }); }); - -describe('TripMembersModal on a phone', () => { - let width: number; - beforeEach(() => { - width = window.innerWidth; - Object.defineProperty(window, 'innerWidth', { value: 375, configurable: true, writable: true }); - }); - afterEach(() => { - Object.defineProperty(window, 'innerWidth', { value: width, configurable: true, writable: true }); - }); - - it('FE-COMP-MEMBERS-058b: the public link toggles say whether they are on (#2604)', async () => { - seedStore(usePermissionsStore, { permissions: { share_manage: 'trip_owner' } }); - seedStore(useTripStore, { trip: buildTrip({ id: 1, user_id: ownerUser.id }) }); - server.use(http.get('/api/trips/1/share-link', () => HttpResponse.json({ - token: 'abc123', share_map: true, share_bookings: true, share_packing: false, share_budget: false, share_collab: false, - }))); - render(); - - expect(await screen.findByRole('button', { name: 'Bookings' })).toHaveAttribute('aria-pressed', 'true'); - expect(screen.getByRole('button', { name: 'Costs' })).toHaveAttribute('aria-pressed', 'false'); - expect(screen.getByRole('button', { name: 'Map & Plan' })).toHaveAttribute('aria-disabled', 'true'); - }); - - it('FE-COMP-MEMBERS-058: keeps the sheet it had, with "(you)" beside the name', async () => { - render(); - expect(await screen.findByText(/\(you\)/i)).toBeInTheDocument(); - expect(screen.getByText(/Access \(1/)).toBeInTheDocument(); - }); - - it('FE-COMP-MEMBERS-059: asks with the browser before a member goes, and only then removes', async () => { - const user = userEvent.setup(); - const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false); - mockRoster([{ id: memberUser.id, username: 'alice', avatar_url: null }]); - let deletes = 0; - server.use(http.delete('/api/trips/1/members/:userId', () => { deletes++; return HttpResponse.json({ success: true }); })); - render(); - - await user.click(await screen.findByTitle('Remove access')); - expect(confirmSpy).toHaveBeenCalledWith('Remove access for this user?'); - expect(deletes).toBe(0); - - confirmSpy.mockReturnValue(true); - await user.click(screen.getByTitle('Remove access')); - await waitFor(() => expect(deletes).toBe(1)); - }); - - it('FE-COMP-MEMBERS-060: a guest is renamed and removed through the same questions', async () => { - const user = userEvent.setup(); - vi.spyOn(window, 'confirm').mockReturnValue(true); - mockRoster([{ id: 3, username: 'Grandma', avatar_url: null, is_guest: true }]); - let renamed: string | null = null; - let deleted = false; - server.use( - http.put('/api/trips/1/guests/:userId', async ({ request }) => { - renamed = ((await request.json()) as { name: string }).name; - return HttpResponse.json({ success: true }); - }), - http.delete('/api/trips/1/guests/:userId', () => { deleted = true; return HttpResponse.json({ success: true }); }), - ); - render(); - - await user.click(await screen.findByTitle('Rename')); - const input = screen.getByDisplayValue('Grandma'); - fireEvent.keyDown(input, { key: 'Escape' }); - await user.click(screen.getByTitle('Rename')); - fireEvent.change(screen.getByDisplayValue('Grandma'), { target: { value: 'Granny' } }); - fireEvent.keyDown(screen.getByDisplayValue('Granny'), { key: 'Enter' }); - await waitFor(() => expect(renamed).toBe('Granny')); - - await user.click(screen.getByTitle('Remove access')); - await waitFor(() => expect(deleted).toBe(true)); - }); -}); diff --git a/client/src/components/Trips/TripMembersModal.tsx b/client/src/components/Trips/TripMembersModal.tsx index d02e8120c..8ff93705b 100644 --- a/client/src/components/Trips/TripMembersModal.tsx +++ b/client/src/components/Trips/TripMembersModal.tsx @@ -1,148 +1,4 @@ -import { TripJournalLinkSection } from './TripJournalLinkSection' -import Modal from '../shared/Modal' -import { Crown, UserMinus, UserPlus, Users, LogOut, Link2, Trash2, Copy, Check, UserRound, Pencil, Plus } from 'lucide-react' -import { useTranslation } from '../../i18n' -import CustomSelect from '../shared/CustomSelect' -import { useIsPhone } from '../../mobile/useIsPhone' -import { TripMemberAvatar as Avatar } from './TripMemberAvatar' import TripShareDialog from './TripShareDialog' -import { SHARE_OPTIONS, SHARE_SECTIONS, useShareLink, useTripInviteLink, useTripMembers } from './useTripShare' - -function ShareLinkSection({ tripId, t }: { tripId: number; t: (key: string, params?: Record) => string }) { - const { loading, url: shareUrl, perms, copied, create: handleCreate, setPerm, remove: handleDelete, copy: handleCopy } = useShareLink(tripId) - const handleUpdatePerms = (key: string, val: boolean) => setPerm(key as Parameters[0], val) - - if (loading) return null - - return ( -
-
- - {t('share.linkTitle')} -
-

{t('share.linkHint')}

- - {/* Permission checkboxes */} -
- {[...SHARE_SECTIONS, ...SHARE_OPTIONS].map(opt => ({ ...opt, label: t(opt.label), always: 'always' in opt && !!opt.always })).map(opt => ( - - ))} -
- - {shareUrl ? ( -
-
- - -
- -
- ) : ( - - )} -
- ) -} - -/** - * Trip invite link (#1143). One rotating token per trip that an existing, - * logged-in user opens to join the trip as a member. Mirrors ShareLinkSection - * but the link points at /join/:token (login-required, no registration). - */ -function TripInviteLinkSection({ tripId, t }: { tripId: number; t: (key: string, params?: Record) => string }) { - const { loading, url: inviteUrl, busy, copied, create, remove, copy } = useTripInviteLink(tripId) - - if (loading) return null - - return ( -
-
- - {t('trip.invite.linkTitle')} -
-

{t('trip.invite.linkHint')}

- - {inviteUrl ? ( -
-
- - -
-
- - -
-
- ) : ( - - )} -
- ) -} interface TripMembersModalProps { isOpen: boolean @@ -154,259 +10,7 @@ interface TripMembersModalProps { onMembersChanged?: () => void } -/** Share: the new dialog on the desktop; the phone, which opens this from its More sheet, keeps the sheet below. */ +/** Share: the trip's members, guests and links. The phone has its own sheet (MTripSettingsSheet) on the same hooks. */ export default function TripMembersModal(props: TripMembersModalProps) { - const isPhone = useIsPhone() - return isPhone ? : -} - -function TripMembersSheet({ isOpen, onClose, tripId, tripTitle, onMembersChanged }: TripMembersModalProps) { - const { t } = useTranslation() - const m = useTripMembers({ isOpen, tripId, onClose, onMembersChanged }) - const { - user, loading, realMembers, guests, allUsers, availableUsers, isCurrentOwner, canManageMembers, canManageShare, - selectedUserId, setSelectedUserId, adding, transferringId, removingId, - newGuestName, setNewGuestName, addingGuest, renamingGuestId, renameValue, setRenameValue, - } = m - const handleAdd = () => void m.add() - const handleAddGuest = () => void m.addGuest() - const handleRenameGuest = (userId: number) => void m.commitRename(userId) - const handleTransfer = (newOwnerId: number, username: string) => { - if (confirm(t('members.confirmTransfer', { name: username }))) void m.transfer(newOwnerId) - } - const handleRemove = (userId: number, isSelf: boolean) => { - if (confirm(isSelf ? t('members.confirmLeave') : t('members.confirmRemove'))) void m.remove(userId, isSelf) - } - const handleDeleteGuest = (userId: number) => { - if (confirm(t('members.confirmRemoveGuest'))) void m.removeGuest(userId) - } - - return ( - -
- - - {/* Left column: Members */} -
- - {/* Trip name */} -
-
{t('nav.trip')}
-
{tripTitle}
-
- - {/* Add member dropdown */} - {canManageMembers &&
- -
- setSelectedUserId(String(value))} - placeholder={t('members.selectUser')} - options={[ - { value: '', label: t('members.selectUser') }, - // As a string: the select compares values strictly, and the chosen id is - // kept as a string, so a number here left the trigger on its placeholder. - ...availableUsers.map(u => ({ - value: String(u.id), - label: u.username, - })), - ]} - searchable - style={{ flex: 1 }} - size="sm" - /> - -
- {availableUsers.length === 0 && allUsers.length > 0 && canManageMembers && ( -

{t('members.allHaveAccess')}

- )} -
} - - {/* Members list */} -
-
- - - {t('members.access')} ({realMembers.length} {t('members.persons', { count: realMembers.length })}) - -
- - {loading ? ( -
- {[1, 2].map(i => ( -
- ))} -
- ) : ( -
- {realMembers.map(member => { - const isSelf = member.id === user?.id - const canRemove = isSelf || (canManageMembers && member.role !== 'owner') - return ( -
- -
-
- {member.username} - {isSelf && ({t('members.you')})} - {member.role === 'owner' && ( - - {t('members.owner')} - - )} -
-
- {isCurrentOwner && member.role !== 'owner' && ( - - )} - {canRemove && ( - - )} -
- ) - })} -
- )} -
- - {/* Guests (#1362) — accountless participants, managed by the owner */} - {(isCurrentOwner || guests.length > 0) && ( -
-
- - - {t('members.guests')}{guests.length > 0 ? ` (${guests.length})` : ''} - -
-

{t('members.guestsHint')}

- -
- {guests.map(g => ( -
- - {renamingGuestId === g.id ? ( - setRenameValue(e.target.value)} - onKeyDown={e => { if (e.key === 'Enter') handleRenameGuest(g.id); if (e.key === 'Escape') m.cancelRename() }} - onBlur={() => handleRenameGuest(g.id)} - maxLength={50} - className="bg-surface border border-edge text-content" - style={{ flex: 1, minWidth: 0, fontSize: 'calc(13px * var(--fs-scale-body, 1))', padding: '4px 8px', borderRadius: 8, outline: 'none', fontFamily: 'inherit' }} - /> - ) : ( -
- {g.username} - - {t('members.guest')} - -
- )} - {isCurrentOwner && renamingGuestId !== g.id && ( - <> - - - - )} -
- ))} -
- - {isCurrentOwner && ( -
0 ? 8 : 0 }}> - setNewGuestName(e.target.value)} - onKeyDown={e => { if (e.key === 'Enter') handleAddGuest() }} - placeholder={t('members.guestNamePlaceholder')} - maxLength={50} - className="bg-surface border border-edge text-content" - style={{ flex: 1, minWidth: 0, fontSize: 'calc(13px * var(--fs-scale-body, 1))', padding: '8px 10px', borderRadius: 10, outline: 'none', fontFamily: 'inherit' }} - /> - -
- )} -
- )} - -
- - {/* Right column: Share Link */} - {canManageShare &&
- - -
} - - - - -
- - ) + return } diff --git a/client/src/mobile/components/MPlanOnDesktopBadge.tsx b/client/src/mobile/components/MPlanOnDesktopBadge.tsx new file mode 100644 index 000000000..277d81c8b --- /dev/null +++ b/client/src/mobile/components/MPlanOnDesktopBadge.tsx @@ -0,0 +1,26 @@ +import { Monitor } from 'lucide-react'; +import { useTranslation } from '../../i18n'; + +/** + * The one way the phone says "this is planned on a bigger screen": the road + * trip's empty stage and the tours list both carry it, so the sentence around + * it only has to say what the phone does instead. + */ +export default function MPlanOnDesktopBadge({ className = '' }: { className?: string }) { + const { t } = useTranslation(); + return ( + + + + {t('mobileTrip.planOnDesktop')} + + + ); +} diff --git a/client/src/mobile/screens/admin/MAdmin.tsx b/client/src/mobile/screens/admin/MAdmin.tsx index f7eb6a799..69973a011 100644 --- a/client/src/mobile/screens/admin/MAdmin.tsx +++ b/client/src/mobile/screens/admin/MAdmin.tsx @@ -1,5 +1,5 @@ -import SchoolHolidayCatalog from '../../../components/Admin/SchoolHolidayCatalog' -import PaddleRegionsPanel from '../../../components/Admin/paddle/PaddleRegionsPanel' +import MAdminSchoolHolidayCatalog from './MAdminSchoolHolidayCatalog' +import MAdminPaddleRegionsPanel from './MAdminPaddleRegionsPanel' import { useState } from 'react' import { ArrowUp, @@ -206,7 +206,7 @@ export default function MAdmin() {
- +
)} {activeTab === 'addons' && ( @@ -217,7 +217,7 @@ export default function MAdmin() { collabFeatures={collabFeatures} onToggleCollabFeature={toggleCollabFeature} /> - +
)} {activeTab === 'plugins' && } diff --git a/client/src/mobile/screens/admin/MAdminAddonManager.tsx b/client/src/mobile/screens/admin/MAdminAddonManager.tsx index 25f765ccc..2cfdef8a1 100644 --- a/client/src/mobile/screens/admin/MAdminAddonManager.tsx +++ b/client/src/mobile/screens/admin/MAdminAddonManager.tsx @@ -23,7 +23,7 @@ import { import { useAddonManager } from '../../../components/Admin/addons/useAddonManager' import { useLlmParsingConfig } from '../../../components/Admin/addons/useLlmParsingConfig' import { ImmichIcon, SynologyIcon } from '../../../components/Admin/addons/PhotoProviderIcons' -import { MAdminButton, MAdminCard, MAdminField, MAdminInput, MAdminSecretInput } from './MAdminUi' +import { MAdminButton, MAdminCard, MAdminClampedText, MAdminField, MAdminInput, MAdminSecretInput } from './MAdminUi' import WandererMark from '../../../components/Tours/wanderer/WandererMark' // Keys are the `icon` column from the addons table; anything unknown falls back to @@ -82,9 +82,10 @@ export default function MAdminAddonManager({ bagTrackingEnabled, onToggleBagTrac {/* Header */}
{t('admin.addons.title')}
-

- {t('admin.addons.subtitleBefore')} - TREK + {/* One run of text with the wordmark inside it, so a line break falls between words and never around the logo. */} +

+ {t('admin.addons.subtitleBefore')}{' '} + TREK{' '} {t('admin.addons.subtitleAfter')}

@@ -100,7 +101,7 @@ export default function MAdminAddonManager({ bagTrackingEnabled, onToggleBagTrac {/* Trip Addons */} {tripAddons.length > 0 && ( - + {tripAddons.map((addon, i) => (
@@ -157,7 +158,7 @@ export default function MAdminAddonManager({ bagTrackingEnabled, onToggleBagTrac {/* Global Addons */} {globalAddons.length > 0 && ( - + {globalAddons.map((addon, i) => (
@@ -171,7 +172,7 @@ export default function MAdminAddonManager({ bagTrackingEnabled, onToggleBagTrac {/* Integration Addons */} {integrationAddons.length > 0 && ( - + {integrationAddons.map((addon, i) => (
@@ -191,11 +192,14 @@ export default function MAdminAddonManager({ bagTrackingEnabled, onToggleBagTrac } /** Group subheader: small icon + uppercase label, matches the desktop section band. */ -function MGroupHead({ icon: Icon, label }: { icon: typeof Briefcase; label: string }) { +function MGroupHead({ icon: Icon, label, hint }: { icon: typeof Briefcase; label: string; hint: string }) { return ( -
- - {label} +
+
+ + {label} +
+

{hint}

) } @@ -225,7 +229,7 @@ function MAddonRow({ addon, onToggle, t, first }: MAddonRowProps) { {typeLabel}
-

{label.description}

+ {label.description}
onToggle(addon)} />
@@ -268,7 +272,7 @@ function MSubRow({ icon: Icon, providerIcon: ProviderIcon, title, subtitle, enab
{title}
-
{subtitle}
+ {subtitle}
diff --git a/client/src/mobile/screens/admin/MAdminPaddleRegionsPanel.tsx b/client/src/mobile/screens/admin/MAdminPaddleRegionsPanel.tsx new file mode 100644 index 000000000..cc0632fe2 --- /dev/null +++ b/client/src/mobile/screens/admin/MAdminPaddleRegionsPanel.tsx @@ -0,0 +1,454 @@ +import type { PaddleRegion, PaddleRegionPending } from '@trek/shared'; +import { + Ban, + Check, + ChevronRight, + CloudDownload, + Hand, + Loader2, + Plus, + Power, + RefreshCw, + Search, + Sparkles, + Trash2, + Waves, + WifiOff, +} from 'lucide-react'; +import { + AUTO_LOAD_MODES, + autoLoadSizes, + formatMegabytes, + progressPercent, + regionStatusTone, +} from '../../../components/Admin/paddle/paddleRegionsModel'; +import { usePaddleRegionsAdmin } from '../../../components/Admin/paddle/usePaddleRegionsAdmin'; +import { useTranslation } from '../../../i18n'; +import MBadge from '../../components/MBadge'; +import MChip from '../../components/MChip'; +import MConfirmSheet from '../settings/MConfirmSheet'; +import { MAdminButton, MAdminCard, MAdminClampedText } from './MAdminUi'; + +type Admin = ReturnType; + +const MODE_ICONS = { auto: CloudDownload, ask: Hand, off: Power } as const; +const MB = 1024 * 1024; +const LABEL = + 'mb-1.5 block font-geist text-[length:calc(9.5px*var(--fs-scale-caption,1))] font-bold uppercase tracking-[.08em] text-m-faint'; +const ROWS = + 'flex flex-col divide-y divide-[color:var(--m-rowbr)] rounded-[14px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)]'; +const ROUND = + 'flex h-[32px] w-[32px] flex-none items-center justify-center rounded-full border border-[color:var(--m-rowbr)] bg-[color:var(--m-sheetop)] text-m-muted disabled:opacity-40'; +const TONE = { + neutral: '', + accent: '!text-[color:var(--m-st-info)]', + success: '!text-[color:var(--m-st-confirmed)]', + danger: '!text-[color:var(--m-st-danger)]', +} as const; + +/** How TREK loads a paddler's region by itself, and up to which size. */ +function AutoLoad({ admin }: { admin: Admin }) { + const { t, language } = useTranslation(); + const { settings, saving, save } = admin.autoLoad; + if (!settings) return null; + return ( +
+ {t('admin.paddleRegions.auto.title')} +
+ {AUTO_LOAD_MODES.map((mode) => { + const Icon = MODE_ICONS[mode]; + const active = settings.mode === mode; + return ( + + ); + })} +
+ + {t(`admin.paddleRegions.auto.modeHint.${settings.mode}`)} + + {settings.mode === 'auto' && ( +
+ {t('admin.paddleRegions.auto.maxSize')} +
+ {autoLoadSizes(settings.max_mb).map((mb) => ( + { + if (!saving) void save({ max_mb: mb }); + }} + > + {formatMegabytes(mb, language)} + + ))} +
+ + {t('admin.paddleRegions.auto.maxSizeHint')} + +
+ )} +
+ ); +} + +function RequestRow({ request, admin }: { request: PaddleRegionPending; admin: Admin }) { + const { t, language } = useTranslation(); + const busy = admin.busyId === request.id; + return ( +
+
+ {request.name} +
+
+ {request.declined ? ( + } className={TONE.danger}> + {t('admin.paddleRegions.requests.declined')} + + ) : ( + + {t(`admin.paddleRegions.requests.reason.${request.reason}`)} + + )} + {request.bytes !== null && ( + {formatMegabytes(Math.max(1, Math.round(request.bytes / MB)), language)} + )} + {request.requested_by && ( + {t('admin.paddleRegions.requests.by', { user: request.requested_by })} + )} + + {new Date(request.requested_at).toLocaleDateString(language, { dateStyle: 'medium' })} + +
+
+ {!request.declined && ( + void admin.decline(request.id)}> + {t('admin.paddleRegions.requests.decline')} + + )} + void admin.approve(request.id)}> + {!busy && } + {t('admin.paddleRegions.requests.approve')} + +
+
+ ); +} + +function Requests({ admin }: { admin: Admin }) { + const { t } = useTranslation(); + if (admin.requests.length === 0) return null; + const ordered = [...admin.requests].sort((a, b) => Number(a.declined) - Number(b.declined)); + const open = admin.requests.filter((request) => !request.declined).length; + return ( +
+ + {t('admin.paddleRegions.requests.title')} + {open > 0 && ( + + {open} + + )} + +
+ {ordered.map((request) => ( + + ))} +
+
+ ); +} + +function CatalogSearch({ admin }: { admin: Admin }) { + const { t } = useTranslation(); + const { search } = admin; + return ( +
+ {t('admin.paddleRegions.search')} +
+
+ {search.searching && ( +

+ + {t('common.loading')} +

+ )} + {search.error && ( +

+ {search.error} +

+ )} + {search.hits && search.hits.length === 0 && ( +

+ {t('admin.paddleRegions.noHits')} +

+ )} + {search.hits && search.hits.length > 0 && ( +
+ {search.hits.map((hit) => ( +
+
+
+ {hit.name} +
+ {hit.path.length > 0 && ( +
+ {hit.path.map((part, index) => ( + + {index > 0 && + ))} +
+ )} +
+ {admin.added.has(hit.id) ? ( + }> + {t('admin.paddleRegions.added')} + + ) : ( + + )} +
+ ))} +
+ )} +
+ ); +} + +function RegionRow({ region, admin }: { region: PaddleRegion; admin: Admin }) { + const { t, language } = useTranslation(); + const busy = admin.busyId === region.id; + const percent = progressPercent(region); + const status = + region.status === 'importing' && region.progress + ? t(`admin.paddleRegions.phase.${region.progress.phase}`) + : t(`admin.paddleRegions.status.${region.status}`); + const date = region.source_timestamp + ? new Date(region.source_timestamp).toLocaleDateString(language, { dateStyle: 'medium' }) + : null; + const km = region.network + ? new Intl.NumberFormat(language, { maximumFractionDigits: 0 }).format(region.network.water_km) + : null; + return ( +
+ +
+
+ {region.name} +
+
+ : undefined} + > + {status} + {percent === null ? '' : ` ${new Intl.NumberFormat(language, { style: 'percent' }).format(percent / 100)}`} + + {region.origin === 'auto' && ( + }> + {t('admin.paddleRegions.origin.auto')} + + )} + {region.imported_at && ( + {t('admin.paddleRegions.objects', { count: region.feature_count })} + )} + {region.imported_at && date && {t('admin.paddleRegions.asOf', { date })}} + {km !== null && {t('admin.paddleRegions.network.km', { km })}} + {!region.network && region.imported_at && region.status !== 'importing' && ( + + {t('admin.paddleRegions.network.missing')} + + )} +
+ {region.status === 'failed' && region.error && ( +

+ {region.error} + {region.imported_at ? ` ${t('admin.paddleRegions.keptData')}` : ''} +

+ )} +
+ + +
+ ); +} + +function Regions({ admin }: { admin: Admin }) { + const { t } = useTranslation(); + if (admin.loading) { + return ( +

+ + {t('common.loading')} +

+ ); + } + if (admin.regions.length === 0) { + return ( +

+ {t('admin.paddleRegions.empty')} +

+ ); + } + return ( +
+ {admin.regions.map((region) => ( + + ))} +
+ ); +} + +/** + * PAD-05A/PAD-11C on the phone: the regions TREK reads paddle data for, how it + * loads a paddler's region by itself and the regions that wait for an admin. + * usePaddleRegionsAdmin is the desktop card's state; this is only the markup. + */ +export default function MAdminPaddleRegionsPanel() { + const { t } = useTranslation(); + const admin = usePaddleRegionsAdmin(); + if (!admin.toursOn) return null; + return ( + +
+ + + +
+
+ + {t('admin.paddleRegions.title')} + + {admin.regions.length > 0 && {admin.regions.length}} +
+ + {t('admin.paddleRegions.hint')} + +
+
+ + {admin.offline && ( +

+ + {t('admin.paddleRegions.offline')} +

+ )} + {admin.error && !admin.offline && ( +
+ + {admin.error} + + + {t('admin.paddleRegions.retry')} + +
+ )} + +
+ + + + +
+

+ {t('admin.paddleRegions.attribution')} +

+ + +
+ ); +} diff --git a/client/src/mobile/screens/admin/MAdminPermissionsPanel.tsx b/client/src/mobile/screens/admin/MAdminPermissionsPanel.tsx index 7a85a3222..94ce9066c 100644 --- a/client/src/mobile/screens/admin/MAdminPermissionsPanel.tsx +++ b/client/src/mobile/screens/admin/MAdminPermissionsPanel.tsx @@ -21,23 +21,21 @@ export default function MAdminPermissionsPanel(): React.ReactElement { return ( -
-
-
{t('perm.title')}
-
{t('perm.subtitle')}
+ {/* The title gets the full width and the actions a row of their own: beside a + long title they ran into its second line. */} +
+
{t('perm.title')}
+
{t('perm.subtitle')}
+
+ + + {t('perm.resetDefaults')} + + + {!saving && } + {t('common.save')} +
- - - - - {!saving && } - {t('common.save')} -
diff --git a/client/src/mobile/screens/admin/MAdminSchoolHolidayCatalog.tsx b/client/src/mobile/screens/admin/MAdminSchoolHolidayCatalog.tsx new file mode 100644 index 000000000..e603b9a73 --- /dev/null +++ b/client/src/mobile/screens/admin/MAdminSchoolHolidayCatalog.tsx @@ -0,0 +1,431 @@ +import { + schoolHolidayRegionRequestSchema, + type SchoolHolidayRegionDetail, + type SchoolHolidayRegionRequest, +} from '@trek/shared'; +import { ChevronRight, GraduationCap, Loader2, MapPinned, Plus, Trash2, WifiOff } from 'lucide-react'; +import { useState } from 'react'; +import { useSchoolHolidayCatalog } from '../../../components/Admin/useSchoolHolidayCatalog'; +import { CustomDatePicker } from '../../../components/shared/CustomDateTimePicker'; +import CustomSelect from '../../../components/shared/CustomSelect'; +import { useTranslation } from '../../../i18n'; +import MBadge from '../../components/MBadge'; +import MSheet from '../../components/MSheet'; +import MConfirmSheet from '../settings/MConfirmSheet'; +import { MAdminButton, MAdminCard, MAdminClampedText, MAdminInput, MAdminSheetFrame } from './MAdminUi'; + +const LABEL = + 'mb-1.5 block font-geist text-[length:calc(9.5px*var(--fs-scale-caption,1))] font-bold uppercase tracking-[.08em] text-m-faint'; +const ROUND = + 'flex h-[38px] w-[38px] flex-none items-center justify-center rounded-full border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] text-m-muted disabled:opacity-40'; +const ALERT = + 'rounded-[12px] bg-[color:var(--m-ic)] px-3 py-2 text-[length:calc(11px*var(--fs-scale-caption,1))] font-semibold text-[color:var(--m-st-danger)]'; + +/** A new country of the catalog: its name and its two-letter code. */ +function AddCountrySheet({ + open, + onClose, + onSave, +}: { + open: boolean; + onClose: () => void; + onSave: (code: string, name: string) => Promise; +}) { + const { t } = useTranslation(); + const [name, setName] = useState(''); + const [code, setCode] = useState(''); + const valid = name.trim().length > 0 && /^[A-Z]{2}$/.test(code); + const close = () => { + setName(''); + setCode(''); + onClose(); + }; + return ( + + + + {t('common.cancel')} + + void onSave(code, name.trim()).then(close)}> + {t('common.save')} + + + } + > +
+ + +
+
+
+ ); +} + +interface EditorProps { + region: SchoolHolidayRegionDetail; + busy: boolean; + offline: boolean; + error: string; + onClose: () => void; + onSave: (body: SchoolHolidayRegionRequest) => void; +} + +/** A region's holidays, the phone's own sheet over the same request the desktop editor sends. */ +function RegionSheet({ region, busy, offline, error, onClose, onSave }: EditorProps) { + const { t } = useTranslation(); + const [name, setName] = useState(region.name); + const [holidays, setHolidays] = useState(region.holidays.map((holiday, index) => ({ ...holiday, key: index }))); + const [nextKey, setNextKey] = useState(holidays.length); + const [invalid, setInvalid] = useState(false); + const body = { + name, + revision: region.revision, + holidays: holidays.map(({ name, startDate, endDate }) => ({ name, startDate, endDate })), + }; + const edit = (key: number, change: Partial<(typeof holidays)[number]>) => + setHolidays((rows) => rows.map((row) => (row.key === key ? { ...row, ...change } : row))); + const save = () => { + const parsed = schoolHolidayRegionRequestSchema.safeParse(body); + setInvalid(!parsed.success); + if (parsed.success) onSave(parsed.data); + }; + const locked = busy || offline; + return ( + { + if (!busy) onClose(); + }} + variant="card" + material="opaque" + ariaLabel={name.trim() || t('schoolCatalog.region')} + discardGuard={body} + > + { + if (!busy) onClose(); + }} + footer={ + <> + + {t('common.cancel')} + + + {t('common.save')} + + + } + > +
+ + + {t('schoolCatalog.periodHint')} + + {holidays.map((holiday, index) => ( +
+
+ edit(holiday.key, { name: e.target.value })} + className="bg-[color:var(--m-sheetop)]" + /> + +
+
+
+ {t('schoolCatalog.start')} + edit(holiday.key, { startDate })} + /> +
+
+ {t('schoolCatalog.end')} + edit(holiday.key, { endDate })} + /> +
+
+
+ ))} + + {invalid && ( +

+ {t('schoolCatalog.invalid')} +

+ )} + {error && ( +

+ {error} +

+ )} +
+
+
+ ); +} + +/** + * The school holiday catalog on the phone: the shared holiday regions per + * country that everyone can pick in their vacation settings. The state is + * useSchoolHolidayCatalog's, the desktop card's; this is the phone's markup. + */ +export default function MAdminSchoolHolidayCatalog() { + const { t } = useTranslation(); + const state = useSchoolHolidayCatalog(); + const [country, setCountry] = useState(''); + const [adding, setAdding] = useState(false); + const [remove, setRemove] = useState<{ label: string; action: () => Promise } | null>(null); + const selectedCountry = + state.catalog.countries.find((item) => item.code === country)?.code || state.catalog.countries[0]?.code || ''; + const regions = state.catalog.regions.filter((region) => region.country === selectedCountry); + const disabled = state.busy || state.loading || state.offline; + + return ( + +
+ + + +
+
+ + {t('schoolCatalog.title')} + + {state.catalog.countries.length > 0 && {state.catalog.regions.length}} +
+ + {t('schoolCatalog.hint')} + +
+
+ +
+ {state.offline && ( +

+ + {t('schoolCatalog.offline')} +

+ )} + {state.loading && ( +

+ + {t('common.loading')} +

+ )} + {state.error && !state.editor && ( +
+ + {state.error} + + void state.refresh()}> + {t('schoolCatalog.retry')} + +
+ )} + + {state.catalog.countries.length > 0 ? ( +
+ {t('schoolCatalog.country')} +
+ setCountry(String(value))} + searchable + disabled={disabled} + size="sm" + options={state.catalog.countries.map((item) => ({ value: item.code, label: item.name }))} + placeholder={t('schoolCatalog.country')} + style={{ flex: 1, minWidth: 0 }} + /> + + +
+ {regions.length > 0 && ( +

+ {t('schoolCatalog.deleteHint')} +

+ )} +
+ ) : ( + !state.loading && ( + setAdding(true)} className="self-start"> + + {t('schoolCatalog.addCountry')} + + ) + )} + + {selectedCountry && ( + <> + {regions.length === 0 ? ( +

+ {t('schoolCatalog.empty')} +

+ ) : ( +
+ {regions.map((region) => ( +
+ + +
+ ))} +
+ )} + + + )} +
+ + setAdding(false)} + onSave={async (code, name) => { + if (await state.createCountry({ code, name })) setCountry(code); + }} + /> + {state.editor && ( + state.setEditor(null)} + onSave={(body) => { + if (state.editor) void state.saveRegion(state.editor.country, state.editor.id, body); + }} + /> + )} + setRemove(null)} + title={t('common.delete')} + message={`${remove?.label || ''}. ${t('schoolCatalog.deleteHint')}`} + confirmLabel={t('common.delete')} + cancelLabel={t('common.cancel')} + danger + onConfirm={() => { + if (remove) void remove.action(); + setRemove(null); + }} + /> +
+ ); +} diff --git a/client/src/mobile/screens/admin/MAdminUi.tsx b/client/src/mobile/screens/admin/MAdminUi.tsx index 1f1a52a67..774e0ee0a 100644 --- a/client/src/mobile/screens/admin/MAdminUi.tsx +++ b/client/src/mobile/screens/admin/MAdminUi.tsx @@ -60,6 +60,25 @@ export function MAdminRow({ ) } +/** + * A description that keeps to two lines until it is tapped. On a phone the long + * ones (a map provider's terms, how a loader decides) pushed every control off + * screen; the whole text stays one tap away. + */ +export function MAdminClampedText({ children, className = '' }: { children: string; className?: string }) { + const [open, setOpen] = useState(false) + return ( + + ) +} + /** Labelled form field with an optional hint line under the control. */ export function MAdminField({ label, hint, children }: { label: ReactNode; hint?: ReactNode; children: ReactNode }) { return ( diff --git a/client/src/mobile/screens/dashboard/MNewTripSheet.tsx b/client/src/mobile/screens/dashboard/MNewTripSheet.tsx index d0e4b93dd..5aa655afb 100644 --- a/client/src/mobile/screens/dashboard/MNewTripSheet.tsx +++ b/client/src/mobile/screens/dashboard/MNewTripSheet.tsx @@ -1,13 +1,15 @@ import React, { useEffect, useRef, useState } from 'react' -import { Archive, ArchiveRestore, Camera, Search, X } from 'lucide-react' +import { Archive, ArchiveRestore, Bell, Camera, Search, X } from 'lucide-react' import { useTranslation } from '../../../i18n' import { useCanDo } from '../../../store/permissionsStore' import { useSettingsStore } from '../../../store/settingsStore' +import { useAuthStore } from '../../../store/authStore' +import MChip from '../../components/MChip' import { CustomDatePicker } from '../../../components/shared/CustomDateTimePicker' import CustomSelect from '../../../components/shared/CustomSelect' import { currenciesWith, SYMBOLS } from '../../../components/Budget/BudgetPanel.constants' import type { DashboardTrip } from '../../../pages/dashboard/dashboardModel' -import { type Trip, type TripCreateRequest } from '@trek/shared' +import { MAX_TRIP_DAYS, type Trip, type TripCreateRequest } from '@trek/shared' import MSheet from '../../components/MSheet' import MIconBtn from '../../components/MIconBtn' import MListRow from '../../components/MListRow' @@ -29,6 +31,9 @@ interface MNewTripSheetProps { onArchive?: () => void } +/** The reminder presets the desktop form offers; anything else is a custom number of days. */ +const REMINDER_PRESETS = [0, 1, 3, 9] + function FieldLabel({ children }: { children: React.ReactNode }): React.ReactElement { return (
{children}
@@ -37,7 +42,9 @@ function FieldLabel({ children }: { children: React.ReactNode }): React.ReactEle /** * Create/edit trip sheet — the mobile counterpart of TripFormModal's core flow: - * title, date range and Unsplash cover search (plus device upload). Archiving + * title, date range (or a day count without one), the reminder and the + * Unsplash cover search (plus device upload). The trip planner's More sheet + * opens it for editing too. Archiving * lives here in edit mode, as decided for the grid cards. */ export default function MNewTripSheet({ open, trip, onClose, onSave, onCoverUpdate, onArchive }: MNewTripSheetProps): React.ReactElement { @@ -48,12 +55,19 @@ export default function MNewTripSheet({ open, trip, onClose, onSave, onCoverUpda const fileRef = useRef(null) const canEditTrip = !isEditing || can('trip_edit', trip) const canUploadCover = !isEditing || can('trip_cover_upload', trip) + const currentUser = useAuthStore(s => s.user) + const remindersEnabled = useAuthStore(s => s.tripRemindersEnabled) + // As on the desktop: the reminder is the owner's (or an admin's), and set when creating. + const showReminder = !isEditing || trip?.user_id === currentUser?.id || currentUser?.role === 'admin' const [title, setTitle] = useState('') const [description, setDescription] = useState('') const [startDate, setStartDate] = useState('') const [endDate, setEndDate] = useState('') const [currency, setCurrency] = useState('EUR') + const [dayCount, setDayCount] = useState(7) + const [reminderDays, setReminderDays] = useState(0) + const [customReminder, setCustomReminder] = useState(false) const [error, setError] = useState('') const [isSaving, setIsSaving] = useState(false) const cover = useTripCoverPicker({ trip, title, onCoverUpdate, releasePreviewOnChange: true }) @@ -71,6 +85,10 @@ export default function MNewTripSheet({ open, trip, onClose, onSave, onCoverUpda setStartDate(trip?.start_date || '') setEndDate(trip?.end_date || '') setCurrency(trip?.currency || defaultCurrency) + setDayCount(trip?.day_count || 7) + const reminder = trip ? (trip.reminder_days ?? 3) : (remindersEnabled ? 3 : 0) + setReminderDays(reminder) + setCustomReminder(!REMINDER_PRESETS.includes(reminder)) cover.resetCover(trip?.cover_image || null) setError('') setPendingRemoval(null) @@ -89,13 +107,21 @@ export default function MNewTripSheet({ open, trip, onClose, onSave, onCoverUpda setError('') const formError = tripFormError({ title, startDate, endDate }, trip, t) if (formError) { setError(formError); return null } + const dateless = !startDate && !endDate + if (dateless && (dayCount === '' || dayCount < 1 || dayCount > MAX_TRIP_DAYS)) { + setError(t('dashboard.dayCountRequired')); return null + } + // The count goes along only when it says something new: the trip's own one + // goes stale in the planner, and sending it back would trim a day just added. + const sendDayCount = dateless && (!isEditing || dayCount !== trip?.day_count) return { title: title.trim(), description: description.trim() || null, start_date: startDate || null, end_date: endDate || null, currency, - ...(!startDate && !endDate && !isEditing ? { day_count: 7 } : {}), + ...(showReminder ? { reminder_days: reminderDays } : {}), + ...(sendDayCount ? { day_count: Number(dayCount) } : {}), } } @@ -197,8 +223,28 @@ export default function MNewTripSheet({ open, trip, onClose, onSave, onCoverUpda />
- {!isEditing && !startDate && !endDate && ( -
{t('dashboard.noDateHint')}
+ {!startDate && !endDate && ( + <> +
+ {t('dashboard.dayCount')} + { + if (!canEditTrip) return + setError('') + const raw = e.target.value + setDayCount(raw === '' ? '' : Math.min(MAX_TRIP_DAYS, Math.max(1, Math.floor(Number(raw)) || 1))) + }} + readOnly={!canEditTrip} + className={inputCls} + /> +
+ {!isEditing &&
{t('dashboard.noDateHint')}
} + )}
@@ -214,6 +260,47 @@ export default function MNewTripSheet({ open, trip, onClose, onSave, onCoverUpda />
+ {showReminder && ( +
+ + {t('trips.reminder')} + + {!remindersEnabled ? ( +

{t('trips.reminderDisabledHint')}

+ ) : ( + <> +
+ {REMINDER_PRESETS.map(days => ( + { setError(''); setReminderDays(days); setCustomReminder(false) }}> + {days === 0 ? t('trips.reminderNone') : `${days} ${t(days === 1 ? 'trips.reminderDay' : 'trips.reminderDays')}`} + + ))} + { setCustomReminder(true); if (REMINDER_PRESETS.includes(reminderDays)) setReminderDays(7) }}> + {t('trips.reminderCustom')} + +
+ {customReminder && ( +
+ setReminderDays(Math.max(1, Math.min(30, Number(e.target.value) || 1)))} + aria-label={t('trips.reminderDaysBefore')} + className="h-[34px] w-16 rounded-full border border-[color:var(--m-rowbr)] bg-transparent px-3 text-center text-[length:calc(13px*var(--fs-scale-body,1))] font-semibold text-m-ink outline-none" + /> + {t('trips.reminderDaysBefore')} +
+ )} + + )} +
+ )} + {canUploadCover && (
) { + return (key: string, params?: Record) => { + const value = table[key] ?? en[key] + if (typeof value !== 'string') return key + return value.replace(/\{(\w+)\}/g, (_, name: string) => String(params?.[name] ?? '')) + } +} + +function planner(overrides: Partial = {}): TripPlanner { return { - t: (key: string) => { - const value = en[key] - return typeof value === 'string' ? value : key - }, + t: translate(en), tripId: 7, tours: [tour], + places: [{ id: 42, name: tour.name, route_geometry: '[[47,11],[47.1,11.1]]', route_color: '#ff0000' }], selectedPlaceId: null, - selectedPlace: null, - selectedTour: null, - selectedDayId: null, - selectedAssignmentId: null, - assignments: {}, - days: [], - files: [], - canUploadFiles: false, - reloadTourPlaceIds, + days: [day], handlePlaceClick, + ...overrides, } as unknown as TripPlanner } -function shell(sheet: MTripShellApi['sheet'] = null): MTripShellApi { - return { - sheet, - openSheet, - } as unknown as MTripShellApi +function shell(): MTripShellApi { + return { sheet: null, openSheet } as unknown as MTripShellApi } beforeEach(() => { @@ -73,84 +71,72 @@ beforeEach(() => { }) describe('MToursSelectionList', () => { - it('shows German ADD, difficulty and empty-state text without raw Tours keys', () => { - const germanPlanner = { ...planner(), t: (key: string) => { - const value = de[key] ?? en[key] - return typeof value === 'string' ? value : key - } } - const { rerender } = render() + it('lists a tour like a place: its grade tile, its name, its figures and a plus to put it on a day', () => { + render() - expect(screen.getByRole('button', { name: 'Zum Tag hinzufügen' })).toBeInTheDocument() - expect(screen.getByTitle('T2 – Bergwandern')).toHaveAttribute('aria-label', 'T2 – Bergwandern') - expect(screen.getByText(germanPlanner.t('tours.planner.mobileHint'))).toBeInTheDocument() - rerender() - expect(screen.getByText('Noch keine Touren')).toBeInTheDocument() - expect(screen.getByText(germanPlanner.t('tours.empty.body'))).toBeInTheDocument() - expect(document.body.textContent).not.toMatch(/\btours\.[a-zA-Z]/) - }) + expect(screen.getByRole('img', { name: en['tours.planner.difficulty.t2'] as string })).toHaveTextContent('T2') + expect(screen.getByText(tour.name)).toHaveClass('truncate') + expect(screen.getByTestId('mobile-tour-metrics')).toHaveTextContent('12.5 km') + expect(screen.getByTestId('mobile-tour-metrics')).toHaveTextContent('420 m') + expect(screen.getByText('1 tour')).toBeInTheDocument() - it('gives a tour name the full row and up to two lines', () => { - render() - - const name = screen.getByText(tour.name) - expect(name).toHaveClass('line-clamp-2', 'break-words') - expect(name).toHaveAttribute('title', tour.name) - expect(name).not.toHaveClass('truncate') - }) - - it('keeps the mobile TRIP-PLAN Tours browse list limited to viewing and assignment', () => { - const { container } = render() - expect(screen.getByRole('option', { name: new RegExp(tour.name) })).toHaveAttribute('aria-selected', 'true') - const metrics = screen.getByTestId('mobile-tour-metrics') - expect(metrics).toHaveTextContent('12.5 km') - expect(metrics).toHaveTextContent('420 m') - expect(metrics).toHaveTextContent('390 m') - for (const metric of metrics.children) expect(metric).toHaveClass('whitespace-nowrap') - expect(metrics.children[0].getAttribute('aria-label')).toContain('12.5 km') - expect(metrics.children[1].getAttribute('aria-label')).toContain('420 m') - expect(metrics.children[2].getAttribute('aria-label')).toContain('390 m') - expect(screen.getByText('TREK')).toBeInTheDocument() - expect(screen.getByTitle(en['tours.planner.difficulty.t2'] as string)).toHaveAttribute('aria-label', en['tours.planner.difficulty.t2']) - const add = screen.getByRole('button', { name: 'Add to day' }) - expect(add).toHaveClass('w-full') - add.click() + fireEvent.click(screen.getByRole('button', { name: 'Add to day' })) expect(openSheet).toHaveBeenCalledWith('bract', { placeId: tour.place_id, dayPicker: true }) - expect(container.querySelector('[data-testid="mobile-tour-metrics"]')).toBeTruthy() - expect(screen.queryByRole('button', { name: 'Delete' })).not.toBeInTheDocument() }) - it('keeps a long German name and source/difficulty labels intact', () => { - const germanPlanner = { - ...planner(), - tours: [{ ...tour, name: 'Eine sehr lange deutsche Wanderung durch das abgelegene Gebirge', has_waypoints: false }], - t: (key: string) => { - const value = de[key] ?? en[key] - return typeof value === 'string' ? value : key - }, - } - render() - expect(screen.getByRole('option', { name: /Eine sehr lange deutsche Wanderung/ })).toBeInTheDocument() - expect(screen.getByText('GPX-Import')).toBeInTheDocument() - expect(screen.getByLabelText('T2 – Bergwandern')).toBeInTheDocument() - expect(screen.getByRole('button', { name: 'Zum Tag hinzufügen' })).toHaveClass('w-full') + it('opens the tour on a tap and marks the selected one', () => { + render() + + const open = screen.getByRole('button', { name: new RegExp(tour.name) }) + expect(open).toHaveAttribute('aria-pressed', 'true') + fireEvent.click(open) + expect(handlePlaceClick).toHaveBeenCalledWith(tour.place_id) }) - it('shows no T-badge for an imported tour that carries no difficulty', () => { - const imported = { ...tour, has_waypoints: false, max_hiking_difficulty: null } - render() + it('names the day a planned tour is on, and how many more, instead of the plus', () => { + seedStore(useTripStore, { assignments: { '11': [{ id: 1, place_id: 42 }], '12': [{ id: 2, place_id: 42 }] } }) + const days = [day, { id: 12, day_number: 2, date: '2026-07-09', title: null }] as never + render() - const row = screen.getByRole('option', { name: new RegExp(tour.name) }) - expect(row).not.toHaveTextContent(/T\d/) - expect(row).not.toHaveTextContent('Tnull') - expect(screen.queryByTitle(/^T\d/)).not.toBeInTheDocument() - expect(screen.getByText(en['tours.badge.gpx'] as string)).toBeInTheDocument() + expect(screen.getByText(/Day 1/)).toHaveTextContent('Day 1 +1') + expect(screen.queryByRole('button', { name: 'Add to day' })).not.toBeInTheDocument() }) - it('does not refresh Tours merely because the mobile assignment sheet closes', () => { - const { rerender } = render() + it('offers no plus to someone who may not plan days', () => { + render() + expect(screen.queryByRole('button', { name: 'Add to day' })).not.toBeInTheDocument() + }) - rerender() + it('shows the type of an ungraded tour and where an import came from', () => { + const imported = { ...tour, has_waypoints: false, max_hiking_difficulty: null, tour_type: 'kayak' as const } + render() - expect(reloadTourPlaceIds).not.toHaveBeenCalled() + expect(screen.getByRole('img', { name: en['tourTypes.kayak'] as string })).not.toHaveTextContent(/T\d/) + expect(screen.getByLabelText(en['tours.badge.gpx'] as string)).toHaveTextContent('GPX') + }) + + it('says once that tours are planned on a bigger screen, in German too', () => { + render() + expect(screen.getByText('Geplant wird am Desktop')).toBeInTheDocument() + expect(screen.getByText('1 Tour')).toBeInTheDocument() + expect(document.body.textContent).not.toMatch(/\b(tours|mobileTrip)\.[a-zA-Z]/) + }) + + it('without tours shows the empty state with the same badge', () => { + render() + expect(screen.getByText('Noch keine Touren')).toBeInTheDocument() + expect(screen.getByText(de['mobileTrip.toursEmptyBody'] as string)).toBeInTheDocument() + expect(screen.getByText('Geplant wird am Desktop')).toBeInTheDocument() + }) + + it('narrows the list by a facet and widens it again', () => { + const other = { ...tour, place_id: 43, name: 'Other', max_hiking_difficulty: 4 } + render() + + fireEvent.click(screen.getByRole('button', { name: /^T4/ })) + expect(screen.getByText('Other')).toBeInTheDocument() + expect(screen.queryByText(tour.name)).not.toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: /^T4/ })) + expect(screen.getByText(tour.name)).toBeInTheDocument() }) }) diff --git a/client/src/mobile/screens/trip/places/MToursSelectionList.tsx b/client/src/mobile/screens/trip/places/MToursSelectionList.tsx index 6db2c0fdd..728288a79 100644 --- a/client/src/mobile/screens/trip/places/MToursSelectionList.tsx +++ b/client/src/mobile/screens/trip/places/MToursSelectionList.tsx @@ -1,9 +1,15 @@ -import { ArrowDown, ArrowUp, Mountain, AlertTriangle, CalendarDays } from 'lucide-react' +import { createElement } from 'react' +import { Plus } from 'lucide-react' import type { TourListItem } from '@trek/shared' -import { useSettingsStore } from '../../../../store/settingsStore' -import { formatDistance, formatElevation } from '../../../../utils/units' -import { hikeSourceBadgeLabel, ORIGIN_LABEL_KEY } from '../../../../components/Tours/tourPresentation' +import { hikeSourceBadgeLabel, ORIGIN_LABEL_KEY, tourSource } from '../../../../components/Tours/tourPresentation' +import { tourDayRowIcon } from '../../../../components/Tours/tourDayRowIcon' +import { useTourDays } from '../../../../components/Tours/tourDays' +import { resolveTrackColor } from '../../../../components/Map/trackColors' import type { TourListFilters } from '../../../../components/Tours/useTourListFilters' +import MDancingTrek from '../../../components/MDancingTrek' +import TourDayRowFacts from '../../../../components/Tours/TourDayRowFacts' +import { TourCautionBadge } from '../../../../components/Tours/tourParts' +import MPlanOnDesktopBadge from '../../../components/MPlanOnDesktopBadge' import type { TripPlanner, MTripShellApi } from '../MTripShell' import { useTourPermissions, type TourPermissionProps } from '../../../../components/Tours/useTourPermissions' @@ -32,20 +38,105 @@ function FacetChip({ active, label, count, onClick }: { active: boolean; label: } /** - * Tours mode of the mobile places browser: a selection list that mirrors - * the desktop ToursSidebar's data, with viewing and day assignment only. - * This list does not provide GPX import. + * The tour's tile, the size of a place's avatar: its grade where it has one, + * its type otherwise, and a small mark under it when it came in from outside. + */ +function TourTile({ tour, t }: { tour: TourListItem; t: TripPlanner['t'] }) { + const level = tour.max_hiking_difficulty + const source = tourSource(tour) + const label = level != null ? t(`tours.planner.difficulty.t${level}`) : t(`tourTypes.${tour.tour_type}`) + return ( + + + {level != null + ? `T${level}` + : createElement(tourDayRowIcon(tour.tour_type), { size: 16, strokeWidth: 1.9, 'aria-hidden': true })} + + {source !== 'trek' && ( + + {t(`tours.badgeShort.${source}`)} + + )} + + ) +} + +/** A tour as a row of the browser, laid out like a place: tap opens it, the right side is its day. */ +function TourRow({ tour, planner, shell, canAssign }: { + tour: TourListItem + planner: TripPlanner + shell: MTripShellApi + canAssign: boolean +}) { + const { t } = planner + const tourDays = useTourDays(tour.place_id, planner.days) + const place = planner.places.find(p => p.id === tour.place_id) + const selected = planner.selectedPlaceId === tour.place_id + return ( +
+ + {tourDays.length > 0 ? ( + + {t('planner.dayN', { n: tourDays[0].index + 1 })} + {tourDays.length > 1 && ` +${tourDays.length - 1}`} + + ) : canAssign && ( + + )} +
+ ) +} + +/** + * Tours mode of the mobile places browser: the trip's tours in the same rows + * as its places, to open and to put on a day. Tours are planned on a bigger + * screen, which the badge under the list says once; the phone never creates or + * imports one. * - * "Add to day" reuses the existing 'bract' place-actions sheet's day picker - * (the same one MPlacesBrowser's unplanned-place rows open) — a tour is a - * Place under the hood, so no new linking code is needed here either. + * "Add to day" reuses the 'bract' place-actions sheet's day picker, the same + * one an unplanned place's plus opens: a tour is a Place under the hood. */ export default function MToursSelectionList({ planner, shell, filters, canAssign: assignPermission }: MToursSelectionListProps) { const { canAssign } = useTourPermissions({ tripId: planner.tripId, canAssign: assignPermission }) const { t } = planner - const distanceUnit = useSettingsStore(s => s.settings.distance_unit) - const filtered: TourListItem[] = filters.filtered - const { facets, selection } = filters + const { facets, selection, filtered } = filters const facetRow = filters.hasFacets && (
{facets.origin.map(option => ( @@ -63,105 +154,41 @@ export default function MToursSelectionList({ planner, shell, filters, canAssign
) - if (filters.emptyByFilter) { + if (planner.tours.length === 0) { return ( - <> - {facetRow} -
-

{t('tours.filter.empty')}

- +
+ +

{t('tours.empty.title')}

+

{t('mobileTrip.toursEmptyBody')}

+
- - ) - } - - if (filtered.length === 0) { - return ( - <> -

- {t('tours.planner.mobileHint')} -

-
- -

{t('tours.empty.title')}

-

{t('tours.empty.body')}

-
- ) } return ( <> -

- {t('tours.planner.mobileHint')} -

- {facetRow} -
    - {filtered.map(tour => { - const isSelected = planner.selectedPlaceId === tour.place_id - return ( -
  • planner.handlePlaceClick(tour.place_id)} - onKeyDown={event => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); planner.handlePlaceClick(tour.place_id) } }} - className="rounded-2xl border border-[color:var(--m-rowbr)] p-3" - style={{ background: isSelected ? 'var(--border-faint)' : 'var(--m-ic)' }} - > -
    - {tour.name} -
    - - {hikeSourceBadgeLabel(tour, t)} - - {tour.max_hiking_difficulty != null && ( - T{tour.max_hiking_difficulty} - )} - {tour.caution && ( - - - )} -
    -
    - - {formatDistance(tour.distance ?? 0, distanceUnit)} - - {tour.elevation_gain != null && ( - - - )} - {tour.elevation_loss != null && ( - - - )} -
    - -
    -
  • - ) - })} -
+ {facetRow} +
+ + + {t('mobileTrip.toursCount', { count: filtered.length })} + + +
+ {filters.emptyByFilter ? ( +
+ +

{t('tours.filter.empty')}

+ +
+ ) : ( + filtered.map(tour => ) + )} +
+ +
) } diff --git a/client/src/mobile/screens/trip/roadtrip/MRoadtripTab.tsx b/client/src/mobile/screens/trip/roadtrip/MRoadtripTab.tsx index 0c9f2477e..e4b869aa4 100644 --- a/client/src/mobile/screens/trip/roadtrip/MRoadtripTab.tsx +++ b/client/src/mobile/screens/trip/roadtrip/MRoadtripTab.tsx @@ -9,6 +9,7 @@ import MRtAlternativesBar from './MRtAlternativesBar' import { RtAutoRow, RtBookendRow, RtBookingChips, RtDryRow, RtLegRow, RtRideRow, RtSpillRow, RtStopRow, type RowChrome } from './MRoadtripRows' import MBadge from '../../../components/MBadge' import MDancingTrek from '../../../components/MDancingTrek' +import MPlanOnDesktopBadge from '../../../components/MPlanOnDesktopBadge' import { formatDurationShort } from '../../../../components/Roadtrip/roadtripModel' import { navigationTargetLabel, getNavigationTargets } from '../../../../components/Planner/placeNavigation' import { useSettingsStore } from '../../../../store/settingsStore' @@ -485,9 +486,12 @@ function EmptyStage({ planner, loading }: { planner: MTripTabPanelProps['planner {loading ? t('roadtrip.summary.partial') : t('roadtrip.empty.title')}

{!loading && ( -

- {t('mobileTrip.rtPlanOnDesktop')} -

+ <> +

+ {t('mobileTrip.rtDriveOnTwoPlaces')} +

+ + )}
) diff --git a/client/src/mobile/screens/trip/sheets/MTripSettingsSheet.tsx b/client/src/mobile/screens/trip/sheets/MTripSettingsSheet.tsx new file mode 100644 index 000000000..d270f88fd --- /dev/null +++ b/client/src/mobile/screens/trip/sheets/MTripSettingsSheet.tsx @@ -0,0 +1,462 @@ +import { + Check, + Copy, + Crown, + Link2, + LogOut, + Pencil, + Plus, + RefreshCw, + Settings2, + Trash2, + UserMinus, + UserPlus, + UserRound, +} from 'lucide-react'; +import { useState, type ReactNode } from 'react'; +import CustomSelect from '../../../../components/shared/CustomSelect'; +import { TripJournalLinkSection } from '../../../../components/Trips/TripJournalLinkSection'; +import { TripMemberAvatar } from '../../../../components/Trips/TripMemberAvatar'; +import { + SHARE_OPTIONS, + SHARE_SECTIONS, + useShareLink, + useTripInviteLink, + useTripMembers, + type ListedMember, +} from '../../../../components/Trips/useTripShare'; +import { useTranslation } from '../../../../i18n'; +import MChip from '../../../components/MChip'; +import MSheet from '../../../components/MSheet'; +import MConfirmSheet from '../../settings/MConfirmSheet'; +import { Eyebrow, INNER_CLS, TileHeader } from './MTripSheetUi'; + +interface MTripSettingsSheetProps { + open: boolean; + onClose: () => void; + tripId: number; + tripTitle: string; + onMembersChanged?: () => void; +} + +type Question = + | { kind: 'transfer'; id: number; name: string } + | { kind: 'remove'; id: number; self: boolean } + | { kind: 'guest'; id: number }; + +const FIELD = + 'h-[42px] min-w-0 flex-1 rounded-full border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-[14px] text-[length:calc(13px*var(--fs-scale-body,1))] font-medium text-m-ink outline-none placeholder:text-m-faint'; +const PRIMARY = + 'flex h-[42px] flex-none items-center gap-1.5 rounded-full bg-m-act px-4 text-[length:calc(13px*var(--fs-scale-body,1))] font-semibold text-m-actfg disabled:opacity-40'; +const ROUND = + 'flex h-[34px] w-[34px] flex-none items-center justify-center rounded-full bg-[color:var(--m-ic)] text-m-muted disabled:opacity-40'; +const PILL = + 'flex h-[22px] flex-none items-center gap-[3px] rounded-full px-[8px] font-geist text-[length:calc(9.5px*var(--fs-scale-caption,1))] font-bold uppercase tracking-[.06em]'; + +/** A titled block of the sheet: the eyebrow, an optional line under it and the content on the inner surface. */ +function Section({ title, hint, children }: { title: string; hint?: string; children: ReactNode }) { + return ( +
+ {title} + {hint && ( +

+ {hint} +

+ )} +
{children}
+
+ ); +} + +/** A person of the trip: the avatar, the name with its badges, and the round actions. */ +function PersonRow({ + name, + avatarUrl, + badges, + children, +}: { + name: ReactNode; + avatarUrl: string | null; + badges?: ReactNode; + children?: ReactNode; +}) { + return ( +
+ +
+ + {name} + + {badges} +
+ {children} +
+ ); +} + +/** A link with its copy button, read-only, the way the phone shows one to hand on. */ +function LinkField({ + url, + copied, + onCopy, + t, +}: { + url: string; + copied: boolean; + onCopy: () => void; + t: (key: string) => string; +}) { + return ( +
+ + +
+ ); +} + +function ShareLinkSection({ tripId }: { tripId: number }) { + const { t } = useTranslation(); + const link = useShareLink(tripId); + if (link.loading) return null; + return ( +
+
+ {[...SHARE_SECTIONS, ...SHARE_OPTIONS].map((option) => { + const always = 'always' in option && !!option.always; + const on = !!link.perms[option.key]; + return ( + { + if (!always) void link.setPerm(option.key, !on); + }} + > + {on && } + {t(option.label)} + + ); + })} +
+ {link.url ? ( + <> + void link.copy()} t={t} /> + + + ) : ( + + )} +
+ ); +} + +function InviteLinkSection({ tripId }: { tripId: number }) { + const { t } = useTranslation(); + const invite = useTripInviteLink(tripId); + if (invite.loading) return null; + return ( +
+ {invite.url ? ( + <> + void invite.copy()} t={t} /> +
+ + +
+ + ) : ( + + )} +
+ ); +} + +/** + * The trip's settings on the phone (the Mehr sheet's "Trip settings"): who has + * access, the guests without an account, the public link, the invite link and + * the linked journal. The state is useTripShare's, the same the desktop share + * dialog runs on; only the markup and the questions are the phone's own. + */ +export default function MTripSettingsSheet({ + open, + onClose, + tripId, + tripTitle, + onMembersChanged, +}: MTripSettingsSheetProps) { + const { t } = useTranslation(); + const m = useTripMembers({ isOpen: open, tripId, onClose, onMembersChanged }); + const [question, setQuestion] = useState(null); + + const answer = () => { + if (!question) return; + if (question.kind === 'transfer') void m.transfer(question.id); + else if (question.kind === 'remove') void m.remove(question.id, question.self); + else void m.removeGuest(question.id); + setQuestion(null); + }; + const questionText = !question + ? '' + : question.kind === 'transfer' + ? t('members.confirmTransfer', { name: question.name }) + : question.kind === 'guest' + ? t('members.confirmRemoveGuest') + : question.self + ? t('members.confirmLeave') + : t('members.confirmRemove'); + const questionAction = + question?.kind === 'transfer' + ? t('members.makeOwner') + : question?.kind === 'remove' && question.self + ? t('members.leaveTrip') + : t('members.removeAccess'); + + const memberActions = (member: ListedMember) => { + const self = member.id === m.user?.id; + const canRemove = self || (m.canManageMembers && member.role !== 'owner'); + return ( + <> + {m.isCurrentOwner && member.role !== 'owner' && ( + + )} + {canRemove && ( + + )} + + ); + }; + + return ( + <> + +
+ } + title={t('trip.settings.title')} + sub={tripTitle} + onClose={onClose} + closeLabel={t('common.close')} + /> +
+ +
+
+ {m.canManageMembers && ( +
+ m.setSelectedUserId(String(value))} + placeholder={t('members.selectUser')} + options={[ + { value: '', label: t('members.selectUser') }, + ...m.availableUsers.map((u) => ({ value: String(u.id), label: u.username })), + ]} + searchable + size="sm" + style={{ flex: 1, minWidth: 0 }} + /> + +
+ )} + {m.canManageMembers && m.availableUsers.length === 0 && m.allUsers.length > 0 && ( +

+ {t('members.allHaveAccess')} +

+ )} + {m.loading && m.realMembers.length === 0 ? ( +
+ ) : ( + m.realMembers.map((member) => ( + + {member.id === m.user?.id && ( + {t('members.you')} + )} + {member.role === 'owner' && ( + + + {t('members.owner')} + + )} + + } + > + {memberActions(member)} + + )) + )} +
+ + {(m.isCurrentOwner || m.guests.length > 0) && ( +
0 ? ` ${m.guests.length}` : ''}`} + hint={t('members.guestsHint')} + > + {m.guests.map((guest) => + m.renamingGuestId === guest.id ? ( + m.setRenameValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') void m.commitRename(guest.id); + if (e.key === 'Escape') m.cancelRename(); + }} + onBlur={() => void m.commitRename(guest.id)} + className={FIELD} + /> + ) : ( + + + {t('members.guest')} + + } + > + {m.isCurrentOwner && ( + <> + + + + )} + + ) + )} + {m.isCurrentOwner && ( +
+ m.setNewGuestName(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') void m.addGuest(); + }} + className={FIELD} + /> + +
+ )} +
+ )} + + {m.canManageShare && } + {m.canManageShare && } + +
+
+ + setQuestion(null)} + title={questionAction} + message={questionText} + confirmLabel={questionAction} + cancelLabel={t('common.cancel')} + danger={question?.kind !== 'transfer'} + onConfirm={answer} + /> + + ); +} diff --git a/client/src/mobile/screens/trip/sheets/MTripSheets.tsx b/client/src/mobile/screens/trip/sheets/MTripSheets.tsx index 425b139cc..8cc0c02f3 100644 --- a/client/src/mobile/screens/trip/sheets/MTripSheets.tsx +++ b/client/src/mobile/screens/trip/sheets/MTripSheets.tsx @@ -3,8 +3,6 @@ import { createPortal } from 'react-dom' import TransitJourneyModal from '../../../../components/Planner/TransitJourneyModal' import BookingImportModal from '../../../../components/Planner/BookingImportModal' import AirTrailImportModal from '../../../../components/Planner/AirTrailImportModal' -import TripFormModal from '../../../../components/Trips/TripFormModal' -import TripMembersModal from '../../../../components/Trips/TripMembersModal' import TourDetailDialog from '../../../../components/Tours/TourDetailDialog' import { useBookingExpenseEditor } from '../../../../pages/tripPlanner/useBookingExpenseEditor' import { applyTripCoverUpdate } from '../../../../pages/tripPlanner/tripCover' @@ -26,6 +24,8 @@ import MNoteSheet, { type MNoteSheetPayload } from './MNoteSheet' import MImportSheet from './MImportSheet' import MExportSheet from './MExportSheet' import MMehrSheet from './MMehrSheet' +import MTripSettingsSheet from './MTripSettingsSheet' +import MNewTripSheet from '../../dashboard/MNewTripSheet' import MPlacesFilterSheet from '../places/MPlacesFilterSheet' import MRtStopSheet from '../roadtrip/MRtStopSheet' import MRtStaySheet from '../roadtrip/MRtStaySheet' @@ -97,9 +97,10 @@ export function MSelectedTourDetail({ planner }: Pick planner.setShowAirTrailImport(false)} tripId={tripId} pushUndo={planner.pushUndo} /> {/* Trip edit + share/members, opened from the Mehr sheet. */} - { await tripActions.updateTrip(tripId, data) toast.success(t('trip.toast.tripUpdated')) }} - trip={trip} onCoverUpdate={applyTripCoverUpdate} /> - diff --git a/client/src/pages/journeyDetail/useJourneyDetail.ts b/client/src/pages/journeyDetail/useJourneyDetail.ts index c577a1e8c..47414e6be 100644 --- a/client/src/pages/journeyDetail/useJourneyDetail.ts +++ b/client/src/pages/journeyDetail/useJourneyDetail.ts @@ -511,7 +511,7 @@ export function useJourneyDetail({ journeyId, embedded = false }: JourneyDetailO dayImport, dayImportDates, } = useJourneyDetailLayers({ journey: current, loaded: journeyLoaded, canEditEntries, loadJourney, dawarich, dawarichByDate, dawarichTrail, - tripDates, sidebarMapItems, photoCount: mapPhotos.length, showTripTracks, + tripDates, sidebarMapItems, photoCount: mapPhotos.length, showTripTracks, allLayers: isMobile, }) /** Studio's margin to the window on all four sides — see `.st-root` in studio.css. */ diff --git a/client/src/pages/journeyDetail/useJourneyDetailLayers.ts b/client/src/pages/journeyDetail/useJourneyDetailLayers.ts index 2de527799..a925f7335 100644 --- a/client/src/pages/journeyDetail/useJourneyDetailLayers.ts +++ b/client/src/pages/journeyDetail/useJourneyDetailLayers.ts @@ -30,6 +30,7 @@ export function useJourneyDetailLayers({ sidebarMapItems, photoCount, showTripTracks, + allLayers = false, }: { journey: JourneyDetail | null; /** Whether `journey` is the one the route names yet: the store keeps the last one while the next loads. */ @@ -43,6 +44,8 @@ export function useJourneyDetailLayers({ sidebarMapItems: ReadonlyArray<{ id: string; entry_date: string; dayColor: string }>; photoCount: number; showTripTracks: boolean; + /** The phone: every map layer on, as it offers no switches (see useJourneyMapLayers). */ + allLayers?: boolean; }) { // The editor's own status, the owner's switch, and where each day of the trips stands. const isAddonEnabled = useAddonStore((s) => s.isEnabled); @@ -111,6 +114,7 @@ export function useJourneyDetailLayers({ recordedRead, colorOfDay, dateOfEntry, + allLayers, }); // The drafts a day's take-over wrote reach the timeline, and its stays leave the pending list. diff --git a/client/src/pages/journeyPublic/useJourneyPublic.ts b/client/src/pages/journeyPublic/useJourneyPublic.ts index f889e88bf..6aefb6252 100644 --- a/client/src/pages/journeyPublic/useJourneyPublic.ts +++ b/client/src/pages/journeyPublic/useJourneyPublic.ts @@ -136,6 +136,7 @@ export function useJourneyPublic() { photoCount: mapPhotos.length, colorOfDay, dateOfEntry, + allLayers: isMobile, }) // A journey shared while the trip is still running reads like a blog, so the owner diff --git a/client/tests/unit/mobile/admin/MAdmin.test.tsx b/client/tests/unit/mobile/admin/MAdmin.test.tsx index 6b2a19d78..05596fea4 100644 --- a/client/tests/unit/mobile/admin/MAdmin.test.tsx +++ b/client/tests/unit/mobile/admin/MAdmin.test.tsx @@ -60,7 +60,7 @@ vi.mock('../../../../src/mobile/screens/admin/MAdminStoragePanel', () => ({ })); // The addon manager is reduced to the two controls MAdmin wires itself. -vi.mock('../../../../src/components/Admin/paddle/PaddleRegionsPanel', () => ({ +vi.mock('../../../../src/mobile/screens/admin/MAdminPaddleRegionsPanel', () => ({ default: () =>
, })); vi.mock('../../../../src/mobile/screens/admin/MAdminAddonManager', () => ({ diff --git a/client/tests/unit/mobile/admin/MAdminAddonManager.test.tsx b/client/tests/unit/mobile/admin/MAdminAddonManager.test.tsx index cab6a32df..43340656f 100644 --- a/client/tests/unit/mobile/admin/MAdminAddonManager.test.tsx +++ b/client/tests/unit/mobile/admin/MAdminAddonManager.test.tsx @@ -118,9 +118,10 @@ describe('MAdminAddonManager', () => { render(); await screen.findByText('Lists'); - expect(screen.getByText('Trip — Available as a tab within each trip')).toBeInTheDocument(); + // The band names the type and says what it means on a line of its own, no dash between them. + expect(screen.getByText('Available as a tab within each trip')).toBeInTheDocument(); expect(screen.getByText('Packing lists and to-do tasks for your trips')).toBeInTheDocument(); - expect(screen.getByText('Trip')).toBeInTheDocument(); + expect(screen.getAllByText('Trip')).toHaveLength(2); expect(screen.getByRole('switch', { name: 'Lists' })).toHaveAttribute('aria-checked', 'true'); }); @@ -275,7 +276,7 @@ describe('MAdminAddonManager', () => { render(); await screen.findByText('Immich'); - expect(screen.getByText('Global — Available as a standalone section in the main navigation')).toBeInTheDocument(); + expect(screen.getByText('Available as a standalone section in the main navigation')).toBeInTheDocument(); expect(screen.getByText('Synology Photos')).toBeInTheDocument(); expect(screen.getByText('Unsplash')).toBeInTheDocument(); expect(screen.getByRole('switch', { name: 'Immich' })).toHaveAttribute('aria-checked', 'true'); @@ -499,7 +500,7 @@ describe('MAdminAddonManager', () => { render(); await screen.findByText('AI Parsing'); - expect(screen.getByText('Integration — Backend services and API integrations with no dedicated page')).toBeInTheDocument(); + expect(screen.getByText('Backend services and API integrations with no dedicated page')).toBeInTheDocument(); expect(screen.queryByText('Connection')).not.toBeInTheDocument(); }); diff --git a/client/tests/unit/mobile/admin/MAdminPaddleRegionsPanel.test.tsx b/client/tests/unit/mobile/admin/MAdminPaddleRegionsPanel.test.tsx new file mode 100644 index 000000000..2186635ac --- /dev/null +++ b/client/tests/unit/mobile/admin/MAdminPaddleRegionsPanel.test.tsx @@ -0,0 +1,167 @@ +// FE-MOB-APAD-001 to FE-MOB-APAD-010: the paddle regions card of the phone admin, drawn from a +// controlled usePaddleRegionsAdmin (the hook has its own test). +import type { PaddleRegion, PaddleRegionPending } from '@trek/shared'; +import { describe, expect, it, vi } from 'vitest'; +import type { usePaddleRegionsAdmin } from '../../../../src/components/Admin/paddle/usePaddleRegionsAdmin'; +import MAdminPaddleRegionsPanel from '../../../../src/mobile/screens/admin/MAdminPaddleRegionsPanel'; +import { fireEvent, render, screen, within } from '../../../helpers/render'; + +type Admin = ReturnType; + +const hook = vi.hoisted(() => ({ usePaddleRegionsAdmin: vi.fn<() => Admin>() })); +vi.mock('../../../../src/components/Admin/paddle/usePaddleRegionsAdmin', () => ({ + usePaddleRegionsAdmin: hook.usePaddleRegionsAdmin, +})); + +function region(overrides: Partial = {}): PaddleRegion { + return { + id: 'oberpfalz', + name: 'Oberpfalz', + status: 'ready', + origin: 'admin', + source_timestamp: '2026-10-03T12:00:00Z', + imported_at: '2026-10-04T08:00:00Z', + feature_count: 1234, + error: null, + progress: null, + network: null, + ...overrides, + }; +} + +const REQUEST: PaddleRegionPending = { + id: 'germany/bayern', + name: 'Bayern', + reason: 'too_large', + bytes: 700 * 1024 * 1024, + requested_by: 'alice', + requested_at: '2026-10-05T12:00:00Z', + declined: false, +} as PaddleRegionPending; + +function show(overrides: Partial = {}, search: Partial = {}) { + const state: Admin = { + toursOn: true, + offline: false, + regions: [], + requests: [], + autoLoad: { settings: null, saving: false, save: vi.fn(async () => {}) }, + loading: false, + error: null, + retry: vi.fn(), + added: new Set(), + busyId: null, + add: vi.fn(async () => {}), + refresh: vi.fn(async () => {}), + approve: vi.fn(async () => {}), + decline: vi.fn(async () => {}), + removing: null, + askRemove: vi.fn(), + cancelRemove: vi.fn(), + confirmRemove: vi.fn(), + ...overrides, + search: { query: '', setQuery: vi.fn(), hits: null, searching: false, error: null, ...search }, + }; + hook.usePaddleRegionsAdmin.mockReturnValue(state); + render(); + return state; +} + +describe('MAdminPaddleRegionsPanel', () => { + it('FE-MOB-APAD-001: shows nothing while the Tours addon is off', () => { + show({ toursOn: false }); + expect(screen.queryByText('Paddle water data')).not.toBeInTheDocument(); + }); + + it('FE-MOB-APAD-002: a region shows its status, its objects and its actions', () => { + const state = show({ regions: [region({ network: { water_km: 812 } as PaddleRegion['network'] })] }); + const row = screen.getByTestId('paddle-region-row'); + expect(within(row).getByText('Oberpfalz')).toBeInTheDocument(); + expect(within(row).getByText('Ready')).toBeInTheDocument(); + expect(within(row).getByText('1234 objects')).toBeInTheDocument(); + expect(within(row).getByText('812 km of water')).toBeInTheDocument(); + + fireEvent.click(within(row).getByRole('button', { name: 'Read again Oberpfalz' })); + expect(state.refresh).toHaveBeenCalledWith('oberpfalz'); + fireEvent.click(within(row).getByRole('button', { name: 'Remove region Oberpfalz' })); + expect(state.askRemove).toHaveBeenCalledWith(expect.objectContaining({ id: 'oberpfalz' })); + }); + + it('FE-MOB-APAD-003: a failed read says why and that the old data stays', () => { + show({ regions: [region({ status: 'failed', error: 'Download broke off.' })] }); + expect(screen.getByText('Failed')).toBeInTheDocument(); + expect(screen.getByText(/Download broke off\. The data read before stays in use\./)).toBeInTheDocument(); + }); + + it('FE-MOB-APAD-004: the mode and the size are switched in place', () => { + const save = vi.fn(async () => {}); + show({ autoLoad: { settings: { mode: 'auto', max_mb: 500 }, saving: false, save } as Admin['autoLoad'] }); + const modes = screen.getByRole('radiogroup', { name: 'Load regions automatically' }); + expect(within(modes).getByRole('radio', { name: /Automatic/ })).toHaveAttribute('aria-checked', 'true'); + fireEvent.click(within(modes).getByRole('radio', { name: /On request/ })); + expect(save).toHaveBeenCalledWith({ mode: 'ask' }); + + const sizes = screen.getByRole('group', { name: 'Largest region loaded automatically' }); + fireEvent.click(within(sizes).getByRole('button', { name: /1 GB/ })); + expect(save).toHaveBeenCalledWith({ max_mb: 1000 }); + }); + + it('FE-MOB-APAD-005: no size choice outside the automatic mode', () => { + show({ + autoLoad: { + settings: { mode: 'off', max_mb: 500 }, + saving: false, + save: vi.fn(), + } as unknown as Admin['autoLoad'], + }); + expect(screen.queryByRole('group', { name: 'Largest region loaded automatically' })).not.toBeInTheDocument(); + }); + + it('FE-MOB-APAD-006: a requested region is loaded or declined', () => { + const state = show({ requests: [REQUEST] }); + const row = screen.getByTestId('paddle-region-request'); + expect(within(row).getByText('Bayern')).toBeInTheDocument(); + fireEvent.click(within(row).getByRole('button', { name: /Load/ })); + expect(state.approve).toHaveBeenCalledWith('germany/bayern'); + fireEvent.click(within(row).getByRole('button', { name: 'Decline' })); + expect(state.decline).toHaveBeenCalledWith('germany/bayern'); + }); + + it('FE-MOB-APAD-007: a search hit is added, an added one says so', () => { + const state = show( + { added: new Set(['netherlands/limburg']) }, + { + query: 'Limburg', + hits: [ + { id: 'belgium/limburg', name: 'Limburg', path: ['Europe', 'Belgium'] }, + { id: 'netherlands/limburg', name: 'Limburg NL', path: ['Europe', 'Netherlands'] }, + ], + } + ); + const hits = screen.getAllByTestId('paddle-region-hit'); + expect(within(hits[0]).getByText('Belgium')).toBeInTheDocument(); + fireEvent.click(within(hits[0]).getByRole('button', { name: /Limburg/ })); + expect(state.add).toHaveBeenCalledWith('belgium/limburg'); + expect(within(hits[1]).getByText('Added')).toBeInTheDocument(); + }); + + it('FE-MOB-APAD-008: typing searches, and a search without hits says so', () => { + const state = show({}, { hits: [] }); + fireEvent.change(screen.getByRole('searchbox', { name: 'Add a region' }), { target: { value: 'Atlantis' } }); + expect(state.search.setQuery).toHaveBeenCalledWith('Atlantis'); + expect(screen.getByText('No region by that name')).toBeInTheDocument(); + }); + + it('FE-MOB-APAD-009: a failed read of the list says so and can be retried', () => { + const state = show({ error: 'Server down' }); + fireEvent.click(screen.getByRole('button', { name: 'Try again' })); + expect(state.retry).toHaveBeenCalled(); + }); + + it('FE-MOB-APAD-010: removing asks in a phone sheet first', () => { + const state = show({ regions: [region()], removing: region() }); + const question = screen.getByRole('dialog', { name: 'Remove region' }); + fireEvent.click(within(question).getByRole('button', { name: 'Remove' })); + expect(state.confirmRemove).toHaveBeenCalled(); + }); +}); diff --git a/client/tests/unit/mobile/admin/MAdminSchoolHolidayCatalog.test.tsx b/client/tests/unit/mobile/admin/MAdminSchoolHolidayCatalog.test.tsx new file mode 100644 index 000000000..78d767389 --- /dev/null +++ b/client/tests/unit/mobile/admin/MAdminSchoolHolidayCatalog.test.tsx @@ -0,0 +1,124 @@ +// FE-MOB-ASCH-001 to FE-MOB-ASCH-008: the school holiday catalog of the phone admin, drawn from a +// controlled useSchoolHolidayCatalog (the hook has its own test). +import type { SchoolHolidayRegionDetail } from '@trek/shared'; +import { describe, expect, it, vi } from 'vitest'; +import type { useSchoolHolidayCatalog } from '../../../../src/components/Admin/useSchoolHolidayCatalog'; +import MAdminSchoolHolidayCatalog from '../../../../src/mobile/screens/admin/MAdminSchoolHolidayCatalog'; +import { fireEvent, render, screen, waitFor, within } from '../../../helpers/render'; + +type Catalog = ReturnType; + +const hook = vi.hoisted(() => ({ useSchoolHolidayCatalog: vi.fn<() => Catalog>() })); +vi.mock('../../../../src/components/Admin/useSchoolHolidayCatalog', () => ({ + useSchoolHolidayCatalog: hook.useSchoolHolidayCatalog, +})); + +const REGION: SchoolHolidayRegionDetail = { + id: 5, + country: 'DE', + name: 'Bayern', + code: 'BY', + revision: 2, + holidays: [{ name: 'Herbstferien', startDate: '2026-11-02', endDate: '2026-11-06' }], +}; + +function show(overrides: Partial = {}) { + const state = { + catalog: { + countries: [{ code: 'DE', name: 'Deutschland' }], + regions: [{ id: 5, country: 'DE', name: 'Bayern', code: 'BY', revision: 2 }], + }, + loading: false, + busy: false, + error: '', + offline: false, + editor: null, + setEditor: vi.fn(), + refresh: vi.fn(async () => true), + createCountry: vi.fn(async () => true), + deleteCountry: vi.fn(async () => true), + openRegion: vi.fn(async () => true), + saveRegion: vi.fn(async () => true), + deleteRegion: vi.fn(async () => true), + ...overrides, + } as unknown as Catalog; + hook.useSchoolHolidayCatalog.mockReturnValue(state); + render(); + return state; +} + +describe('MAdminSchoolHolidayCatalog', () => { + it('FE-MOB-ASCH-001: lists the regions of the chosen country and opens one', () => { + const state = show(); + expect(screen.getByText('School holidays')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Bayern' })); + expect(state.openRegion).toHaveBeenCalledWith(5); + }); + + it('FE-MOB-ASCH-002: a country with regions cannot be deleted, and says why', () => { + show(); + expect(screen.getByRole('button', { name: 'Delete country' })).toBeDisabled(); + expect(screen.getByText('Remove regions first. Regions used by calendars cannot be deleted.')).toBeInTheDocument(); + }); + + it('FE-MOB-ASCH-003: a region is deleted after the phone question', () => { + const state = show(); + fireEvent.click(screen.getByRole('button', { name: 'Delete Bayern' })); + const question = screen.getByRole('dialog', { name: 'Delete' }); + fireEvent.click(within(question).getByRole('button', { name: 'Delete' })); + expect(state.deleteRegion).toHaveBeenCalledWith(5, 2); + }); + + it('FE-MOB-ASCH-004: an empty country says so and offers a new region', () => { + const state = show({ + catalog: { countries: [{ code: 'AT', name: 'Österreich' }], regions: [] } as Catalog['catalog'], + }); + expect(screen.getByText('No regions yet.')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Add region' })); + expect(state.setEditor).toHaveBeenCalledWith(expect.objectContaining({ id: 0, country: 'AT', holidays: [] })); + }); + + it('FE-MOB-ASCH-005: a country is added in its own sheet, with a two-letter code', async () => { + const state = show({ catalog: { countries: [], regions: [] } as Catalog['catalog'] }); + fireEvent.click(screen.getByRole('button', { name: 'Add country' })); + const sheet = screen.getByRole('dialog', { name: 'Add country' }); + const save = within(sheet).getByRole('button', { name: 'Save' }); + expect(save).toBeDisabled(); + const [name, code] = within(sheet).getAllByRole('textbox'); + fireEvent.change(name, { target: { value: 'France' } }); + fireEvent.change(code, { target: { value: 'fr' } }); + expect(code).toHaveValue('FR'); + fireEvent.click(save); + await waitFor(() => expect(state.createCountry).toHaveBeenCalledWith({ code: 'FR', name: 'France' })); + }); + + it('FE-MOB-ASCH-006: the region sheet saves its periods as the request the server takes', () => { + const state = show({ editor: REGION }); + const sheet = screen.getByRole('dialog', { name: 'Bayern' }); + fireEvent.click(within(sheet).getByRole('button', { name: 'Add holiday period' })); + expect(within(sheet).getAllByPlaceholderText('Holiday name')).toHaveLength(2); + fireEvent.click(within(sheet).getByRole('button', { name: 'Delete 2' })); + fireEvent.click(within(sheet).getByRole('button', { name: 'Save' })); + expect(state.saveRegion).toHaveBeenCalledWith('DE', 5, { + name: 'Bayern', + revision: 2, + holidays: [{ name: 'Herbstferien', startDate: '2026-11-02', endDate: '2026-11-06' }], + }); + }); + + it('FE-MOB-ASCH-007: an incomplete period is refused before it is sent', () => { + const state = show({ editor: REGION }); + const sheet = screen.getByRole('dialog', { name: 'Bayern' }); + fireEvent.click(within(sheet).getByRole('button', { name: 'Add holiday period' })); + fireEvent.click(within(sheet).getByRole('button', { name: 'Save' })); + expect(within(sheet).getByRole('alert')).toHaveTextContent('Check the names and dates.'); + expect(state.saveRegion).not.toHaveBeenCalled(); + }); + + it('FE-MOB-ASCH-008: a failed read says so and can be tried again', () => { + const state = show({ error: 'Server down' }); + expect(screen.getByRole('alert')).toHaveTextContent('Server down'); + fireEvent.click(screen.getByRole('button', { name: 'Retry' })); + expect(state.refresh).toHaveBeenCalled(); + }); +}); diff --git a/client/tests/unit/mobile/dashboard/MNewTripSheet.test.tsx b/client/tests/unit/mobile/dashboard/MNewTripSheet.test.tsx index d4846cab4..71d6750bc 100644 --- a/client/tests/unit/mobile/dashboard/MNewTripSheet.test.tsx +++ b/client/tests/unit/mobile/dashboard/MNewTripSheet.test.tsx @@ -178,6 +178,7 @@ describe('MNewTripSheet', () => { start_date: null, end_date: null, currency: 'USD', + reminder_days: 0, day_count: 7, })); expect(onClose).toHaveBeenCalled(); @@ -664,3 +665,66 @@ describe('MNewTripSheet', () => { spy.mockRestore(); }); }); + +describe('MNewTripSheet day count and reminder', () => { + it('FE-MOB-NTSH-090: a dateless trip sends a changed day count, an unchanged one not', async () => { + const onSave = vi.fn(async (_data: TripCreateRequest) => undefined); + const trip = buildDashTrip({ start_date: null, end_date: null, day_count: 5 } as Partial); + const { unmount } = render(); + + fireEvent.click(screen.getByRole('button', { name: 'Update' })); + await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1)); + expect(onSave.mock.calls[0][0]).not.toHaveProperty('day_count'); + unmount(); + + render(); + fireEvent.change(screen.getByDisplayValue('5'), { target: { value: '9' } }); + fireEvent.click(screen.getByRole('button', { name: 'Update' })); + await waitFor(() => expect(onSave).toHaveBeenCalledTimes(2)); + expect(onSave.mock.calls[1][0]).toMatchObject({ day_count: 9 }); + }); + + it('FE-MOB-NTSH-091: an empty day count is refused', async () => { + const onSave = vi.fn(async (_data: TripCreateRequest) => undefined); + render()} onClose={vi.fn()} onSave={onSave} />); + + fireEvent.change(screen.getByDisplayValue('5'), { target: { value: '' } }); + fireEvent.click(screen.getByRole('button', { name: 'Update' })); + expect(await screen.findByText('Number of days is required')).toBeInTheDocument(); + expect(onSave).not.toHaveBeenCalled(); + }); + + it('FE-MOB-NTSH-092: the owner picks a reminder, a preset or a number of their own', async () => { + useAuthStore.setState({ tripRemindersEnabled: true }); + const onSave = vi.fn(async (_data: TripCreateRequest) => undefined); + render()} onClose={vi.fn()} onSave={onSave} />); + + expect(screen.getByRole('button', { name: '3 days' })).toHaveAttribute('aria-pressed', 'true'); + fireEvent.click(screen.getByRole('button', { name: 'Custom' })); + const days = screen.getByRole('spinbutton', { name: 'days before departure' }); + expect(days).toHaveValue(7); + fireEvent.change(days, { target: { value: '12' } }); + fireEvent.click(screen.getByRole('button', { name: 'Update' })); + await waitFor(() => expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ reminder_days: 12 }))); + + fireEvent.click(screen.getByRole('button', { name: 'None' })); + expect(screen.queryByRole('spinbutton', { name: 'days before departure' })).not.toBeInTheDocument(); + }); + + it('FE-MOB-NTSH-093: with reminders off the owner is told where to switch them on', () => { + useAuthStore.setState({ tripRemindersEnabled: false }); + render(); + expect(screen.getByText(/Trip reminders are disabled/)).toBeInTheDocument(); + }); + + it('FE-MOB-NTSH-094: someone else\'s trip offers no reminder and sends none', async () => { + useAuthStore.setState({ tripRemindersEnabled: true, user: buildUser({ id: 9, role: 'user' }) }); + const onSave = vi.fn(async (_data: TripCreateRequest) => undefined); + render(); + + expect(screen.queryByRole('button', { name: 'Custom' })).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Update' })); + await waitFor(() => expect(onSave).toHaveBeenCalled()); + expect(onSave.mock.calls[0][0]).not.toHaveProperty('reminder_days'); + }); +}); diff --git a/client/tests/unit/mobile/trip/MRoadtripTab.test.tsx b/client/tests/unit/mobile/trip/MRoadtripTab.test.tsx index f623c887d..e410a77bc 100644 --- a/client/tests/unit/mobile/trip/MRoadtripTab.test.tsx +++ b/client/tests/unit/mobile/trip/MRoadtripTab.test.tsx @@ -272,7 +272,7 @@ describe('MRoadtripTab', () => { renderTab(planner({ roadtripRoutes: routes({ days: [] }) })) expect(screen.getByText('roadtrip.empty.title')).toBeInTheDocument() - expect(screen.getByText('mobileTrip.rtPlanOnDesktop')).toBeInTheDocument() + expect(screen.getByText('mobileTrip.rtDriveOnTwoPlaces')).toBeInTheDocument() expect(screen.queryByText('mobileTrip.rtStart')).toBeNull() expect(screen.queryByText(/roadtrip\.day\.stopCount/)).toBeNull() }) @@ -282,7 +282,7 @@ describe('MRoadtripTab', () => { expect(screen.getByText('roadtrip.summary.partial')).toBeInTheDocument() expect(screen.queryByText('roadtrip.empty.title')).toBeNull() - expect(screen.queryByText('mobileTrip.rtPlanOnDesktop')).toBeNull() + expect(screen.queryByText('mobileTrip.rtDriveOnTwoPlaces')).toBeNull() }) it('FE-MOB-RTTAB-053: with the drive routed and no day picked it asks for a day, not for a route', () => { @@ -294,7 +294,7 @@ describe('MRoadtripTab', () => { expect(screen.getByText('mobileTrip.rtNoDay')).toBeInTheDocument() expect(screen.getByText('mobileTrip.rtNoDayHint')).toBeInTheDocument() expect(screen.queryByText('roadtrip.empty.title')).toBeNull() - expect(screen.queryByText('mobileTrip.rtPlanOnDesktop')).toBeNull() + expect(screen.queryByText('mobileTrip.rtDriveOnTwoPlaces')).toBeNull() expect(screen.queryByText('mobileTrip.rtStart')).toBeNull() // A picked day the routing has nothing for is still the empty stage: that one is diff --git a/client/tests/unit/mobile/trip/MTripSettingsSheet.test.tsx b/client/tests/unit/mobile/trip/MTripSettingsSheet.test.tsx new file mode 100644 index 000000000..930faba83 --- /dev/null +++ b/client/tests/unit/mobile/trip/MTripSettingsSheet.test.tsx @@ -0,0 +1,228 @@ +import userEvent from '@testing-library/user-event'; +import { http, HttpResponse } from 'msw'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import MTripSettingsSheet from '../../../../src/mobile/screens/trip/sheets/MTripSettingsSheet'; +import { useAuthStore } from '../../../../src/store/authStore'; +import { usePermissionsStore } from '../../../../src/store/permissionsStore'; +import { useTripStore } from '../../../../src/store/tripStore'; +import { buildTrip, buildUser } from '../../../helpers/factories'; +import { server } from '../../../helpers/msw/server'; +import { render, screen, waitFor, within } from '../../../helpers/render'; +import { resetAllStores, seedStore } from '../../../helpers/store'; + +// FE-MOB-TSET-001 to FE-MOB-TSET-008: the phone's trip settings, on useTripShare. + +const owner = buildUser({ id: 1, username: 'owner' }); +const alice = buildUser({ id: 2, username: 'alice' }); + +interface Row { + id: number; + username: string; + avatar_url?: string | null; + is_guest?: boolean; +} + +function roster(members: Row[]) { + server.use( + http.get('/api/trips/1/members', () => + HttpResponse.json({ + owner: { id: owner.id, username: owner.username, avatar_url: null }, + members, + current_user_id: owner.id, + }) + ) + ); +} + +function renderSheet(props: Partial[0]> = {}) { + const onClose = vi.fn(); + const onMembersChanged = vi.fn(); + render( + + ); + return { onClose, onMembersChanged }; +} + +beforeEach(() => { + resetAllStores(); + seedStore(useAuthStore, { user: owner, isAuthenticated: true }); + seedStore(useTripStore, { trip: buildTrip({ id: 1, title: 'Japan 2026', user_id: owner.id }) }); + roster([]); + server.use( + http.get('/api/trips/1/share-link', () => HttpResponse.json({ token: null })), + http.get('/api/trips/1/invite-link', () => HttpResponse.json({ token: null })), + http.get('/api/auth/users', () => HttpResponse.json({ users: [alice] })) + ); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('MTripSettingsSheet', () => { + it('FE-MOB-TSET-001: names the trip in its header and the owner with their badges', async () => { + renderSheet(); + const sheet = screen.getByRole('dialog', { name: 'Trip settings' }); + expect(within(sheet).getByText('Japan 2026')).toBeInTheDocument(); + expect(await within(sheet).findByText('owner')).toBeInTheDocument(); + expect(within(sheet).getByText('you')).toBeInTheDocument(); + expect(within(sheet).getByText('Owner')).toBeInTheDocument(); + }); + + it('FE-MOB-TSET-002: asks in a sheet of its own before a member goes, and only then removes', async () => { + const user = userEvent.setup(); + roster([{ id: alice.id, username: 'alice', avatar_url: null }]); + let deletes = 0; + server.use( + http.delete('/api/trips/1/members/:userId', () => { + deletes++; + return HttpResponse.json({ success: true }); + }) + ); + const { onMembersChanged } = renderSheet(); + + await user.click(await screen.findByRole('button', { name: 'Remove access' })); + const question = await screen.findByRole('dialog', { name: 'Remove access' }); + expect(within(question).getByText('Remove access for this user?')).toBeInTheDocument(); + expect(deletes).toBe(0); + + await user.click(within(question).getByRole('button', { name: 'Remove access' })); + await waitFor(() => expect(deletes).toBe(1)); + await waitFor(() => expect(onMembersChanged).toHaveBeenCalled()); + }); + + it('FE-MOB-TSET-003: a question that is cancelled changes nothing', async () => { + const user = userEvent.setup(); + roster([{ id: alice.id, username: 'alice', avatar_url: null }]); + let transfers = 0; + server.use( + http.post('/api/trips/1/transfer', () => { + transfers++; + return HttpResponse.json({ success: true }); + }) + ); + renderSheet(); + + await user.click(await screen.findByRole('button', { name: 'Make owner' })); + const question = await screen.findByRole('dialog', { name: 'Make owner' }); + await user.click(within(question).getByRole('button', { name: 'Cancel' })); + expect(transfers).toBe(0); + }); + + it('FE-MOB-TSET-004: a guest is added, renamed and removed', async () => { + const user = userEvent.setup(); + roster([{ id: 3, username: 'Grandma', avatar_url: null, is_guest: true }]); + let created: string | null = null; + let renamed: string | null = null; + let deleted = false; + server.use( + http.post('/api/trips/1/guests', async ({ request }) => { + created = ((await request.json()) as { name: string }).name; + return HttpResponse.json({ success: true }); + }), + http.put('/api/trips/1/guests/:userId', async ({ request }) => { + renamed = ((await request.json()) as { name: string }).name; + return HttpResponse.json({ success: true }); + }), + http.delete('/api/trips/1/guests/:userId', () => { + deleted = true; + return HttpResponse.json({ success: true }); + }) + ); + renderSheet(); + + expect(await screen.findByText('Grandma')).toBeInTheDocument(); + await user.type(screen.getByPlaceholderText('Guest name'), 'Grandpa'); + await user.click(screen.getByRole('button', { name: 'Add guest' })); + await waitFor(() => expect(created).toBe('Grandpa')); + + await user.click(screen.getByRole('button', { name: 'Rename' })); + const input = screen.getByDisplayValue('Grandma'); + await user.clear(input); + await user.type(input, 'Granny{Enter}'); + await waitFor(() => expect(renamed).toBe('Granny')); + + const removeGuest = screen.getAllByRole('button', { name: 'Remove access' }); + await user.click(removeGuest[removeGuest.length - 1]); + await user.click( + within(await screen.findByRole('dialog', { name: 'Remove access' })).getByRole('button', { + name: 'Remove access', + }) + ); + await waitFor(() => expect(deleted).toBe(true)); + }); + + it('FE-MOB-TSET-005: the public link toggles say whether they are on (#2604)', async () => { + seedStore(usePermissionsStore, { permissions: { share_manage: 'trip_owner' } }); + server.use( + http.get('/api/trips/1/share-link', () => + HttpResponse.json({ + token: 'abc123', + share_map: true, + share_bookings: true, + share_packing: false, + share_budget: false, + share_collab: false, + }) + ) + ); + renderSheet(); + + expect(await screen.findByRole('button', { name: 'Bookings' })).toHaveAttribute('aria-pressed', 'true'); + expect(screen.getByRole('button', { name: 'Costs' })).toHaveAttribute('aria-pressed', 'false'); + expect(screen.getByDisplayValue(/\/shared\/abc123$/)).toBeInTheDocument(); + }); + + it('FE-MOB-TSET-006: a public link is created with what it should show', async () => { + const user = userEvent.setup(); + seedStore(usePermissionsStore, { permissions: { share_manage: 'trip_owner' } }); + let body: Record | null = null; + server.use( + http.post('/api/trips/1/share-link', async ({ request }) => { + body = (await request.json()) as Record; + return HttpResponse.json({ token: 'fresh' }); + }) + ); + renderSheet(); + + await user.click(await screen.findByRole('button', { name: 'Packing' })); + await user.click(screen.getByRole('button', { name: 'Create link' })); + await waitFor(() => expect(body).toMatchObject({ share_map: true, share_packing: true })); + expect(await screen.findByDisplayValue(/\/shared\/fresh$/)).toBeInTheDocument(); + }); + + it('FE-MOB-TSET-007: the invite link is created and switched off again', async () => { + const user = userEvent.setup(); + seedStore(usePermissionsStore, { permissions: { share_manage: 'trip_owner' } }); + let removed = false; + server.use( + http.post('/api/trips/1/invite-link', () => HttpResponse.json({ token: 'join1' })), + http.delete('/api/trips/1/invite-link', () => { + removed = true; + return HttpResponse.json({ success: true }); + }) + ); + renderSheet(); + + await user.click(await screen.findByRole('button', { name: 'Create invite link' })); + expect(await screen.findByDisplayValue(/\/join\/join1$/)).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'Disable' })); + await waitFor(() => expect(removed).toBe(true)); + }); + + it('FE-MOB-TSET-008: closes through its close button', async () => { + const user = userEvent.setup(); + const { onClose } = renderSheet(); + await user.click( + within(screen.getByRole('dialog', { name: 'Trip settings' })).getByRole('button', { name: 'Close' }) + ); + expect(onClose).toHaveBeenCalled(); + }); +}); diff --git a/client/tests/unit/mobile/trip/MTripSheets.members.test.tsx b/client/tests/unit/mobile/trip/MTripSheets.members.test.tsx index dcb605665..28a8a46b5 100644 --- a/client/tests/unit/mobile/trip/MTripSheets.members.test.tsx +++ b/client/tests/unit/mobile/trip/MTripSheets.members.test.tsx @@ -11,9 +11,8 @@ import { render, screen, waitFor } from '../../../helpers/render' // FE-MOB-SHOST-030 to FE-MOB-SHOST-031 (001 to 029 and 032 live in MTripSheets.test.tsx) // -// The phone has no share sheet of its own: the Mehr sheet opens the desktop -// TripMembersModal through this host. Every other sheet is stubbed away so the real -// modal is what the phone user gets (#2478). +// The Mehr sheet opens the phone's own MTripSettingsSheet through this host. Every +// other sheet is stubbed away so the real sheet is what the phone user gets (#2478). vi.mock('../../../../src/mobile/screens/trip/sheets/MPlaceSheet', () => ({ default: () => null })) vi.mock('../../../../src/mobile/screens/trip/sheets/MDaySheet', () => ({ default: () => null })) @@ -39,7 +38,7 @@ vi.mock('../../../../src/mobile/screens/settings/MConfirmSheet', () => ({ defaul vi.mock('../../../../src/components/Planner/BookingImportModal', () => ({ default: () => null })) vi.mock('../../../../src/components/Planner/AirTrailImportModal', () => ({ default: () => null })) vi.mock('../../../../src/components/Planner/TransitJourneyModal', () => ({ default: () => null })) -vi.mock('../../../../src/components/Trips/TripFormModal', () => ({ default: () => null })) +vi.mock('../../../../src/mobile/screens/dashboard/MNewTripSheet', () => ({ default: () => null })) import MTripSheets from '../../../../src/mobile/screens/trip/sheets/MTripSheets' diff --git a/client/tests/unit/mobile/trip/MTripSheets.test.tsx b/client/tests/unit/mobile/trip/MTripSheets.test.tsx index 6bb779f50..2c939d709 100644 --- a/client/tests/unit/mobile/trip/MTripSheets.test.tsx +++ b/client/tests/unit/mobile/trip/MTripSheets.test.tsx @@ -133,9 +133,9 @@ vi.mock('../../../../src/components/Planner/AirTrailImportModal', () => ({ ), })) -vi.mock('../../../../src/components/Trips/TripFormModal', () => ({ - default: ({ isOpen, onClose, onSave, trip, onCoverUpdate }: { - isOpen: boolean; onClose: () => void +vi.mock('../../../../src/mobile/screens/dashboard/MNewTripSheet', () => ({ + default: ({ open: isOpen, onClose, onSave, trip, onCoverUpdate }: { + open: boolean; onClose: () => void onSave: (data: Record) => Promise trip?: Trip | null onCoverUpdate: (id: number, coverUrl: string) => void @@ -148,9 +148,9 @@ vi.mock('../../../../src/components/Trips/TripFormModal', () => ({ ), })) -vi.mock('../../../../src/components/Trips/TripMembersModal', () => ({ - default: ({ isOpen, onClose, tripTitle, onMembersChanged }: { - isOpen: boolean; onClose: () => void; tripTitle?: string; onMembersChanged: () => void +vi.mock('../../../../src/mobile/screens/trip/sheets/MTripSettingsSheet', () => ({ + default: ({ open: isOpen, onClose, tripTitle, onMembersChanged }: { + open: boolean; onClose: () => void; tripTitle?: string; onMembersChanged: () => void }) => (
diff --git a/shared/src/i18n/ar/admin.ts b/shared/src/i18n/ar/admin.ts index a5f7c2b18..f94c80501 100644 --- a/shared/src/i18n/ar/admin.ts +++ b/shared/src/i18n/ar/admin.ts @@ -672,6 +672,8 @@ const admin: TranslationStrings = { 'admin.addons.catalog.roadtrip.name': 'رحلة برية', 'admin.addons.catalog.roadtrip.description': 'خطّط رحلات القيادة مع محطات توقف، وتُحدَّث أوقات القيادة والوصول تلقائيًا', + 'admin.addons.catalog.tours.name': 'الجولات', + 'admin.addons.catalog.tours.description': 'مشي وركوب دراجات وجولات أخرى تتبع مسارًا', 'admin.addons.catalog.memories.name': 'صور (Immich)', 'admin.addons.catalog.memories.description': 'شارك صور رحلتك عبر Immich', 'admin.addons.catalog.mcp.description': 'بروتوكول سياق النموذج لتكامل مساعد الذكاء الاصطناعي', diff --git a/shared/src/i18n/ar/mobileTrip.ts b/shared/src/i18n/ar/mobileTrip.ts index 3ce667d0e..2597340d6 100644 --- a/shared/src/i18n/ar/mobileTrip.ts +++ b/shared/src/i18n/ar/mobileTrip.ts @@ -68,7 +68,15 @@ const mobileTrip: TranslationStrings = { 'mobileTrip.rtInfoTitle': 'أرقام القيادة', 'mobileTrip.rtDesktopNote': 'تُضبط هذه الأرقام على سطح المكتب، حيث يمكنك أيضًا مقارنة المسارات البديلة، وجعل يوم يتبع مسارًا مستوردًا.', - 'mobileTrip.rtPlanOnDesktop': 'يتم التخطيط على سطح المكتب. يحسب TREK القيادة بمجرد أن يضم اليوم مكانين.', + 'mobileTrip.planOnDesktop': 'التخطيط على الحاسوب', + 'mobileTrip.rtDriveOnTwoPlaces': 'يحسب TREK القيادة بمجرد أن يضم اليوم مكانين.', + 'mobileTrip.toursEmptyBody': 'تظهر الجولات المحفوظة هنا ويمكن إضافتها إلى يوم.', + 'mobileTrip.toursCount': '{count} جولة', + 'mobileTrip.toursCount.zero': 'لا توجد جولات', + 'mobileTrip.toursCount.one': 'جولة واحدة', + 'mobileTrip.toursCount.two': 'جولتان', + 'mobileTrip.toursCount.few': '{count} جولات', + 'mobileTrip.toursCount.many': '{count} جولة', 'mobileTrip.rtSearchOffline': 'يلزم اتصال بالإنترنت: يقرأ البحث المسار الذي أمامك.', 'mobileTrip.rtBehind': '{time} تأخرًا عن الخطة', 'mobileTrip.rtStart': 'البداية', diff --git a/shared/src/i18n/ar/nav.ts b/shared/src/i18n/ar/nav.ts index 9cb899219..cfc71af96 100644 --- a/shared/src/i18n/ar/nav.ts +++ b/shared/src/i18n/ar/nav.ts @@ -1,7 +1,6 @@ import type { TranslationStrings } from '../types'; const nav: TranslationStrings = { - 'nav.trip': 'الرحلة', 'nav.settings': 'الإعدادات', 'nav.admin': 'الإدارة', 'nav.logout': 'تسجيل الخروج', diff --git a/shared/src/i18n/ar/tours.ts b/shared/src/i18n/ar/tours.ts index 2640685f0..c19a454ce 100644 --- a/shared/src/i18n/ar/tours.ts +++ b/shared/src/i18n/ar/tours.ts @@ -211,8 +211,6 @@ const tours: TranslationStrings = { 'tours.planner.inspector.profileFocus': 'عند {distance}: {elevation}', 'tours.planner.inspector.elevationPlaceholder': 'يظهر المخطط عند توفّر بيانات ارتفاع المسار.', 'tours.planner.gpxNoGeometry': 'بيانات الشكل الهندسي للمسار غير متاحة لملف GPX المستورد هذا.', - 'tours.planner.mobileHint': - 'تخطيط الجولات متاح على الأجهزة اللوحية والحواسيب. يمكنك هنا عرض الجولات المحفوظة وجدولتها.', 'tourTypes.hike': 'مشي طويل', 'tourTypes.bike': 'جولة بالدراجة', 'tourTypes.run': 'جري', diff --git a/shared/src/i18n/az/admin.ts b/shared/src/i18n/az/admin.ts index d2010d3c7..2c8d2efda 100644 --- a/shared/src/i18n/az/admin.ts +++ b/shared/src/i18n/az/admin.ts @@ -709,6 +709,8 @@ const admin: TranslationStrings = { 'admin.addons.catalog.roadtrip.name': 'Avtomobil səyahəti', 'admin.addons.catalog.roadtrip.description': 'Marşrut üzərindəki dayanacaqlar, sürmə müddətləri və avtomatik yenilənən çatma vaxtları ilə avtomobil səfərlərini planlaşdırın', + 'admin.addons.catalog.tours.name': 'Turlar', + 'admin.addons.catalog.tours.description': 'Marşrut üzrə gəzintilər, velosiped və digər turlar', 'admin.addons.catalog.memories.name': 'Fotolar (Immich)', 'admin.addons.catalog.memories.description': 'Immich sisteminiz vasitəsilə səyahət fotolarını paylaşın', 'admin.addons.catalog.mcp.name': 'MCP', diff --git a/shared/src/i18n/az/mobileTrip.ts b/shared/src/i18n/az/mobileTrip.ts index 86d81c379..c10e45e0a 100644 --- a/shared/src/i18n/az/mobileTrip.ts +++ b/shared/src/i18n/az/mobileTrip.ts @@ -56,8 +56,11 @@ const mobileTrip: TranslationStrings = { 'mobileTrip.rtInfoTitle': 'Avtomobil marşrutu göstəriciləri', 'mobileTrip.rtDesktopNote': 'Bu göstəricilər kompüterdə təyin edilir. Orada alternativ marşrutları müqayisə edə və günü idxal edilmiş iz üzrə hərəkət edəcək şəkildə qura bilərsiniz.', - 'mobileTrip.rtPlanOnDesktop': - 'Planlaşdırma kompüterdə aparılır. Gündə iki məkan olduqda TREK avtomobil marşrutunu hesablayır.', + 'mobileTrip.planOnDesktop': 'Planlama kompüterdə', + 'mobileTrip.rtDriveOnTwoPlaces': 'Gündə iki məkan olduqda TREK avtomobil marşrutunu hesablayır.', + 'mobileTrip.toursEmptyBody': 'Saxlanmış turlar burada görünür və günə əlavə edilə bilər.', + 'mobileTrip.toursCount': '{count} tur', + 'mobileTrip.toursCount.one': '{count} tur', 'mobileTrip.rtSearchOffline': 'İnternet bağlantısı tələb olunur: axtarış qarşıdakı marşrutu yoxlayır.', 'mobileTrip.rtBehind': 'Plandan {time} geri', 'mobileTrip.rtStart': 'Başlanğıc', diff --git a/shared/src/i18n/az/nav.ts b/shared/src/i18n/az/nav.ts index ac7a287a7..c0b74b4f5 100644 --- a/shared/src/i18n/az/nav.ts +++ b/shared/src/i18n/az/nav.ts @@ -1,7 +1,6 @@ import type { TranslationStrings } from '../types'; const nav: TranslationStrings = { - 'nav.trip': 'Səyahət', 'nav.settings': 'Tənzimləmələr', 'nav.admin': 'Admin', 'nav.logout': 'Çıxış et', diff --git a/shared/src/i18n/az/tours.ts b/shared/src/i18n/az/tours.ts index 13b42cc6a..dbf289421 100644 --- a/shared/src/i18n/az/tours.ts +++ b/shared/src/i18n/az/tours.ts @@ -195,8 +195,6 @@ const tours: TranslationStrings = { 'tours.planner.inspector.profileFocus': '{distance} məsafədə: {elevation}', 'tours.planner.inspector.elevationPlaceholder': 'Marşrutun yüksəklik məlumatları olduqda profil göstərilir.', 'tours.planner.gpxNoGeometry': 'Bu GPX idxalı üçün marşrut həndəsəsi əlçatan deyil.', - 'tours.planner.mobileHint': - 'Tur planlaşdırmaq planşet və kompüterdə mümkündür. Burada saxlanılmış turlara baxmaq və onları günlərə təyin etmək olar.', 'tourTypes.hike': 'Yürüş', 'tourTypes.bike': 'Velosiped turu', 'tourTypes.run': 'Qaçış', diff --git a/shared/src/i18n/br/admin.ts b/shared/src/i18n/br/admin.ts index 8296226a2..cc02f6374 100644 --- a/shared/src/i18n/br/admin.ts +++ b/shared/src/i18n/br/admin.ts @@ -621,6 +621,8 @@ const admin: TranslationStrings = { 'admin.addons.catalog.roadtrip.name': 'Viagem de carro', 'admin.addons.catalog.roadtrip.description': 'Planeje trajetos com paradas; tempos de direção e horários de chegada se recalculam sozinhos', + 'admin.addons.catalog.tours.name': 'Passeios', + 'admin.addons.catalog.tours.description': 'Trilhas, pedaladas e outros passeios que seguem uma rota', 'admin.addons.catalog.mcp.name': 'MCP', 'admin.addons.catalog.mcp.description': 'Model Context Protocol para integração com assistentes de IA', 'admin.addons.subtitleBefore': 'Ative ou desative recursos para personalizar sua ', diff --git a/shared/src/i18n/br/mobileTrip.ts b/shared/src/i18n/br/mobileTrip.ts index 3055ea577..914b652de 100644 --- a/shared/src/i18n/br/mobileTrip.ts +++ b/shared/src/i18n/br/mobileTrip.ts @@ -56,8 +56,11 @@ const mobileTrip: TranslationStrings = { 'mobileTrip.rtInfoTitle': 'Dados de condução', 'mobileTrip.rtDesktopNote': 'Esses dados são definidos no computador, onde você também pode comparar rotas alternativas e fazer um dia seguir uma trilha importada.', - 'mobileTrip.rtPlanOnDesktop': - 'O planejamento acontece no computador. O TREK calcula o trajeto assim que um dia tem dois locais.', + 'mobileTrip.planOnDesktop': 'Planejado no computador', + 'mobileTrip.rtDriveOnTwoPlaces': 'O TREK calcula o trajeto assim que um dia tem dois locais.', + 'mobileTrip.toursEmptyBody': 'Os passeios salvos aparecem aqui, prontos para entrar em um dia.', + 'mobileTrip.toursCount': '{count} passeios', + 'mobileTrip.toursCount.one': '{count} passeio', 'mobileTrip.rtSearchOffline': 'Precisa de conexão: a busca lê a rota à frente.', 'mobileTrip.rtBehind': '{time} de atraso em relação ao plano', 'mobileTrip.rtStart': 'Início', diff --git a/shared/src/i18n/br/nav.ts b/shared/src/i18n/br/nav.ts index 476264f0c..13f2db5f0 100644 --- a/shared/src/i18n/br/nav.ts +++ b/shared/src/i18n/br/nav.ts @@ -1,7 +1,6 @@ import type { TranslationStrings } from '../types'; const nav: TranslationStrings = { - 'nav.trip': 'Viagem', 'nav.settings': 'Configurações', 'nav.admin': 'Admin', 'nav.logout': 'Sair', diff --git a/shared/src/i18n/br/tours.ts b/shared/src/i18n/br/tours.ts index 1f08059d4..637435aae 100644 --- a/shared/src/i18n/br/tours.ts +++ b/shared/src/i18n/br/tours.ts @@ -200,8 +200,6 @@ const tours: TranslationStrings = { 'tours.planner.inspector.elevationPlaceholder': 'O perfil é exibido quando os dados de elevação da rota estão disponíveis.', 'tours.planner.gpxNoGeometry': 'A geometria da rota não está disponível para esta importação GPX.', - 'tours.planner.mobileHint': - 'O planejamento de passeios está disponível em tablets e computadores. Aqui você pode visualizar os passeios salvos e atribuí-los a um dia.', 'tourTypes.hike': 'Trilha', 'tourTypes.bike': 'Passeio de bicicleta', 'tourTypes.run': 'Corrida', diff --git a/shared/src/i18n/ca/admin.ts b/shared/src/i18n/ca/admin.ts index 5f404e7a4..69f4693f2 100644 --- a/shared/src/i18n/ca/admin.ts +++ b/shared/src/i18n/ca/admin.ts @@ -383,6 +383,8 @@ const admin: TranslationStrings = { 'admin.addons.catalog.roadtrip.name': 'Viatge per carretera', 'admin.addons.catalog.roadtrip.description': 'Planifica trajectes amb parades: els temps de conducció i les hores d’arribada es recalculen sols', + 'admin.addons.catalog.tours.name': 'Excursions', + 'admin.addons.catalog.tours.description': 'Caminades, sortides en bici i altres excursions que segueixen una ruta', 'admin.addons.catalog.collections.name': 'Col·leccions', 'admin.addons.catalog.collections.description': 'Recull llocs de qualsevol viatge en llistes amb nom i reutilitza’ls', 'admin.oidcOnlyMode': 'Desactivar autenticació per contrasenya', diff --git a/shared/src/i18n/ca/mobileTrip.ts b/shared/src/i18n/ca/mobileTrip.ts index 7b9b465c8..59ac634cd 100644 --- a/shared/src/i18n/ca/mobileTrip.ts +++ b/shared/src/i18n/ca/mobileTrip.ts @@ -56,8 +56,11 @@ const mobileTrip: TranslationStrings = { 'mobileTrip.rtInfoTitle': 'Dades de conducció', 'mobileTrip.rtDesktopNote': 'Aquestes dades es configuren a l’escriptori, on també pots comparar rutes alternatives i fer que un dia segueixi una traça importada.', - 'mobileTrip.rtPlanOnDesktop': - 'La planificació es fa a l’escriptori. TREK calcula el trajecte quan un dia té dos llocs.', + 'mobileTrip.planOnDesktop': 'Es planifica a l’ordinador', + 'mobileTrip.rtDriveOnTwoPlaces': 'TREK calcula el trajecte quan un dia té dos llocs.', + 'mobileTrip.toursEmptyBody': 'Les excursions desades apareixen aquí, a punt per afegir-les a un dia.', + 'mobileTrip.toursCount': '{count} excursions', + 'mobileTrip.toursCount.one': '{count} excursió', 'mobileTrip.rtSearchOffline': 'Cal connexió: la cerca llegeix la ruta que tens per endavant.', 'mobileTrip.rtBehind': '{time} de retard sobre el pla', 'mobileTrip.rtStart': 'Inici', diff --git a/shared/src/i18n/ca/nav.ts b/shared/src/i18n/ca/nav.ts index 6309341f5..1fa2316d2 100644 --- a/shared/src/i18n/ca/nav.ts +++ b/shared/src/i18n/ca/nav.ts @@ -1,7 +1,6 @@ import type { TranslationStrings } from '../types'; const nav: TranslationStrings = { - 'nav.trip': 'Viatge', 'nav.settings': 'Configuració', 'nav.admin': 'Administració', 'nav.logout': 'Tanca la sessió', diff --git a/shared/src/i18n/ca/tours.ts b/shared/src/i18n/ca/tours.ts index f76ecfb40..e5e679504 100644 --- a/shared/src/i18n/ca/tours.ts +++ b/shared/src/i18n/ca/tours.ts @@ -200,8 +200,6 @@ const tours: TranslationStrings = { 'tours.planner.inspector.elevationPlaceholder': "El perfil es mostra quan hi ha dades d'altitud de la ruta disponibles.", 'tours.planner.gpxNoGeometry': 'La geometria de la ruta no està disponible per a aquesta importació GPX.', - 'tours.planner.mobileHint': - 'La planificació d’excursions està disponible en tauletes i ordinadors. Aquí pots consultar les excursions desades i assignar-les a un dia.', 'tourTypes.hike': 'Senderisme', 'tourTypes.bike': 'Ruta en bicicleta', 'tourTypes.run': 'Córrer', diff --git a/shared/src/i18n/cs/admin.ts b/shared/src/i18n/cs/admin.ts index 1a1b2e538..d89a214d1 100644 --- a/shared/src/i18n/cs/admin.ts +++ b/shared/src/i18n/cs/admin.ts @@ -641,6 +641,8 @@ const admin: TranslationStrings = { 'admin.addons.catalog.roadtrip.name': 'Cesta autem', 'admin.addons.catalog.roadtrip.description': 'Plánujte jízdy se zastávkami – doby jízdy a časy příjezdu se přepočítají samy', + 'admin.addons.catalog.tours.name': 'Výlety', + 'admin.addons.catalog.tours.description': 'Pěší, cyklo a další výlety, které vedou po trase', 'admin.addons.enabled': 'Povoleno', 'admin.addons.disabled': 'Zakázáno', 'admin.addons.type.trip': 'Cesta', diff --git a/shared/src/i18n/cs/mobileTrip.ts b/shared/src/i18n/cs/mobileTrip.ts index bc2041a0e..a4d60b911 100644 --- a/shared/src/i18n/cs/mobileTrip.ts +++ b/shared/src/i18n/cs/mobileTrip.ts @@ -59,7 +59,12 @@ const mobileTrip: TranslationStrings = { 'mobileTrip.rtInfoTitle': 'Údaje o jízdě', 'mobileTrip.rtDesktopNote': 'Tyto údaje se nastavují na počítači, kde také můžete porovnávat jiné trasy a nechat den sledovat importovanou stopu.', - 'mobileTrip.rtPlanOnDesktop': 'Plánování probíhá na počítači. Jakmile má den dvě místa, TREK jízdu spočítá.', + 'mobileTrip.planOnDesktop': 'Plánuje se na počítači', + 'mobileTrip.rtDriveOnTwoPlaces': 'Jakmile má den dvě místa, TREK jízdu spočítá.', + 'mobileTrip.toursEmptyBody': 'Uložené výlety se zobrazí tady a můžeš je přidat ke dni.', + 'mobileTrip.toursCount': '{count} výletů', + 'mobileTrip.toursCount.one': '{count} výlet', + 'mobileTrip.toursCount.few': '{count} výlety', 'mobileTrip.rtSearchOffline': 'Vyžaduje připojení: hledání čte trasu před vámi.', 'mobileTrip.rtBehind': 'o {time} za plánem', 'mobileTrip.rtStart': 'Začátek', diff --git a/shared/src/i18n/cs/nav.ts b/shared/src/i18n/cs/nav.ts index 0afe0a518..4be41d2dd 100644 --- a/shared/src/i18n/cs/nav.ts +++ b/shared/src/i18n/cs/nav.ts @@ -1,7 +1,6 @@ import type { TranslationStrings } from '../types'; const nav: TranslationStrings = { - 'nav.trip': 'Cesta', 'nav.settings': 'Nastavení', 'nav.admin': 'Administrace', 'nav.logout': 'Odhlásit se', diff --git a/shared/src/i18n/cs/tours.ts b/shared/src/i18n/cs/tours.ts index f6f410887..65cb9bcf1 100644 --- a/shared/src/i18n/cs/tours.ts +++ b/shared/src/i18n/cs/tours.ts @@ -198,8 +198,6 @@ const tours: TranslationStrings = { 'tours.planner.inspector.profileFocus': 'Ve vzdálenosti {distance}: {elevation}', 'tours.planner.inspector.elevationPlaceholder': 'Profil se zobrazí, až budou k dispozici výškové údaje trasy.', 'tours.planner.gpxNoGeometry': 'Pro tento import GPX není dostupná geometrie trasy.', - 'tours.planner.mobileHint': - 'Plánování výletů je dostupné na tabletu a počítači. Zde lze uložené výlety prohlížet a přiřazovat ke dnům.', 'tourTypes.hike': 'Pěší túra', 'tourTypes.bike': 'Cyklovýlet', 'tourTypes.run': 'Běh', diff --git a/shared/src/i18n/de/admin.ts b/shared/src/i18n/de/admin.ts index d4243856f..8fb296f2d 100644 --- a/shared/src/i18n/de/admin.ts +++ b/shared/src/i18n/de/admin.ts @@ -626,6 +626,8 @@ const admin: TranslationStrings = { 'admin.addons.catalog.roadtrip.name': 'Roadtrip', 'admin.addons.catalog.roadtrip.description': 'Fahrten mit Zwischenstopps planen — Fahrzeiten und Ankunftszeiten rechnen sich selbst neu', + 'admin.addons.catalog.tours.name': 'Touren', + 'admin.addons.catalog.tours.description': 'Wanderungen, Radtouren und andere Touren entlang einer Route', 'admin.addons.catalog.memories.name': 'Fotos (Immich)', 'admin.addons.catalog.memories.description': 'Reisefotos über deine Immich-Instanz teilen', 'admin.addons.catalog.mcp.name': 'MCP', diff --git a/shared/src/i18n/de/mobileTrip.ts b/shared/src/i18n/de/mobileTrip.ts index 5e70ecce4..bcdf44b11 100644 --- a/shared/src/i18n/de/mobileTrip.ts +++ b/shared/src/i18n/de/mobileTrip.ts @@ -56,7 +56,11 @@ const mobileTrip: TranslationStrings = { 'mobileTrip.rtInfoTitle': 'Fahrdaten', 'mobileTrip.rtDesktopNote': 'Diese Werte werden am Desktop gesetzt. Dort kannst du auch Routenvarianten vergleichen und einen Tag einer importierten Spur folgen lassen.', - 'mobileTrip.rtPlanOnDesktop': 'Geplant wird am Desktop. TREK berechnet die Fahrt, sobald ein Tag zwei Orte hat.', + 'mobileTrip.planOnDesktop': 'Geplant wird am Desktop', + 'mobileTrip.rtDriveOnTwoPlaces': 'TREK berechnet die Fahrt, sobald ein Tag zwei Orte hat.', + 'mobileTrip.toursEmptyBody': 'Gespeicherte Touren erscheinen hier und lassen sich einem Tag zuordnen.', + 'mobileTrip.toursCount': '{count} Touren', + 'mobileTrip.toursCount.one': '{count} Tour', 'mobileTrip.rtSearchOffline': 'Braucht eine Verbindung: Die Suche liest die Strecke, die vor dir liegt.', 'mobileTrip.rtBehind': '{time} hinter dem Plan', 'mobileTrip.rtStart': 'Start', diff --git a/shared/src/i18n/de/nav.ts b/shared/src/i18n/de/nav.ts index c3f0bc6d9..f35a4cb0a 100644 --- a/shared/src/i18n/de/nav.ts +++ b/shared/src/i18n/de/nav.ts @@ -1,7 +1,6 @@ import type { TranslationStrings } from '../types'; const nav: TranslationStrings = { - 'nav.trip': 'Reise', 'nav.settings': 'Einstellungen', 'nav.admin': 'Admin', 'nav.logout': 'Abmelden', diff --git a/shared/src/i18n/de/tours.ts b/shared/src/i18n/de/tours.ts index dc880cb4a..2dadbee81 100644 --- a/shared/src/i18n/de/tours.ts +++ b/shared/src/i18n/de/tours.ts @@ -201,8 +201,6 @@ const tours: TranslationStrings = { 'tours.planner.inspector.elevationPlaceholder': 'Das Höhenprofil erscheint, sobald Höhendaten für die Route verfügbar sind.', 'tours.planner.gpxNoGeometry': 'Für diesen GPX-Import sind keine Routendaten verfügbar.', - 'tours.planner.mobileHint': - 'Touren planen kannst du auf einem Tablet oder am Computer. Hier kannst du gespeicherte Touren ansehen und zuordnen.', 'tourTypes.hike': 'Wanderung', 'tourTypes.bike': 'Radtour', 'tourTypes.run': 'Lauf', diff --git a/shared/src/i18n/en/admin.ts b/shared/src/i18n/en/admin.ts index 075fdd762..63d6bbc17 100644 --- a/shared/src/i18n/en/admin.ts +++ b/shared/src/i18n/en/admin.ts @@ -672,6 +672,8 @@ const admin: TranslationStrings = { 'admin.addons.catalog.roadtrip.name': 'Road trip', 'admin.addons.catalog.roadtrip.description': 'Plan drives with stops along the route, driving times, and arrival times that update themselves', + 'admin.addons.catalog.tours.name': 'Tours', + 'admin.addons.catalog.tours.description': 'Hikes, rides and other tours that follow a route', 'admin.addons.catalog.memories.name': 'Photos (Immich)', 'admin.addons.catalog.memories.description': 'Share trip photos via your Immich instance', 'admin.addons.catalog.mcp.name': 'MCP', diff --git a/shared/src/i18n/en/mobileTrip.ts b/shared/src/i18n/en/mobileTrip.ts index 5b52a1934..c72a9a579 100644 --- a/shared/src/i18n/en/mobileTrip.ts +++ b/shared/src/i18n/en/mobileTrip.ts @@ -56,7 +56,11 @@ const mobileTrip: TranslationStrings = { 'mobileTrip.rtInfoTitle': 'Driving figures', 'mobileTrip.rtDesktopNote': 'These figures are set on the desktop, where you can also compare route alternatives and make a day follow an imported track.', - 'mobileTrip.rtPlanOnDesktop': 'Planning happens on the desktop. TREK works out the drive once a day has two places.', + 'mobileTrip.planOnDesktop': 'Planned on desktop', + 'mobileTrip.rtDriveOnTwoPlaces': 'TREK works out the drive once a day has two places.', + 'mobileTrip.toursEmptyBody': 'Saved tours show up here, ready to put on a day.', + 'mobileTrip.toursCount': '{count} tours', + 'mobileTrip.toursCount.one': '{count} tour', 'mobileTrip.rtSearchOffline': 'Needs a connection: the search reads the route ahead.', 'mobileTrip.rtBehind': '{time} behind plan', 'mobileTrip.rtStart': 'Start', diff --git a/shared/src/i18n/en/nav.ts b/shared/src/i18n/en/nav.ts index 6855368e2..c702206fc 100644 --- a/shared/src/i18n/en/nav.ts +++ b/shared/src/i18n/en/nav.ts @@ -1,7 +1,6 @@ import type { TranslationStrings } from '../types'; const nav: TranslationStrings = { - 'nav.trip': 'Trip', 'nav.settings': 'Settings', 'nav.admin': 'Admin', 'nav.logout': 'Log out', diff --git a/shared/src/i18n/en/tours.ts b/shared/src/i18n/en/tours.ts index 8fc40dc1f..d95682966 100644 --- a/shared/src/i18n/en/tours.ts +++ b/shared/src/i18n/en/tours.ts @@ -194,8 +194,6 @@ const tours: TranslationStrings = { 'tours.planner.inspector.profileFocus': 'At {distance}: {elevation}', 'tours.planner.inspector.elevationPlaceholder': 'The profile is shown when route elevation data is available.', 'tours.planner.gpxNoGeometry': 'Route geometry is unavailable for this GPX import.', - 'tours.planner.mobileHint': - 'Tour planning is available on tablet and desktop. Saved tours can be viewed and scheduled here.', 'tourTypes.hike': 'Hike', 'tourTypes.bike': 'Bike tour', 'tourTypes.run': 'Run', diff --git a/shared/src/i18n/es/admin.ts b/shared/src/i18n/es/admin.ts index d112f41e5..e279af1f1 100644 --- a/shared/src/i18n/es/admin.ts +++ b/shared/src/i18n/es/admin.ts @@ -766,6 +766,8 @@ const admin: TranslationStrings = { 'admin.addons.catalog.roadtrip.name': 'Viaje por carretera', 'admin.addons.catalog.roadtrip.description': 'Planifica trayectos con paradas: los tiempos de conducción y las horas de llegada se recalculan solos', + 'admin.addons.catalog.tours.name': 'Excursiones', + 'admin.addons.catalog.tours.description': 'Senderismo, rutas en bici y otras excursiones que siguen un recorrido', 'admin.oidcOnlyMode': 'Desactivar autenticación por contraseña', 'admin.oidcOnlyModeHint': 'Si está activado, solo se permite el inicio de sesión con SSO. El inicio de sesión y registro con contraseña se bloquean.', diff --git a/shared/src/i18n/es/mobileTrip.ts b/shared/src/i18n/es/mobileTrip.ts index ae9949e55..fe8849604 100644 --- a/shared/src/i18n/es/mobileTrip.ts +++ b/shared/src/i18n/es/mobileTrip.ts @@ -56,8 +56,11 @@ const mobileTrip: TranslationStrings = { 'mobileTrip.rtInfoTitle': 'Cifras del trayecto', 'mobileTrip.rtDesktopNote': 'Estas cifras se ajustan en el escritorio, donde también puedes comparar rutas alternativas y hacer que un día siga una traza importada.', - 'mobileTrip.rtPlanOnDesktop': - 'La planificación se hace en el escritorio. TREK calcula el trayecto en cuanto un día tiene dos lugares.', + 'mobileTrip.planOnDesktop': 'Se planifica en el ordenador', + 'mobileTrip.rtDriveOnTwoPlaces': 'TREK calcula el trayecto en cuanto un día tiene dos lugares.', + 'mobileTrip.toursEmptyBody': 'Las excursiones guardadas aparecen aquí, listas para añadirlas a un día.', + 'mobileTrip.toursCount': '{count} excursiones', + 'mobileTrip.toursCount.one': '{count} excursión', 'mobileTrip.rtSearchOffline': 'Hace falta conexión: la búsqueda lee la ruta que queda por delante.', 'mobileTrip.rtBehind': '{time} de retraso sobre lo previsto', 'mobileTrip.rtStart': 'Inicio', diff --git a/shared/src/i18n/es/nav.ts b/shared/src/i18n/es/nav.ts index ef14bec40..ce7f11d50 100644 --- a/shared/src/i18n/es/nav.ts +++ b/shared/src/i18n/es/nav.ts @@ -1,7 +1,6 @@ import type { TranslationStrings } from '../types'; const nav: TranslationStrings = { - 'nav.trip': 'Viaje', 'nav.settings': 'Ajustes', 'nav.admin': 'Administración', 'nav.logout': 'Cerrar sesión', diff --git a/shared/src/i18n/es/tours.ts b/shared/src/i18n/es/tours.ts index 1fe79b827..49ed4c10d 100644 --- a/shared/src/i18n/es/tours.ts +++ b/shared/src/i18n/es/tours.ts @@ -200,8 +200,6 @@ const tours: TranslationStrings = { 'tours.planner.inspector.profileFocus': 'A {distance}: {elevation}', 'tours.planner.inspector.elevationPlaceholder': 'El perfil se muestra cuando hay datos de altitud de la ruta.', 'tours.planner.gpxNoGeometry': 'La geometría de la ruta no está disponible para esta importación GPX.', - 'tours.planner.mobileHint': - 'La planificación de excursiones está disponible en tabletas y ordenadores. Aquí puedes consultar las excursiones guardadas y asignarlas a un día.', 'tourTypes.hike': 'Senderismo', 'tourTypes.bike': 'Ruta en bicicleta', 'tourTypes.run': 'Carrera a pie', diff --git a/shared/src/i18n/et/admin.ts b/shared/src/i18n/et/admin.ts index b6ff1a3af..98f6799b4 100644 --- a/shared/src/i18n/et/admin.ts +++ b/shared/src/i18n/et/admin.ts @@ -690,6 +690,8 @@ const admin: TranslationStrings = { 'admin.addons.catalog.roadtrip.name': 'Autoreis', 'admin.addons.catalog.roadtrip.description': 'Planeeri sõite koos marsruudipeatuste, sõiduaegade ja automaatselt uuenevate saabumisaegadega', + 'admin.addons.catalog.tours.name': 'Retked', + 'admin.addons.catalog.tours.description': 'Matkad, rattasõidud ja muud retked, mis järgivad marsruuti', 'admin.addons.catalog.memories.name': 'Fotod (Immich)', 'admin.addons.catalog.memories.description': 'Jaga reisifotosid oma Immichi paigalduse kaudu', 'admin.addons.catalog.mcp.name': 'MCP', diff --git a/shared/src/i18n/et/mobileTrip.ts b/shared/src/i18n/et/mobileTrip.ts index bf1ea4df3..8e2d92ec5 100644 --- a/shared/src/i18n/et/mobileTrip.ts +++ b/shared/src/i18n/et/mobileTrip.ts @@ -56,8 +56,11 @@ const mobileTrip: TranslationStrings = { 'mobileTrip.rtInfoTitle': 'Sõiduandmed', 'mobileTrip.rtDesktopNote': 'Neid andmeid saab määrata töölauavaates, kus saad võrrelda ka alternatiivseid marsruute ja määrata päeva järgima imporditud rada.', - 'mobileTrip.rtPlanOnDesktop': - 'Planeerimine toimub töölauavaates. TREK arvutab sõidu, kui päeval on vähemalt kaks kohta.', + 'mobileTrip.planOnDesktop': 'Planeeritakse arvutis', + 'mobileTrip.rtDriveOnTwoPlaces': 'TREK arvutab sõidu, kui päeval on vähemalt kaks kohta.', + 'mobileTrip.toursEmptyBody': 'Salvestatud retked ilmuvad siia ja neid saab päevale lisada.', + 'mobileTrip.toursCount': '{count} retke', + 'mobileTrip.toursCount.one': '{count} retk', 'mobileTrip.rtSearchOffline': 'Vajab ühendust: otsing kasutab eesolevat marsruuti.', 'mobileTrip.rtBehind': '{time} plaanist maas', 'mobileTrip.rtStart': 'Algus', diff --git a/shared/src/i18n/et/nav.ts b/shared/src/i18n/et/nav.ts index b3de48c0b..2e1664b3d 100644 --- a/shared/src/i18n/et/nav.ts +++ b/shared/src/i18n/et/nav.ts @@ -1,7 +1,6 @@ import type { TranslationStrings } from '../types'; const nav: TranslationStrings = { - 'nav.trip': 'Reis', 'nav.settings': 'Seaded', 'nav.admin': 'Haldus', 'nav.logout': 'Logi välja', diff --git a/shared/src/i18n/et/tours.ts b/shared/src/i18n/et/tours.ts index fde277867..d8f048617 100644 --- a/shared/src/i18n/et/tours.ts +++ b/shared/src/i18n/et/tours.ts @@ -197,8 +197,6 @@ const tours: TranslationStrings = { 'tours.planner.inspector.profileFocus': 'Kaugusel {distance}: {elevation}', 'tours.planner.inspector.elevationPlaceholder': 'Profiil kuvatakse, kui marsruudi kõrgusandmed on saadaval.', 'tours.planner.gpxNoGeometry': 'Selle GPX-impordi marsruudi geomeetria pole saadaval.', - 'tours.planner.mobileHint': - 'Retki saab planeerida tahvel- ja lauaarvutis. Siin saab salvestatud retki vaadata ja päevadele lisada.', 'tourTypes.hike': 'Matk', 'tourTypes.bike': 'Rattaretk', 'tourTypes.run': 'Jooks', diff --git a/shared/src/i18n/fr/admin.ts b/shared/src/i18n/fr/admin.ts index 38d16af29..c5f0e0ed7 100644 --- a/shared/src/i18n/fr/admin.ts +++ b/shared/src/i18n/fr/admin.ts @@ -635,6 +635,8 @@ const admin: TranslationStrings = { 'admin.addons.catalog.roadtrip.name': 'Road trip', 'admin.addons.catalog.roadtrip.description': 'Planifiez des trajets avec étapes : temps de conduite et heures d’arrivée se recalculent seuls', + 'admin.addons.catalog.tours.name': 'Sorties', + 'admin.addons.catalog.tours.description': 'Randonnées, balades à vélo et autres sorties qui suivent un itinéraire', 'admin.addons.subtitleBefore': 'Activez ou désactivez des fonctionnalités pour personnaliser votre expérience ', 'admin.addons.subtitleAfter': '.', 'admin.addons.catalog.naver_list_import.name': 'Naver List Import', diff --git a/shared/src/i18n/fr/mobileTrip.ts b/shared/src/i18n/fr/mobileTrip.ts index 3d2301e77..b21c066de 100644 --- a/shared/src/i18n/fr/mobileTrip.ts +++ b/shared/src/i18n/fr/mobileTrip.ts @@ -56,8 +56,11 @@ const mobileTrip: TranslationStrings = { 'mobileTrip.rtInfoTitle': 'Chiffres du trajet', 'mobileTrip.rtDesktopNote': 'Ces chiffres se règlent sur ordinateur, où vous pouvez aussi comparer d’autres itinéraires et faire suivre à un jour une trace importée.', - 'mobileTrip.rtPlanOnDesktop': - 'La planification se fait sur ordinateur. TREK calcule le trajet dès qu’un jour compte deux lieux.', + 'mobileTrip.planOnDesktop': 'Planifié sur ordinateur', + 'mobileTrip.rtDriveOnTwoPlaces': 'TREK calcule le trajet dès qu’un jour compte deux lieux.', + 'mobileTrip.toursEmptyBody': 'Les sorties enregistrées apparaissent ici, prêtes à être ajoutées à un jour.', + 'mobileTrip.toursCount': '{count} sorties', + 'mobileTrip.toursCount.one': '{count} sortie', 'mobileTrip.rtSearchOffline': 'Connexion nécessaire : la recherche lit l’itinéraire à venir.', 'mobileTrip.rtBehind': '{time} de retard sur le programme', 'mobileTrip.rtStart': 'Début', diff --git a/shared/src/i18n/fr/nav.ts b/shared/src/i18n/fr/nav.ts index 9f96508f6..4bb3d278a 100644 --- a/shared/src/i18n/fr/nav.ts +++ b/shared/src/i18n/fr/nav.ts @@ -1,7 +1,6 @@ import type { TranslationStrings } from '../types'; const nav: TranslationStrings = { - 'nav.trip': 'Voyage', 'nav.settings': 'Paramètres', 'nav.admin': 'Admin', 'nav.logout': 'Déconnexion', diff --git a/shared/src/i18n/fr/tours.ts b/shared/src/i18n/fr/tours.ts index f475fe637..86f4931f3 100644 --- a/shared/src/i18n/fr/tours.ts +++ b/shared/src/i18n/fr/tours.ts @@ -203,8 +203,6 @@ const tours: TranslationStrings = { 'tours.planner.inspector.elevationPlaceholder': "Le profil s'affiche lorsque les données d'altitude de l'itinéraire sont disponibles.", 'tours.planner.gpxNoGeometry': "La géométrie de l'itinéraire est indisponible pour cet import GPX.", - 'tours.planner.mobileHint': - 'La planification des sorties est disponible sur tablette et ordinateur. Vous pouvez consulter les sorties enregistrées et les affecter à une journée ici.', 'tourTypes.hike': 'Randonnée', 'tourTypes.bike': 'Sortie à vélo', 'tourTypes.run': 'Course à pied', diff --git a/shared/src/i18n/gr/admin.ts b/shared/src/i18n/gr/admin.ts index 68e9682fa..33be714cc 100644 --- a/shared/src/i18n/gr/admin.ts +++ b/shared/src/i18n/gr/admin.ts @@ -691,6 +691,8 @@ const admin: TranslationStrings = { 'admin.addons.catalog.roadtrip.name': 'Οδικό ταξίδι', 'admin.addons.catalog.roadtrip.description': 'Σχεδιάστε διαδρομές με στάσεις — οι χρόνοι οδήγησης και άφιξης υπολογίζονται ξανά αυτόματα', + 'admin.addons.catalog.tours.name': 'Εκδρομές', + 'admin.addons.catalog.tours.description': 'Πεζοπορίες, ποδηλατάδες και άλλες εκδρομές που ακολουθούν μια διαδρομή', 'admin.addons.catalog.memories.name': 'Φωτογραφίες (Immich)', 'admin.addons.catalog.memories.description': 'Μοιραστείτε φωτογραφίες ταξιδιού μέσω της δικής σας εγκατάστασης Immich', diff --git a/shared/src/i18n/gr/mobileTrip.ts b/shared/src/i18n/gr/mobileTrip.ts index c7db3f862..cccdde91b 100644 --- a/shared/src/i18n/gr/mobileTrip.ts +++ b/shared/src/i18n/gr/mobileTrip.ts @@ -56,8 +56,11 @@ const mobileTrip: TranslationStrings = { 'mobileTrip.rtInfoTitle': 'Στοιχεία οδήγησης', 'mobileTrip.rtDesktopNote': 'Αυτά τα στοιχεία ορίζονται στον υπολογιστή, όπου μπορείτε επίσης να συγκρίνετε άλλες διαδρομές και να κάνετε μια ημέρα να ακολουθεί ένα εισαγόμενο ίχνος.', - 'mobileTrip.rtPlanOnDesktop': - 'Ο σχεδιασμός γίνεται στον υπολογιστή. Το TREK υπολογίζει τη διαδρομή μόλις μια ημέρα έχει δύο μέρη.', + 'mobileTrip.planOnDesktop': 'Σχεδιάζεται στον υπολογιστή', + 'mobileTrip.rtDriveOnTwoPlaces': 'Το TREK υπολογίζει τη διαδρομή μόλις μια ημέρα έχει δύο μέρη.', + 'mobileTrip.toursEmptyBody': 'Οι αποθηκευμένες εκδρομές εμφανίζονται εδώ, έτοιμες να μπουν σε μια ημέρα.', + 'mobileTrip.toursCount': '{count} εκδρομές', + 'mobileTrip.toursCount.one': '{count} εκδρομή', 'mobileTrip.rtSearchOffline': 'Χρειάζεται σύνδεση: η αναζήτηση διαβάζει τη διαδρομή που ακολουθεί.', 'mobileTrip.rtBehind': '{time} πίσω από το πρόγραμμα', 'mobileTrip.rtStart': 'Έναρξη', diff --git a/shared/src/i18n/gr/nav.ts b/shared/src/i18n/gr/nav.ts index 5a146eaf1..dd0aedf8a 100644 --- a/shared/src/i18n/gr/nav.ts +++ b/shared/src/i18n/gr/nav.ts @@ -1,7 +1,6 @@ import type { TranslationStrings } from '../types'; const nav: TranslationStrings = { - 'nav.trip': 'Ταξίδι', 'nav.settings': 'Ρυθμίσεις', 'nav.admin': 'Διαχείριση', 'nav.logout': 'Αποσύνδεση', diff --git a/shared/src/i18n/gr/tours.ts b/shared/src/i18n/gr/tours.ts index a9af03eac..f72574456 100644 --- a/shared/src/i18n/gr/tours.ts +++ b/shared/src/i18n/gr/tours.ts @@ -204,8 +204,6 @@ const tours: TranslationStrings = { 'tours.planner.inspector.elevationPlaceholder': 'Το προφίλ εμφανίζεται όταν είναι διαθέσιμα τα υψομετρικά δεδομένα της διαδρομής.', 'tours.planner.gpxNoGeometry': 'Η γεωμετρία της διαδρομής δεν είναι διαθέσιμη για αυτήν την εισαγωγή GPX.', - 'tours.planner.mobileHint': - 'Ο σχεδιασμός εκδρομών είναι διαθέσιμος σε τάμπλετ και υπολογιστή. Εδώ μπορείτε να βλέπετε τις αποθηκευμένες εκδρομές και να τις αντιστοιχίζετε σε ημέρες.', 'tourTypes.hike': 'Πεζοπορία', 'tourTypes.bike': 'Ποδηλατική εκδρομή', 'tourTypes.run': 'Τρέξιμο', diff --git a/shared/src/i18n/hu/admin.ts b/shared/src/i18n/hu/admin.ts index 7369726dc..b66dea2e0 100644 --- a/shared/src/i18n/hu/admin.ts +++ b/shared/src/i18n/hu/admin.ts @@ -631,6 +631,8 @@ const admin: TranslationStrings = { 'admin.addons.catalog.roadtrip.name': 'Autós út', 'admin.addons.catalog.roadtrip.description': 'Tervezz megállókkal tarkított utakat – a vezetési és érkezési idők maguktól újraszámolódnak', + 'admin.addons.catalog.tours.name': 'Túrák', + 'admin.addons.catalog.tours.description': 'Gyalog-, kerékpár- és más túrák, amelyek útvonalat követnek', 'admin.addons.catalog.memories.name': 'Fotók (Immich)', 'admin.addons.catalog.memories.description': 'Utazási fotók megosztása az Immich példányon keresztül', 'admin.addons.catalog.mcp.name': 'MCP', diff --git a/shared/src/i18n/hu/mobileTrip.ts b/shared/src/i18n/hu/mobileTrip.ts index a214235ca..0061ff623 100644 --- a/shared/src/i18n/hu/mobileTrip.ts +++ b/shared/src/i18n/hu/mobileTrip.ts @@ -56,8 +56,11 @@ const mobileTrip: TranslationStrings = { 'mobileTrip.rtInfoTitle': 'Vezetési adatok', 'mobileTrip.rtDesktopNote': 'Ezeket az adatokat asztali gépen adod meg, ott hasonlíthatod össze az útvonalváltozatokat, és állíthatod be, hogy egy nap importált nyomvonalat kövessen.', - 'mobileTrip.rtPlanOnDesktop': - 'A tervezés asztali gépen történik. Amint egy napon két hely szerepel, a TREK kiszámolja az útvonalat.', + 'mobileTrip.planOnDesktop': 'Tervezés számítógépen', + 'mobileTrip.rtDriveOnTwoPlaces': 'Amint egy napon két hely szerepel, a TREK kiszámolja az útvonalat.', + 'mobileTrip.toursEmptyBody': 'A mentett túrák itt jelennek meg, és egy naphoz rendelhetők.', + 'mobileTrip.toursCount': '{count} túra', + 'mobileTrip.toursCount.one': '{count} túra', 'mobileTrip.rtSearchOffline': 'Internetkapcsolat kell hozzá: a keresés az előtted lévő útvonalat olvassa be.', 'mobileTrip.rtBehind': '{time} késés a tervhez képest', 'mobileTrip.rtStart': 'Kezdés', diff --git a/shared/src/i18n/hu/nav.ts b/shared/src/i18n/hu/nav.ts index a4df55c2d..8f4da6a12 100644 --- a/shared/src/i18n/hu/nav.ts +++ b/shared/src/i18n/hu/nav.ts @@ -1,7 +1,6 @@ import type { TranslationStrings } from '../types'; const nav: TranslationStrings = { - 'nav.trip': 'Utazás', 'nav.settings': 'Beállítások', 'nav.admin': 'Admin', 'nav.logout': 'Kijelentkezés', diff --git a/shared/src/i18n/hu/tours.ts b/shared/src/i18n/hu/tours.ts index fd0732290..b9214f575 100644 --- a/shared/src/i18n/hu/tours.ts +++ b/shared/src/i18n/hu/tours.ts @@ -199,8 +199,6 @@ const tours: TranslationStrings = { 'tours.planner.inspector.elevationPlaceholder': 'A profil akkor jelenik meg, ha rendelkezésre állnak az útvonal magassági adatai.', 'tours.planner.gpxNoGeometry': 'Ehhez a GPX-importhoz nem érhető el útvonalgeometria.', - 'tours.planner.mobileHint': - 'A túratervezés táblagépen és számítógépen érhető el. Itt megtekintheted és napokhoz rendelheted a mentett túrákat.', 'tourTypes.hike': 'Gyalogtúra', 'tourTypes.bike': 'Kerékpártúra', 'tourTypes.run': 'Futás', diff --git a/shared/src/i18n/id/admin.ts b/shared/src/i18n/id/admin.ts index ece9c4b93..b00d1214c 100644 --- a/shared/src/i18n/id/admin.ts +++ b/shared/src/i18n/id/admin.ts @@ -672,6 +672,8 @@ const admin: TranslationStrings = { 'admin.addons.catalog.roadtrip.name': 'Perjalanan darat', 'admin.addons.catalog.roadtrip.description': 'Rencanakan perjalanan dengan perhentian; waktu berkendara dan tiba dihitung ulang otomatis', + 'admin.addons.catalog.tours.name': 'Tur', + 'admin.addons.catalog.tours.description': 'Pendakian, bersepeda, dan tur lain yang mengikuti rute', 'admin.addons.catalog.memories.name': 'Foto (Immich)', 'admin.addons.catalog.memories.description': 'Bagikan foto perjalanan melalui instans Immich kamu', 'admin.addons.catalog.mcp.name': 'MCP', diff --git a/shared/src/i18n/id/mobileTrip.ts b/shared/src/i18n/id/mobileTrip.ts index 7a0404732..71ddb4239 100644 --- a/shared/src/i18n/id/mobileTrip.ts +++ b/shared/src/i18n/id/mobileTrip.ts @@ -53,8 +53,10 @@ const mobileTrip: TranslationStrings = { 'mobileTrip.rtInfoTitle': 'Data berkendara', 'mobileTrip.rtDesktopNote': 'Data ini diatur di desktop, di sana kamu juga bisa membandingkan rute alternatif dan membuat satu hari mengikuti jalur yang diimpor.', - 'mobileTrip.rtPlanOnDesktop': - 'Perencanaan dilakukan di desktop. TREK menghitung perjalanannya begitu satu hari punya dua tempat.', + 'mobileTrip.planOnDesktop': 'Direncanakan di komputer', + 'mobileTrip.rtDriveOnTwoPlaces': 'TREK menghitung perjalanannya begitu satu hari punya dua tempat.', + 'mobileTrip.toursEmptyBody': 'Tur yang tersimpan muncul di sini, siap ditambahkan ke sebuah hari.', + 'mobileTrip.toursCount': '{count} tur', 'mobileTrip.rtSearchOffline': 'Perlu koneksi: pencarian membaca rute di depan.', 'mobileTrip.rtBehind': 'terlambat {time} dari rencana', 'mobileTrip.rtStart': 'Mulai', diff --git a/shared/src/i18n/id/nav.ts b/shared/src/i18n/id/nav.ts index 6ab97f1e3..11628ae89 100644 --- a/shared/src/i18n/id/nav.ts +++ b/shared/src/i18n/id/nav.ts @@ -1,7 +1,6 @@ import type { TranslationStrings } from '../types'; const nav: TranslationStrings = { - 'nav.trip': 'Perjalanan', 'nav.settings': 'Pengaturan', 'nav.admin': 'Admin', 'nav.logout': 'Keluar', diff --git a/shared/src/i18n/id/tours.ts b/shared/src/i18n/id/tours.ts index c25e01c51..ba3b37002 100644 --- a/shared/src/i18n/id/tours.ts +++ b/shared/src/i18n/id/tours.ts @@ -193,8 +193,6 @@ const tours: TranslationStrings = { 'tours.planner.inspector.profileFocus': 'Pada jarak {distance}: {elevation}', 'tours.planner.inspector.elevationPlaceholder': 'Profil ditampilkan saat data elevasi rute tersedia.', 'tours.planner.gpxNoGeometry': 'Geometri rute tidak tersedia untuk impor GPX ini.', - 'tours.planner.mobileHint': - 'Perencanaan tur tersedia di tablet dan komputer. Tur tersimpan dapat dilihat dan dijadwalkan di sini.', 'tourTypes.hike': 'Pendakian', 'tourTypes.bike': 'Tur sepeda', 'tourTypes.run': 'Lari', diff --git a/shared/src/i18n/it/admin.ts b/shared/src/i18n/it/admin.ts index ce09fe2bf..1ef068188 100644 --- a/shared/src/i18n/it/admin.ts +++ b/shared/src/i18n/it/admin.ts @@ -626,6 +626,8 @@ const admin: TranslationStrings = { 'admin.addons.catalog.roadtrip.name': 'Viaggio su strada', 'admin.addons.catalog.roadtrip.description': 'Pianifica viaggi con soste: tempi di guida e orari di arrivo si ricalcolano da soli', + 'admin.addons.catalog.tours.name': 'Escursioni', + 'admin.addons.catalog.tours.description': 'Escursioni a piedi, in bici e altre uscite che seguono un percorso', 'admin.addons.catalog.memories.name': 'Foto (Immich)', 'admin.addons.catalog.memories.description': 'Condividi le foto del viaggio tramite la tua istanza Immich', 'admin.addons.catalog.mcp.name': 'MCP', diff --git a/shared/src/i18n/it/mobileTrip.ts b/shared/src/i18n/it/mobileTrip.ts index afdc9b350..acb6e8a9a 100644 --- a/shared/src/i18n/it/mobileTrip.ts +++ b/shared/src/i18n/it/mobileTrip.ts @@ -56,8 +56,11 @@ const mobileTrip: TranslationStrings = { 'mobileTrip.rtInfoTitle': 'Dati del tragitto', 'mobileTrip.rtDesktopNote': 'Questi dati si impostano da computer, dove puoi anche confrontare percorsi alternativi e far seguire a un giorno una traccia importata.', - 'mobileTrip.rtPlanOnDesktop': - 'La pianificazione si fa da computer. TREK calcola il tragitto appena un giorno ha due luoghi.', + 'mobileTrip.planOnDesktop': 'Si pianifica dal computer', + 'mobileTrip.rtDriveOnTwoPlaces': 'TREK calcola il tragitto appena un giorno ha due luoghi.', + 'mobileTrip.toursEmptyBody': 'Le escursioni salvate compaiono qui, pronte da aggiungere a un giorno.', + 'mobileTrip.toursCount': '{count} escursioni', + 'mobileTrip.toursCount.one': '{count} escursione', 'mobileTrip.rtSearchOffline': 'Serve una connessione: la ricerca legge il percorso che hai davanti.', 'mobileTrip.rtBehind': '{time} di ritardo sul programma', 'mobileTrip.rtStart': 'Inizio', diff --git a/shared/src/i18n/it/nav.ts b/shared/src/i18n/it/nav.ts index 6a4230351..089232e2a 100644 --- a/shared/src/i18n/it/nav.ts +++ b/shared/src/i18n/it/nav.ts @@ -1,7 +1,6 @@ import type { TranslationStrings } from '../types'; const nav: TranslationStrings = { - 'nav.trip': 'Viaggio', 'nav.settings': 'Impostazioni', 'nav.admin': 'Amministrazione', 'nav.logout': 'Esci', diff --git a/shared/src/i18n/it/tours.ts b/shared/src/i18n/it/tours.ts index 2e5544c69..189d11bba 100644 --- a/shared/src/i18n/it/tours.ts +++ b/shared/src/i18n/it/tours.ts @@ -199,8 +199,6 @@ const tours: TranslationStrings = { 'tours.planner.inspector.elevationPlaceholder': 'Il profilo viene mostrato quando sono disponibili i dati altimetrici del percorso.', 'tours.planner.gpxNoGeometry': 'La geometria del percorso non è disponibile per questa importazione GPX.', - 'tours.planner.mobileHint': - 'La pianificazione delle escursioni è disponibile su tablet e computer. Qui puoi visualizzare le escursioni salvate e assegnarle a un giorno.', 'tourTypes.hike': 'Escursione a piedi', 'tourTypes.bike': 'Escursione in bici', 'tourTypes.run': 'Corsa', diff --git a/shared/src/i18n/ja/admin.ts b/shared/src/i18n/ja/admin.ts index a2ec1970c..58e9a60bf 100644 --- a/shared/src/i18n/ja/admin.ts +++ b/shared/src/i18n/ja/admin.ts @@ -636,6 +636,8 @@ const admin: TranslationStrings = { 'admin.addons.catalog.roadtrip.name': 'ロードトリップ', 'admin.addons.catalog.roadtrip.description': '立ち寄り先付きのドライブを計画。運転時間と到着時刻は自動で再計算されます', + 'admin.addons.catalog.tours.name': 'ツアー', + 'admin.addons.catalog.tours.description': 'ルートに沿ったハイキング、サイクリングなどのツアー', 'admin.addons.catalog.memories.name': '写真(Immich)', 'admin.addons.catalog.memories.description': 'Immichで旅行写真を共有', 'admin.addons.catalog.mcp.name': 'MCP', diff --git a/shared/src/i18n/ja/mobileTrip.ts b/shared/src/i18n/ja/mobileTrip.ts index df955107e..dc3ad08f2 100644 --- a/shared/src/i18n/ja/mobileTrip.ts +++ b/shared/src/i18n/ja/mobileTrip.ts @@ -53,7 +53,10 @@ const mobileTrip: TranslationStrings = { 'mobileTrip.rtInfoTitle': '走行データ', 'mobileTrip.rtDesktopNote': 'これらの数値はデスクトップで設定します。デスクトップでは、別のルートを比べたり、読み込んだトラックにその日を沿わせたりもできます。', - 'mobileTrip.rtPlanOnDesktop': '計画はデスクトップで行います。1日に場所が2か所そろうと、TREKが走行を計算します。', + 'mobileTrip.planOnDesktop': '計画はデスクトップで', + 'mobileTrip.rtDriveOnTwoPlaces': '1日に場所が2か所そろうと、TREKが走行を計算します。', + 'mobileTrip.toursEmptyBody': '保存したツアーがここに表示され、日に追加できます。', + 'mobileTrip.toursCount': '{count} 件のツアー', 'mobileTrip.rtSearchOffline': '接続が必要です:この検索は先のルートを読み取ります。', 'mobileTrip.rtBehind': '予定より {time} 遅れ', 'mobileTrip.rtStart': '開始', diff --git a/shared/src/i18n/ja/nav.ts b/shared/src/i18n/ja/nav.ts index 9845634eb..6903c7495 100644 --- a/shared/src/i18n/ja/nav.ts +++ b/shared/src/i18n/ja/nav.ts @@ -1,7 +1,6 @@ import type { TranslationStrings } from '../types'; const nav: TranslationStrings = { - 'nav.trip': '旅行', 'nav.settings': '設定', 'nav.admin': '管理', 'nav.logout': 'ログアウト', diff --git a/shared/src/i18n/ja/tours.ts b/shared/src/i18n/ja/tours.ts index 65bdf10f4..b2f8cdc1d 100644 --- a/shared/src/i18n/ja/tours.ts +++ b/shared/src/i18n/ja/tours.ts @@ -190,8 +190,6 @@ const tours: TranslationStrings = { 'tours.planner.inspector.profileFocus': '距離{distance}:標高{elevation}', 'tours.planner.inspector.elevationPlaceholder': 'ルートの標高データが取得できると、グラフが表示されます。', 'tours.planner.gpxNoGeometry': 'このGPXインポートにはルートの形状データがありません。', - 'tours.planner.mobileHint': - 'ツアーの計画はタブレットとパソコンで利用できます。ここでは保存済みのツアーを閲覧し、日程に割り当てられます。', 'tourTypes.hike': 'ハイキング', 'tourTypes.bike': 'サイクリング', 'tourTypes.run': 'ランニング', diff --git a/shared/src/i18n/ko/admin.ts b/shared/src/i18n/ko/admin.ts index bc4db198a..f002bf217 100644 --- a/shared/src/i18n/ko/admin.ts +++ b/shared/src/i18n/ko/admin.ts @@ -635,6 +635,8 @@ const admin: TranslationStrings = { 'admin.addons.catalog.roadtrip.name': '로드트립', 'admin.addons.catalog.roadtrip.description': '경유지가 있는 주행을 계획하세요. 운전 시간과 도착 시간이 자동으로 다시 계산됩니다', + 'admin.addons.catalog.tours.name': '투어', + 'admin.addons.catalog.tours.description': '경로를 따라가는 하이킹, 라이딩 등 투어', 'admin.addons.catalog.memories.name': '사진 (Immich)', 'admin.addons.catalog.memories.description': 'Immich 인스턴스를 통해 여행 사진 공유', 'admin.addons.catalog.mcp.name': 'MCP', diff --git a/shared/src/i18n/ko/mobileTrip.ts b/shared/src/i18n/ko/mobileTrip.ts index 300d87423..7b04a06f1 100644 --- a/shared/src/i18n/ko/mobileTrip.ts +++ b/shared/src/i18n/ko/mobileTrip.ts @@ -53,7 +53,10 @@ const mobileTrip: TranslationStrings = { 'mobileTrip.rtInfoTitle': '주행 수치', 'mobileTrip.rtDesktopNote': '이 수치는 데스크톱에서 설정합니다. 데스크톱에서는 다른 경로를 비교하고, 가져온 트랙을 따라가도록 하루를 맞출 수도 있습니다.', - 'mobileTrip.rtPlanOnDesktop': '계획은 데스크톱에서 세웁니다. 하루에 장소가 두 곳 생기면 TREK이 주행을 계산합니다.', + 'mobileTrip.planOnDesktop': '계획은 데스크톱에서', + 'mobileTrip.rtDriveOnTwoPlaces': '하루에 장소가 두 곳 생기면 TREK이 주행을 계산합니다.', + 'mobileTrip.toursEmptyBody': '저장된 투어가 여기에 표시되며 날짜에 추가할 수 있습니다.', + 'mobileTrip.toursCount': '투어 {count}개', 'mobileTrip.rtSearchOffline': '연결이 필요합니다: 이 검색은 앞으로 갈 경로를 읽습니다.', 'mobileTrip.rtBehind': '계획보다 {time} 늦음', 'mobileTrip.rtStart': '시작', diff --git a/shared/src/i18n/ko/nav.ts b/shared/src/i18n/ko/nav.ts index a92ac2d36..b4afeac11 100644 --- a/shared/src/i18n/ko/nav.ts +++ b/shared/src/i18n/ko/nav.ts @@ -1,7 +1,6 @@ import type { TranslationStrings } from '../types'; const nav: TranslationStrings = { - 'nav.trip': '여행', 'nav.settings': '설정', 'nav.admin': '관리자', 'nav.logout': '로그아웃', diff --git a/shared/src/i18n/ko/tours.ts b/shared/src/i18n/ko/tours.ts index dde5c851d..754dbfc4c 100644 --- a/shared/src/i18n/ko/tours.ts +++ b/shared/src/i18n/ko/tours.ts @@ -188,8 +188,6 @@ const tours: TranslationStrings = { 'tours.planner.inspector.profileFocus': '{distance} 지점: {elevation}', 'tours.planner.inspector.elevationPlaceholder': '경로의 고도 데이터가 있으면 그래프가 표시됩니다.', 'tours.planner.gpxNoGeometry': '이 GPX 가져오기에는 경로 형상 데이터가 없습니다.', - 'tours.planner.mobileHint': - '투어 계획은 태블릿과 컴퓨터에서 이용할 수 있습니다. 여기에서는 저장된 투어를 보고 일정에 배정할 수 있습니다.', 'tourTypes.hike': '하이킹', 'tourTypes.bike': '자전거 투어', 'tourTypes.run': '달리기', diff --git a/shared/src/i18n/nl/admin.ts b/shared/src/i18n/nl/admin.ts index f296faf2e..6b80bfea0 100644 --- a/shared/src/i18n/nl/admin.ts +++ b/shared/src/i18n/nl/admin.ts @@ -629,6 +629,8 @@ const admin: TranslationStrings = { 'admin.addons.catalog.roadtrip.name': 'Roadtrip', 'admin.addons.catalog.roadtrip.description': 'Plan ritten met tussenstops; rijtijden en aankomsttijden berekenen zichzelf opnieuw', + 'admin.addons.catalog.tours.name': 'Tochten', + 'admin.addons.catalog.tours.description': 'Wandelingen, fietstochten en andere tochten langs een route', 'admin.addons.subtitleBefore': 'Schakel functies in of uit om je ', 'admin.addons.subtitleAfter': '-ervaring aan te passen.', 'admin.addons.catalog.naver_list_import.name': 'Naver List Import', diff --git a/shared/src/i18n/nl/mobileTrip.ts b/shared/src/i18n/nl/mobileTrip.ts index b8eafba90..b0a4fd225 100644 --- a/shared/src/i18n/nl/mobileTrip.ts +++ b/shared/src/i18n/nl/mobileTrip.ts @@ -56,7 +56,11 @@ const mobileTrip: TranslationStrings = { 'mobileTrip.rtInfoTitle': 'Rijgegevens', 'mobileTrip.rtDesktopNote': 'Deze waarden stel je in op de desktop. Daar kun je ook routevarianten vergelijken en een dag een geïmporteerde track laten volgen.', - 'mobileTrip.rtPlanOnDesktop': 'Plannen doe je op de desktop. TREK berekent de rit zodra een dag twee plaatsen heeft.', + 'mobileTrip.planOnDesktop': 'Plannen op de computer', + 'mobileTrip.rtDriveOnTwoPlaces': 'TREK berekent de rit zodra een dag twee plaatsen heeft.', + 'mobileTrip.toursEmptyBody': 'Opgeslagen tochten verschijnen hier en kun je aan een dag toevoegen.', + 'mobileTrip.toursCount': '{count} tochten', + 'mobileTrip.toursCount.one': '{count} tocht', 'mobileTrip.rtSearchOffline': 'Vereist een verbinding: de zoekopdracht leest de route die voor je ligt.', 'mobileTrip.rtBehind': '{time} achter op schema', 'mobileTrip.rtStart': 'Start', diff --git a/shared/src/i18n/nl/nav.ts b/shared/src/i18n/nl/nav.ts index 555d09580..304a92f7b 100644 --- a/shared/src/i18n/nl/nav.ts +++ b/shared/src/i18n/nl/nav.ts @@ -1,7 +1,6 @@ import type { TranslationStrings } from '../types'; const nav: TranslationStrings = { - 'nav.trip': 'Reis', 'nav.settings': 'Instellingen', 'nav.admin': 'Admin', 'nav.logout': 'Uitloggen', diff --git a/shared/src/i18n/nl/tours.ts b/shared/src/i18n/nl/tours.ts index 60a875049..188e69c5c 100644 --- a/shared/src/i18n/nl/tours.ts +++ b/shared/src/i18n/nl/tours.ts @@ -199,8 +199,6 @@ const tours: TranslationStrings = { 'tours.planner.inspector.elevationPlaceholder': 'Het profiel wordt getoond zodra hoogtegegevens voor de route beschikbaar zijn.', 'tours.planner.gpxNoGeometry': 'De routegeometrie is niet beschikbaar voor deze GPX-import.', - 'tours.planner.mobileHint': - 'Tochten plannen kan op een tablet of computer. Hier kun je opgeslagen tochten bekijken en aan een dag toewijzen.', 'tourTypes.hike': 'Wandeling', 'tourTypes.bike': 'Fietstocht', 'tourTypes.run': 'Hardlooptocht', diff --git a/shared/src/i18n/pl/admin.ts b/shared/src/i18n/pl/admin.ts index 2b43ed06f..623cfeceb 100644 --- a/shared/src/i18n/pl/admin.ts +++ b/shared/src/i18n/pl/admin.ts @@ -641,6 +641,8 @@ const admin: TranslationStrings = { 'admin.addons.catalog.roadtrip.name': 'Podróż samochodowa', 'admin.addons.catalog.roadtrip.description': 'Planuj przejazdy z przystankami — czasy jazdy i godziny przyjazdu przeliczają się same', + 'admin.addons.catalog.tours.name': 'Wycieczki', + 'admin.addons.catalog.tours.description': 'Wędrówki, wycieczki rowerowe i inne trasy', 'admin.addons.catalog.memories.name': 'Zdjęcia (Immich)', 'admin.addons.catalog.memories.description': 'Udostępniaj zdjęcia z podróży za pośrednictwem swojej instancji Immich', 'admin.addons.catalog.mcp.name': 'MCP', diff --git a/shared/src/i18n/pl/mobileTrip.ts b/shared/src/i18n/pl/mobileTrip.ts index b5a724752..7f5905399 100644 --- a/shared/src/i18n/pl/mobileTrip.ts +++ b/shared/src/i18n/pl/mobileTrip.ts @@ -62,7 +62,13 @@ const mobileTrip: TranslationStrings = { 'mobileTrip.rtInfoTitle': 'Dane jazdy', 'mobileTrip.rtDesktopNote': 'Te dane ustawia się na komputerze, gdzie możesz też porównywać warianty trasy i sprawić, by dzień podążał za zaimportowanym śladem.', - 'mobileTrip.rtPlanOnDesktop': 'Planowanie odbywa się na komputerze. TREK wylicza przejazd, gdy dzień ma dwa miejsca.', + 'mobileTrip.planOnDesktop': 'Planowanie na komputerze', + 'mobileTrip.rtDriveOnTwoPlaces': 'TREK wylicza przejazd, gdy dzień ma dwa miejsca.', + 'mobileTrip.toursEmptyBody': 'Zapisane wycieczki pojawią się tutaj i można je dodać do dnia.', + 'mobileTrip.toursCount': '{count} wycieczki', + 'mobileTrip.toursCount.one': '{count} wycieczka', + 'mobileTrip.toursCount.few': '{count} wycieczki', + 'mobileTrip.toursCount.many': '{count} wycieczek', 'mobileTrip.rtSearchOffline': 'Wymaga połączenia: wyszukiwanie czyta trasę przed tobą.', 'mobileTrip.rtBehind': '{time} opóźnienia względem planu', 'mobileTrip.rtStart': 'Początek', diff --git a/shared/src/i18n/pl/nav.ts b/shared/src/i18n/pl/nav.ts index fcad104e1..b63783932 100644 --- a/shared/src/i18n/pl/nav.ts +++ b/shared/src/i18n/pl/nav.ts @@ -1,7 +1,6 @@ import type { TranslationStrings } from '../types'; const nav: TranslationStrings = { - 'nav.trip': 'Podróż', 'nav.settings': 'Ustawienia', 'nav.admin': 'Admin', 'nav.logout': 'Wyloguj', diff --git a/shared/src/i18n/pl/tours.ts b/shared/src/i18n/pl/tours.ts index fa086ff55..f615e6525 100644 --- a/shared/src/i18n/pl/tours.ts +++ b/shared/src/i18n/pl/tours.ts @@ -207,8 +207,6 @@ const tours: TranslationStrings = { 'tours.planner.inspector.profileFocus': 'Na dystansie {distance}: {elevation}', 'tours.planner.inspector.elevationPlaceholder': 'Profil pojawi się, gdy będą dostępne dane wysokościowe trasy.', 'tours.planner.gpxNoGeometry': 'Geometria trasy jest niedostępna dla tego importu GPX.', - 'tours.planner.mobileHint': - 'Planowanie wycieczek jest dostępne na tablecie i komputerze. Tutaj możesz przeglądać zapisane wycieczki i przypisywać je do dni.', 'tourTypes.hike': 'Wędrówka piesza', 'tourTypes.bike': 'Wycieczka rowerowa', 'tourTypes.run': 'Bieg', diff --git a/shared/src/i18n/ru/admin.ts b/shared/src/i18n/ru/admin.ts index 51f2ecbb5..b16c259c0 100644 --- a/shared/src/i18n/ru/admin.ts +++ b/shared/src/i18n/ru/admin.ts @@ -650,6 +650,8 @@ const admin: TranslationStrings = { 'admin.addons.catalog.roadtrip.name': 'Автопутешествие', 'admin.addons.catalog.roadtrip.description': 'Планируйте поездки с остановками: время в пути и прибытия пересчитывается автоматически', + 'admin.addons.catalog.tours.name': 'Туры', + 'admin.addons.catalog.tours.description': 'Походы, велопрогулки и другие туры по маршруту', 'admin.addons.subtitleBefore': 'Включайте или отключайте функции для настройки ', 'admin.addons.subtitleAfter': ' под себя.', 'admin.addons.catalog.naver_list_import.name': 'Naver List Import', diff --git a/shared/src/i18n/ru/mobileTrip.ts b/shared/src/i18n/ru/mobileTrip.ts index 1f0c7e502..63cfc423a 100644 --- a/shared/src/i18n/ru/mobileTrip.ts +++ b/shared/src/i18n/ru/mobileTrip.ts @@ -62,8 +62,13 @@ const mobileTrip: TranslationStrings = { 'mobileTrip.rtInfoTitle': 'Данные о вождении', 'mobileTrip.rtDesktopNote': 'Эти данные задаются на компьютере: там же можно сравнивать другие маршруты и провести день по импортированному треку.', - 'mobileTrip.rtPlanOnDesktop': - 'Планирование происходит на компьютере. TREK рассчитает маршрут, как только в дне окажется два места.', + 'mobileTrip.planOnDesktop': 'Планирование на компьютере', + 'mobileTrip.rtDriveOnTwoPlaces': 'TREK рассчитает маршрут, как только в дне окажется два места.', + 'mobileTrip.toursEmptyBody': 'Сохранённые туры появятся здесь, их можно добавить в день.', + 'mobileTrip.toursCount': '{count} тура', + 'mobileTrip.toursCount.one': '{count} тур', + 'mobileTrip.toursCount.few': '{count} тура', + 'mobileTrip.toursCount.many': '{count} туров', 'mobileTrip.rtSearchOffline': 'Нужна сеть: поиск считывает маршрут впереди.', 'mobileTrip.rtBehind': 'на {time} позже плана', 'mobileTrip.rtStart': 'Начало', diff --git a/shared/src/i18n/ru/nav.ts b/shared/src/i18n/ru/nav.ts index 4c95a043f..7fdac1ebc 100644 --- a/shared/src/i18n/ru/nav.ts +++ b/shared/src/i18n/ru/nav.ts @@ -1,7 +1,6 @@ import type { TranslationStrings } from '../types'; const nav: TranslationStrings = { - 'nav.trip': 'Поездка', 'nav.settings': 'Настройки', 'nav.admin': 'Администрирование', 'nav.logout': 'Выйти', diff --git a/shared/src/i18n/ru/tours.ts b/shared/src/i18n/ru/tours.ts index b5f7c9fc5..70ed4aeb4 100644 --- a/shared/src/i18n/ru/tours.ts +++ b/shared/src/i18n/ru/tours.ts @@ -209,8 +209,6 @@ const tours: TranslationStrings = { 'tours.planner.inspector.profileFocus': 'На расстоянии {distance}: {elevation}', 'tours.planner.inspector.elevationPlaceholder': 'Профиль появится, когда будут доступны данные о высоте маршрута.', 'tours.planner.gpxNoGeometry': 'Геометрия маршрута недоступна для этого импорта GPX.', - 'tours.planner.mobileHint': - 'Планирование туров доступно на планшете и компьютере. Здесь можно просматривать сохранённые туры и назначать их на дни.', 'tourTypes.hike': 'Пеший поход', 'tourTypes.bike': 'Велотур', 'tourTypes.run': 'Пробежка', diff --git a/shared/src/i18n/sk/admin.ts b/shared/src/i18n/sk/admin.ts index 67b6bc1fb..8f0d92774 100644 --- a/shared/src/i18n/sk/admin.ts +++ b/shared/src/i18n/sk/admin.ts @@ -695,6 +695,8 @@ const admin: TranslationStrings = { 'admin.addons.catalog.roadtrip.name': 'Cesta autom', 'admin.addons.catalog.roadtrip.description': 'Plánujte jazdy so zastávkami na trase, časmi jazdy a časmi príchodu, ktoré sa aktualizujú samy', + 'admin.addons.catalog.tours.name': 'Túry', + 'admin.addons.catalog.tours.description': 'Pešie, cyklo a ďalšie túry, ktoré vedú po trase', 'admin.addons.catalog.memories.name': 'Fotky (Immich)', 'admin.addons.catalog.memories.description': 'Zdieľajte cestovné fotky cez svoju inštanciu Immich', 'admin.addons.catalog.mcp.name': 'MCP', diff --git a/shared/src/i18n/sk/mobileTrip.ts b/shared/src/i18n/sk/mobileTrip.ts index f636247d1..870c70d1c 100644 --- a/shared/src/i18n/sk/mobileTrip.ts +++ b/shared/src/i18n/sk/mobileTrip.ts @@ -59,7 +59,12 @@ const mobileTrip: TranslationStrings = { 'mobileTrip.rtInfoTitle': 'Údaje o jazde', 'mobileTrip.rtDesktopNote': 'Tieto údaje sa nastavujú na počítači, kde môžete tiež porovnávať alternatívne trasy a nechať deň sledovať importovanú stopu.', - 'mobileTrip.rtPlanOnDesktop': 'Plánovanie prebieha na počítači. Len čo má deň dve miesta, TREK jazdu vypočíta.', + 'mobileTrip.planOnDesktop': 'Plánuje sa na počítači', + 'mobileTrip.rtDriveOnTwoPlaces': 'Len čo má deň dve miesta, TREK jazdu vypočíta.', + 'mobileTrip.toursEmptyBody': 'Uložené túry sa zobrazia tu a môžeš ich pridať k dňu.', + 'mobileTrip.toursCount': '{count} túr', + 'mobileTrip.toursCount.one': '{count} túra', + 'mobileTrip.toursCount.few': '{count} túry', 'mobileTrip.rtSearchOffline': 'Vyžaduje pripojenie: hľadanie číta trasu pred vami.', 'mobileTrip.rtBehind': 'o {time} za plánom', 'mobileTrip.rtStart': 'Začiatok', diff --git a/shared/src/i18n/sk/nav.ts b/shared/src/i18n/sk/nav.ts index 479624e2c..a19751b33 100644 --- a/shared/src/i18n/sk/nav.ts +++ b/shared/src/i18n/sk/nav.ts @@ -1,7 +1,6 @@ import type { TranslationStrings } from '../types'; const nav: TranslationStrings = { - 'nav.trip': 'Cesta', 'nav.settings': 'Nastavenia', 'nav.admin': 'Správa', 'nav.logout': 'Odhlásiť sa', diff --git a/shared/src/i18n/sk/tours.ts b/shared/src/i18n/sk/tours.ts index a7c8abd5f..6ee838d13 100644 --- a/shared/src/i18n/sk/tours.ts +++ b/shared/src/i18n/sk/tours.ts @@ -199,8 +199,6 @@ const tours: TranslationStrings = { 'tours.planner.inspector.profileFocus': 'Vo vzdialenosti {distance}: {elevation}', 'tours.planner.inspector.elevationPlaceholder': 'Profil sa zobrazí, keď budú dostupné výškové údaje trasy.', 'tours.planner.gpxNoGeometry': 'Geometria trasy nie je pre tento import GPX dostupná.', - 'tours.planner.mobileHint': - 'Plánovanie túr je dostupné na tablete a počítači. Tu si môžete uložené túry prezerať a priraďovať k dňom.', 'tourTypes.hike': 'Pešia túra', 'tourTypes.bike': 'Cyklotúra', 'tourTypes.run': 'Beh', diff --git a/shared/src/i18n/sv/admin.ts b/shared/src/i18n/sv/admin.ts index f0c0699d7..8a96d9eee 100644 --- a/shared/src/i18n/sv/admin.ts +++ b/shared/src/i18n/sv/admin.ts @@ -694,6 +694,8 @@ const admin: TranslationStrings = { 'admin.addons.catalog.roadtrip.name': 'Bilresa', 'admin.addons.catalog.roadtrip.description': 'Planera körningar med stopp – körtider och ankomsttider räknas om automatiskt', + 'admin.addons.catalog.tours.name': 'Turer', + 'admin.addons.catalog.tours.description': 'Vandringar, cykelturer och andra turer längs en rutt', 'admin.addons.catalog.memories.name': 'Foton (Immich)', 'admin.addons.catalog.memories.description': 'Dela resefoton via din Immich-instans', 'admin.addons.catalog.mcp.name': 'MCP', diff --git a/shared/src/i18n/sv/mobileTrip.ts b/shared/src/i18n/sv/mobileTrip.ts index 9037eecc8..2043c9e91 100644 --- a/shared/src/i18n/sv/mobileTrip.ts +++ b/shared/src/i18n/sv/mobileTrip.ts @@ -56,7 +56,11 @@ const mobileTrip: TranslationStrings = { 'mobileTrip.rtInfoTitle': 'Kördata', 'mobileTrip.rtDesktopNote': 'De här värdena ställer du in på datorn. Där kan du också jämföra alternativa vägar och låta en dag följa ett importerat spår.', - 'mobileTrip.rtPlanOnDesktop': 'Planeringen sker på datorn. TREK räknar ut körningen så snart en dag har två platser.', + 'mobileTrip.planOnDesktop': 'Planeras på datorn', + 'mobileTrip.rtDriveOnTwoPlaces': 'TREK räknar ut körningen så snart en dag har två platser.', + 'mobileTrip.toursEmptyBody': 'Sparade turer visas här och kan läggas till en dag.', + 'mobileTrip.toursCount': '{count} turer', + 'mobileTrip.toursCount.one': '{count} tur', 'mobileTrip.rtSearchOffline': 'Kräver anslutning: sökningen läser rutten som ligger framför dig.', 'mobileTrip.rtBehind': '{time} efter planen', 'mobileTrip.rtStart': 'Start', diff --git a/shared/src/i18n/sv/nav.ts b/shared/src/i18n/sv/nav.ts index 5d9dfa7e5..e10744b72 100644 --- a/shared/src/i18n/sv/nav.ts +++ b/shared/src/i18n/sv/nav.ts @@ -1,7 +1,6 @@ import type { TranslationStrings } from '../types'; const nav: TranslationStrings = { - 'nav.trip': 'Resa', 'nav.settings': 'Inställningar', 'nav.admin': 'Admin', 'nav.logout': 'Logga ut', diff --git a/shared/src/i18n/sv/tours.ts b/shared/src/i18n/sv/tours.ts index 711185ece..cdc7b4252 100644 --- a/shared/src/i18n/sv/tours.ts +++ b/shared/src/i18n/sv/tours.ts @@ -194,8 +194,6 @@ const tours: TranslationStrings = { 'tours.planner.inspector.profileFocus': 'Vid {distance}: {elevation}', 'tours.planner.inspector.elevationPlaceholder': 'Profilen visas när höjddata för rutten finns tillgängliga.', 'tours.planner.gpxNoGeometry': 'Ruttgeometrin är inte tillgänglig för den här GPX-importen.', - 'tours.planner.mobileHint': - 'Turplanering är tillgänglig på surfplatta och dator. Här kan du visa sparade turer och tilldela dem till en dag.', 'tourTypes.hike': 'Vandring', 'tourTypes.bike': 'Cykeltur', 'tourTypes.run': 'Löptur', diff --git a/shared/src/i18n/th/admin.ts b/shared/src/i18n/th/admin.ts index 09c8243db..7619134a3 100644 --- a/shared/src/i18n/th/admin.ts +++ b/shared/src/i18n/th/admin.ts @@ -816,6 +816,8 @@ const admin: TranslationStrings = { 'admin.addons.catalog.roadtrip.name': 'โรดทริป', 'admin.addons.catalog.roadtrip.description': 'วางแผนการขับรถด้วยจุดแวะตามเส้นทาง เวลาเดินทาง และเวลาไปถึงที่อัปเดตเอง', + 'admin.addons.catalog.tours.name': 'ทัวร์', + 'admin.addons.catalog.tours.description': 'เดินป่า ปั่นจักรยาน และทัวร์อื่นๆ ที่ไปตามเส้นทาง', 'admin.addons.catalog.dawarich.name': 'Dawarich', 'admin.addons.catalog.dawarich.description': 'อ่านการเยี่ยมชมและเส้นทางที่บันทึกจากอินสแตนซ์ Dawarich ที่ผู้อ่านแต่ละคนเชื่อมต่อเอง', diff --git a/shared/src/i18n/th/mobileTrip.ts b/shared/src/i18n/th/mobileTrip.ts index 9107082a1..3dfb43f2e 100644 --- a/shared/src/i18n/th/mobileTrip.ts +++ b/shared/src/i18n/th/mobileTrip.ts @@ -53,7 +53,10 @@ const mobileTrip: TranslationStrings = { 'mobileTrip.rtInfoTitle': 'ข้อมูลการขับรถ', 'mobileTrip.rtDesktopNote': 'กำหนดข้อมูลเหล่านี้บนเดสก์ท็อป ซึ่งคุณยังเปรียบเทียบเส้นทางอื่นและให้วันนั้นตามเส้นทางที่นำเข้าได้', - 'mobileTrip.rtPlanOnDesktop': 'การวางแผนทำบนเดสก์ท็อป TREK จะคำนวณการขับรถเมื่อวันหนึ่งมีสถานที่สองแห่ง', + 'mobileTrip.planOnDesktop': 'วางแผนบนคอมพิวเตอร์', + 'mobileTrip.rtDriveOnTwoPlaces': 'TREK จะคำนวณการขับรถเมื่อวันหนึ่งมีสถานที่สองแห่ง', + 'mobileTrip.toursEmptyBody': 'ทัวร์ที่บันทึกไว้จะแสดงที่นี่ และเพิ่มลงในวันได้', + 'mobileTrip.toursCount': '{count} ทัวร์', 'mobileTrip.rtSearchOffline': 'ต้องเชื่อมต่ออินเทอร์เน็ต: การค้นหาจะอ่านเส้นทางข้างหน้า', 'mobileTrip.rtBehind': 'ช้ากว่าแผน {time}', 'mobileTrip.rtStart': 'เริ่มต้น', diff --git a/shared/src/i18n/th/nav.ts b/shared/src/i18n/th/nav.ts index 93d588a32..131f3a81b 100644 --- a/shared/src/i18n/th/nav.ts +++ b/shared/src/i18n/th/nav.ts @@ -1,7 +1,6 @@ import type { TranslationStrings } from '../types'; const nav: TranslationStrings = { - 'nav.trip': 'การเดินทาง', 'nav.settings': 'การตั้งค่า', 'nav.admin': 'ผู้ดูแลระบบ', 'nav.logout': 'ออกจากระบบ', diff --git a/shared/src/i18n/th/tours.ts b/shared/src/i18n/th/tours.ts index d5ea1f71b..917b24f8e 100644 --- a/shared/src/i18n/th/tours.ts +++ b/shared/src/i18n/th/tours.ts @@ -187,7 +187,6 @@ const tours: TranslationStrings = { 'tours.planner.inspector.profileFocus': 'ที่ระยะ {distance}: {elevation}', 'tours.planner.inspector.elevationPlaceholder': 'กราฟจะแสดงเมื่อมีข้อมูลระดับความสูงของเส้นทาง', 'tours.planner.gpxNoGeometry': 'ไม่มีข้อมูลรูปทรงเส้นทางสำหรับทัวร์ที่นำเข้าจาก GPX นี้', - 'tours.planner.mobileHint': 'วางแผนทัวร์ได้บนแท็บเล็ตและคอมพิวเตอร์ ที่นี่สามารถดูทัวร์ที่บันทึกไว้และจัดลงวันได้', 'tourTypes.hike': 'เดินป่า', 'tourTypes.bike': 'ทัวร์จักรยาน', 'tourTypes.run': 'วิ่ง', diff --git a/shared/src/i18n/tours-locales.spec.ts b/shared/src/i18n/tours-locales.spec.ts index 9fb1b16b3..d765adc34 100644 --- a/shared/src/i18n/tours-locales.spec.ts +++ b/shared/src/i18n/tours-locales.spec.ts @@ -20,7 +20,7 @@ const toursModules = import.meta.glob<{ default: TranslationStrings }>('./*/tour const TOUR_DOMAINS: { file: string; generalKeyCount: number; modules: DomainModules }[] = [ { file: 'tours', - generalKeyCount: 1336, + generalKeyCount: 1335, modules: toursModules, }, { diff --git a/shared/src/i18n/tr/admin.ts b/shared/src/i18n/tr/admin.ts index 9e989d012..ad727fa31 100644 --- a/shared/src/i18n/tr/admin.ts +++ b/shared/src/i18n/tr/admin.ts @@ -679,6 +679,8 @@ const admin: TranslationStrings = { 'admin.addons.catalog.roadtrip.name': 'Yol gezisi', 'admin.addons.catalog.roadtrip.description': 'Duraklı sürüşler planlayın; sürüş ve varış saatleri kendiliğinden yeniden hesaplanır', + 'admin.addons.catalog.tours.name': 'Turlar', + 'admin.addons.catalog.tours.description': 'Bir rotayı izleyen yürüyüşler, bisiklet ve diğer turlar', 'admin.addons.catalog.memories.name': 'Fotoğraflar (Immich)', 'admin.addons.catalog.memories.description': 'Immich örneğiniz aracılığıyla gezi fotoğraflarını paylaşın', 'admin.addons.catalog.mcp.name': 'MCP', diff --git a/shared/src/i18n/tr/mobileTrip.ts b/shared/src/i18n/tr/mobileTrip.ts index 1afc28f25..b366137d5 100644 --- a/shared/src/i18n/tr/mobileTrip.ts +++ b/shared/src/i18n/tr/mobileTrip.ts @@ -56,7 +56,11 @@ const mobileTrip: TranslationStrings = { 'mobileTrip.rtInfoTitle': 'Sürüş değerleri', 'mobileTrip.rtDesktopNote': 'Bu değerler masaüstünde ayarlanır; orada başka yolları karşılaştırabilir ve bir günü içe aktarılmış bir izi takip ettirebilirsiniz.', - 'mobileTrip.rtPlanOnDesktop': 'Planlama masaüstünde yapılır. Bir günde iki yer olduğunda TREK sürüşü hesaplar.', + 'mobileTrip.planOnDesktop': 'Planlama bilgisayarda', + 'mobileTrip.rtDriveOnTwoPlaces': 'Bir günde iki yer olduğunda TREK sürüşü hesaplar.', + 'mobileTrip.toursEmptyBody': 'Kaydedilen turlar burada görünür ve bir güne eklenebilir.', + 'mobileTrip.toursCount': '{count} tur', + 'mobileTrip.toursCount.one': '{count} tur', 'mobileTrip.rtSearchOffline': 'Bağlantı gerekir: arama, ileride kalan rotayı okur.', 'mobileTrip.rtBehind': 'plandan {time} geride', 'mobileTrip.rtStart': 'Başlangıç', diff --git a/shared/src/i18n/tr/nav.ts b/shared/src/i18n/tr/nav.ts index 8c21d144d..a70c407bb 100644 --- a/shared/src/i18n/tr/nav.ts +++ b/shared/src/i18n/tr/nav.ts @@ -1,7 +1,6 @@ import type { TranslationStrings } from '../types'; const nav: TranslationStrings = { - 'nav.trip': 'Seyahat', 'nav.settings': 'Ayarlar', 'nav.admin': 'Yönetici', 'nav.logout': 'Çıkış yap', diff --git a/shared/src/i18n/tr/tours.ts b/shared/src/i18n/tr/tours.ts index 8a895bd18..5925d8162 100644 --- a/shared/src/i18n/tr/tours.ts +++ b/shared/src/i18n/tr/tours.ts @@ -195,8 +195,6 @@ const tours: TranslationStrings = { 'tours.planner.inspector.elevationPlaceholder': 'Rotanın yükseklik verileri kullanılabilir olduğunda profil gösterilir.', 'tours.planner.gpxNoGeometry': 'Bu GPX içe aktarımı için rota geometrisi kullanılamıyor.', - 'tours.planner.mobileHint': - 'Tur planlama tablet ve bilgisayarda kullanılabilir. Burada kaydedilmiş turları görüntüleyebilir ve günlere atayabilirsiniz.', 'tourTypes.hike': 'Doğa yürüyüşü', 'tourTypes.bike': 'Bisiklet turu', 'tourTypes.run': 'Koşu', diff --git a/shared/src/i18n/uk/admin.ts b/shared/src/i18n/uk/admin.ts index 001042bd6..1f6bafccd 100644 --- a/shared/src/i18n/uk/admin.ts +++ b/shared/src/i18n/uk/admin.ts @@ -647,6 +647,8 @@ const admin: TranslationStrings = { 'admin.addons.catalog.roadtrip.name': 'Автоподорож', 'admin.addons.catalog.roadtrip.description': 'Плануйте поїздки із зупинками — час у дорозі та прибуття перераховуються автоматично', + 'admin.addons.catalog.tours.name': 'Тури', + 'admin.addons.catalog.tours.description': 'Походи, велопрогулянки та інші тури за маршрутом', 'admin.addons.subtitleBefore': 'Увімкніть або вимкніть функції для налаштування ', 'admin.addons.subtitleAfter': ' під себе.', 'admin.addons.catalog.naver_list_import.name': 'Naver List Import', diff --git a/shared/src/i18n/uk/mobileTrip.ts b/shared/src/i18n/uk/mobileTrip.ts index ba125b199..c5e4a4d24 100644 --- a/shared/src/i18n/uk/mobileTrip.ts +++ b/shared/src/i18n/uk/mobileTrip.ts @@ -62,8 +62,13 @@ const mobileTrip: TranslationStrings = { 'mobileTrip.rtInfoTitle': 'Дані про водіння', 'mobileTrip.rtDesktopNote': 'Ці дані задаються на комп’ютері: там само можна порівнювати інші маршрути та провести день за імпортованим треком.', - 'mobileTrip.rtPlanOnDesktop': - 'Планування відбувається на комп’ютері. TREK розрахує маршрут, щойно в дні буде два місця.', + 'mobileTrip.planOnDesktop': 'Планування на комп’ютері', + 'mobileTrip.rtDriveOnTwoPlaces': 'TREK розрахує маршрут, щойно в дні буде два місця.', + 'mobileTrip.toursEmptyBody': 'Збережені тури з’являться тут, їх можна додати до дня.', + 'mobileTrip.toursCount': '{count} тури', + 'mobileTrip.toursCount.one': '{count} тур', + 'mobileTrip.toursCount.few': '{count} тури', + 'mobileTrip.toursCount.many': '{count} турів', 'mobileTrip.rtSearchOffline': 'Потрібне з’єднання: пошук зчитує маршрут попереду.', 'mobileTrip.rtBehind': 'на {time} пізніше плану', 'mobileTrip.rtStart': 'Початок', diff --git a/shared/src/i18n/uk/nav.ts b/shared/src/i18n/uk/nav.ts index 60bfbae46..46e95409a 100644 --- a/shared/src/i18n/uk/nav.ts +++ b/shared/src/i18n/uk/nav.ts @@ -1,7 +1,6 @@ import type { TranslationStrings } from '../types'; const nav: TranslationStrings = { - 'nav.trip': 'Поїздка', 'nav.settings': 'Налаштування', 'nav.admin': 'Адмін', 'nav.logout': 'Вийти', diff --git a/shared/src/i18n/uk/tours.ts b/shared/src/i18n/uk/tours.ts index fcded912a..b10594b01 100644 --- a/shared/src/i18n/uk/tours.ts +++ b/shared/src/i18n/uk/tours.ts @@ -206,8 +206,6 @@ const tours: TranslationStrings = { 'tours.planner.inspector.profileFocus': 'На відстані {distance}: {elevation}', 'tours.planner.inspector.elevationPlaceholder': 'Профіль з’явиться, коли будуть доступні дані про висоту маршруту.', 'tours.planner.gpxNoGeometry': 'Геометрія маршруту недоступна для цього імпорту GPX.', - 'tours.planner.mobileHint': - 'Планування турів доступне на планшеті й комп’ютері. Тут можна переглядати збережені тури та призначати їх на дні.', 'tourTypes.hike': 'Піший похід', 'tourTypes.bike': 'Велотур', 'tourTypes.run': 'Пробіжка', diff --git a/shared/src/i18n/vi/admin.ts b/shared/src/i18n/vi/admin.ts index 51a9e8c8e..bb39fafe1 100644 --- a/shared/src/i18n/vi/admin.ts +++ b/shared/src/i18n/vi/admin.ts @@ -685,6 +685,8 @@ const admin: TranslationStrings = { 'admin.addons.catalog.collab.description': 'Ghi chú, bình chọn, trò chuyện và gợi ý để cùng lên kế hoạch', 'admin.addons.catalog.roadtrip.name': 'Chuyến đi đường bộ', 'admin.addons.catalog.roadtrip.description': 'Lên kế hoạch lái xe có điểm dừng; thời gian lái và giờ đến tự tính lại', + 'admin.addons.catalog.tours.name': 'Chuyến tham quan', + 'admin.addons.catalog.tours.description': 'Đi bộ đường dài, đạp xe và các chuyến đi theo lộ trình', 'admin.addons.catalog.memories.name': 'Hình ảnh (Immich)', 'admin.addons.catalog.memories.description': 'Chia sẻ ảnh chuyến đi qua phiên bản Immich của bạn', 'admin.addons.catalog.mcp.name': 'MCP', diff --git a/shared/src/i18n/vi/mobileTrip.ts b/shared/src/i18n/vi/mobileTrip.ts index f7aa42dbe..3801dd792 100644 --- a/shared/src/i18n/vi/mobileTrip.ts +++ b/shared/src/i18n/vi/mobileTrip.ts @@ -53,8 +53,10 @@ const mobileTrip: TranslationStrings = { 'mobileTrip.rtInfoTitle': 'Số liệu lái xe', 'mobileTrip.rtDesktopNote': 'Các số liệu này được đặt trên máy tính, nơi bạn cũng có thể so sánh các lộ trình khác và cho một ngày đi theo tuyến đã nhập.', - 'mobileTrip.rtPlanOnDesktop': - 'Việc lên kế hoạch diễn ra trên máy tính. TREK tính chặng lái xe ngay khi một ngày có hai địa điểm.', + 'mobileTrip.planOnDesktop': 'Lập kế hoạch trên máy tính', + 'mobileTrip.rtDriveOnTwoPlaces': 'TREK tính chặng lái xe ngay khi một ngày có hai địa điểm.', + 'mobileTrip.toursEmptyBody': 'Các chuyến tham quan đã lưu hiện ở đây, sẵn sàng thêm vào một ngày.', + 'mobileTrip.toursCount': '{count} chuyến tham quan', 'mobileTrip.rtSearchOffline': 'Cần có kết nối: việc tìm kiếm đọc lộ trình phía trước.', 'mobileTrip.rtBehind': 'chậm {time} so với kế hoạch', 'mobileTrip.rtStart': 'Bắt đầu', diff --git a/shared/src/i18n/vi/nav.ts b/shared/src/i18n/vi/nav.ts index a463c3c32..050f9c59b 100644 --- a/shared/src/i18n/vi/nav.ts +++ b/shared/src/i18n/vi/nav.ts @@ -1,7 +1,6 @@ import type { TranslationStrings } from '../types'; const nav: TranslationStrings = { - 'nav.trip': 'Chuyến đi', 'nav.settings': 'Cài đặt', 'nav.admin': 'Quản trị viên', 'nav.logout': 'Đăng xuất', diff --git a/shared/src/i18n/vi/tours.ts b/shared/src/i18n/vi/tours.ts index ff4d14d84..dd7cc8e62 100644 --- a/shared/src/i18n/vi/tours.ts +++ b/shared/src/i18n/vi/tours.ts @@ -195,8 +195,6 @@ const tours: TranslationStrings = { 'tours.planner.inspector.profileFocus': 'Tại {distance}: {elevation}', 'tours.planner.inspector.elevationPlaceholder': 'Biểu đồ sẽ hiển thị khi có dữ liệu độ cao của tuyến đường.', 'tours.planner.gpxNoGeometry': 'Không có dữ liệu hình dạng tuyến đường cho bản nhập GPX này.', - 'tours.planner.mobileHint': - 'Có thể lập kế hoạch tham quan trên máy tính bảng và máy tính. Tại đây, bạn có thể xem và xếp lịch cho các chuyến tham quan đã lưu.', 'tourTypes.hike': 'Đi bộ đường dài', 'tourTypes.bike': 'Chuyến đạp xe', 'tourTypes.run': 'Chạy bộ', diff --git a/shared/src/i18n/zh-TW/admin.ts b/shared/src/i18n/zh-TW/admin.ts index d6038564f..52587c19a 100644 --- a/shared/src/i18n/zh-TW/admin.ts +++ b/shared/src/i18n/zh-TW/admin.ts @@ -615,6 +615,8 @@ const admin: TranslationStrings = { 'admin.addons.catalog.collab.description': '備註、投票、聊天與建議,方便一起規劃', 'admin.addons.catalog.roadtrip.name': '公路旅行', 'admin.addons.catalog.roadtrip.description': '規劃含停靠點的駕駛行程,駕駛時間與抵達時間會自動重算', + 'admin.addons.catalog.tours.name': '遊程', + 'admin.addons.catalog.tours.description': '沿路線進行的健行、騎行等遊程', 'admin.addons.subtitleBefore': '啟用或停用功能以自定義你的 ', 'admin.addons.subtitleAfter': ' 體驗。', 'admin.addons.catalog.naver_list_import.name': 'Naver List Import', diff --git a/shared/src/i18n/zh-TW/mobileTrip.ts b/shared/src/i18n/zh-TW/mobileTrip.ts index 4fad7e3aa..2ce4419cf 100644 --- a/shared/src/i18n/zh-TW/mobileTrip.ts +++ b/shared/src/i18n/zh-TW/mobileTrip.ts @@ -52,7 +52,10 @@ const mobileTrip: TranslationStrings = { 'mobileTrip.transportsEmpty': '暫無交通安排', 'mobileTrip.rtInfoTitle': '駕駛數據', 'mobileTrip.rtDesktopNote': '這些數據在電腦上設定,在那裡也可以比較其他路線,以及讓某一天沿匯入的軌跡行駛。', - 'mobileTrip.rtPlanOnDesktop': '規劃在電腦上進行。當一天有兩個地點時,TREK 就會算出車程。', + 'mobileTrip.planOnDesktop': '請在電腦上規劃', + 'mobileTrip.rtDriveOnTwoPlaces': '當一天有兩個地點時,TREK 就會算出車程。', + 'mobileTrip.toursEmptyBody': '已儲存的遊程會顯示在這裡,可加入某一天。', + 'mobileTrip.toursCount': '{count} 個遊程', 'mobileTrip.rtSearchOffline': '需要連線:搜尋會讀取前方的路線。', 'mobileTrip.rtBehind': '比計畫晚 {time}', 'mobileTrip.rtStart': '開始', diff --git a/shared/src/i18n/zh-TW/nav.ts b/shared/src/i18n/zh-TW/nav.ts index f42b98828..d6060bed1 100644 --- a/shared/src/i18n/zh-TW/nav.ts +++ b/shared/src/i18n/zh-TW/nav.ts @@ -1,7 +1,6 @@ import type { TranslationStrings } from '../types'; const nav: TranslationStrings = { - 'nav.trip': '旅行', 'nav.settings': '設定', 'nav.admin': '管理', 'nav.logout': '退出登入', diff --git a/shared/src/i18n/zh-TW/tours.ts b/shared/src/i18n/zh-TW/tours.ts index 94c8dcb10..82f070f0b 100644 --- a/shared/src/i18n/zh-TW/tours.ts +++ b/shared/src/i18n/zh-TW/tours.ts @@ -180,7 +180,6 @@ const tours: TranslationStrings = { 'tours.planner.inspector.profileFocus': '距離 {distance} 處:{elevation}', 'tours.planner.inspector.elevationPlaceholder': '取得路線的海拔資料後,會顯示剖面圖。', 'tours.planner.gpxNoGeometry': '這次 GPX 匯入沒有可用的路線幾何資料。', - 'tours.planner.mobileHint': '可在平板電腦與電腦上規劃遊程。在這裡可以檢視已儲存的遊程並安排日期。', 'tourTypes.hike': '健行', 'tourTypes.bike': '單車遊程', 'tourTypes.run': '跑步', diff --git a/shared/src/i18n/zh/admin.ts b/shared/src/i18n/zh/admin.ts index 886ec6e2d..b7dc974ff 100644 --- a/shared/src/i18n/zh/admin.ts +++ b/shared/src/i18n/zh/admin.ts @@ -565,6 +565,8 @@ const admin: TranslationStrings = { 'admin.addons.catalog.collab.description': '备忘、投票、聊天和建议,方便一起规划', 'admin.addons.catalog.roadtrip.name': '公路旅行', 'admin.addons.catalog.roadtrip.description': '规划带停靠点的驾驶行程,驾驶时间和到达时间会自动重算', + 'admin.addons.catalog.tours.name': '游览', + 'admin.addons.catalog.tours.description': '沿路线进行的徒步、骑行等游览', 'admin.addons.subtitleBefore': '启用或禁用功能以自定义你的 ', 'admin.addons.subtitleAfter': ' 体验。', 'admin.addons.catalog.naver_list_import.name': 'Naver List Import', diff --git a/shared/src/i18n/zh/mobileTrip.ts b/shared/src/i18n/zh/mobileTrip.ts index 6793f57f7..a3df1ef46 100644 --- a/shared/src/i18n/zh/mobileTrip.ts +++ b/shared/src/i18n/zh/mobileTrip.ts @@ -52,7 +52,10 @@ const mobileTrip: TranslationStrings = { 'mobileTrip.transportsEmpty': '暂无交通安排', 'mobileTrip.rtInfoTitle': '驾驶数据', 'mobileTrip.rtDesktopNote': '这些数据在桌面端设置。在桌面端还可以比较其他路线走法,并让某一天沿导入的轨迹行驶。', - 'mobileTrip.rtPlanOnDesktop': '规划在桌面端进行。一天里有了两个地点,TREK 就会算出这段车程。', + 'mobileTrip.planOnDesktop': '请在电脑上规划', + 'mobileTrip.rtDriveOnTwoPlaces': '一天里有了两个地点,TREK 就会算出这段车程。', + 'mobileTrip.toursEmptyBody': '已保存的游览会显示在这里,可添加到某一天。', + 'mobileTrip.toursCount': '{count} 个游览', 'mobileTrip.rtSearchOffline': '需要联网:搜索会读取前方的路线。', 'mobileTrip.rtBehind': '比计划晚 {time}', 'mobileTrip.rtStart': '开始', diff --git a/shared/src/i18n/zh/nav.ts b/shared/src/i18n/zh/nav.ts index b67fb1097..7c9e16264 100644 --- a/shared/src/i18n/zh/nav.ts +++ b/shared/src/i18n/zh/nav.ts @@ -1,7 +1,6 @@ import type { TranslationStrings } from '../types'; const nav: TranslationStrings = { - 'nav.trip': '旅行', 'nav.settings': '设置', 'nav.admin': '管理', 'nav.logout': '退出登录', diff --git a/shared/src/i18n/zh/tours.ts b/shared/src/i18n/zh/tours.ts index 4f6b68cd3..2979e7bf6 100644 --- a/shared/src/i18n/zh/tours.ts +++ b/shared/src/i18n/zh/tours.ts @@ -181,7 +181,6 @@ const tours: TranslationStrings = { 'tours.planner.inspector.profileFocus': '距离 {distance} 处:{elevation}', 'tours.planner.inspector.elevationPlaceholder': '获取到路线海拔数据后将显示剖面图。', 'tours.planner.gpxNoGeometry': '本次 GPX 导入没有可用的路线几何数据。', - 'tours.planner.mobileHint': '可在平板电脑和电脑上规划游览路线。在这里可以查看已保存的游览活动并安排日期。', 'tourTypes.hike': '徒步', 'tourTypes.bike': '骑行', 'tourTypes.run': '跑步',