mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-13 07:40:59 +00:00
feat(mobile): page transitions, pull-to-refresh, share sheet and haptics
Page transitions: on the phone, pages move like a native navigation stack, built on the View Transitions API (mobile/routeTransition.ts). A page opened from another slides in from the trailing edge, back slides it away, and switching between the dock's sections cross-fades while the dock stands still. Lazy routes do not flash their skeleton: the swap waits, behind the frozen snapshot, until the new page rendered. The edge-swipe back gesture already moves the page and skips the transition. RTL mirrors the motion, reduced motion and desktop get none. Pull-to-refresh on the mobile dashboard: the page follows the finger with resistance, ticks once past the threshold and re-reads trips and notifications quietly, without a loading state. Share: trip share and invite links and the journey link get a share button next to copy, opening the system share sheet (Capacitor Share in the apps, Web Share in browsers that have it). Haptics: a selection click for switches and segmented controls, a tick when a sheet is flung closed, and a firmer bump for the chat's long press. Theme lint: this branch's new screens use the type and z-index tokens, the Vacay company tint is a token, and the baseline is lowered to the new counts.
This commit is contained in:
@@ -125,7 +125,6 @@
|
||||
"components/shared/CustomSelect.tsx": 1,
|
||||
"components/shared/CustomTimePicker.tsx": 1,
|
||||
"components/shared/EmptyState.tsx": 1,
|
||||
"components/shared/ErrorBoundary.tsx": 2,
|
||||
"components/shared/Modal.tsx": 3,
|
||||
"components/shared/NavigationMenu.tsx": 1,
|
||||
"components/shared/NoteColorPicker.tsx": 2,
|
||||
@@ -166,7 +165,7 @@
|
||||
"mobile/screens/admin/MAdminUi.tsx": 10,
|
||||
"mobile/screens/admin/MAdminUsersSection.tsx": 14,
|
||||
"mobile/screens/admin/MApiProviderBlocks.tsx": 10,
|
||||
"mobile/screens/atlas/MAtlas.tsx": 7,
|
||||
"mobile/screens/atlas/MAtlas.tsx": 5,
|
||||
"mobile/screens/atlas/MAtlasBucketSheet.tsx": 16,
|
||||
"mobile/screens/atlas/MAtlasCountryPopup.tsx": 11,
|
||||
"mobile/screens/atlas/MAtlasSearch.tsx": 5,
|
||||
@@ -219,11 +218,10 @@
|
||||
"mobile/screens/trip/places/MPlacesBrowser.tsx": 10,
|
||||
"mobile/screens/trip/places/MPlacesBulkCategorySheet.tsx": 4,
|
||||
"mobile/screens/trip/places/MPlacesFilterControls.tsx": 1,
|
||||
"mobile/screens/trip/places/MPlacesFilterSheet.tsx": 3,
|
||||
"mobile/screens/trip/places/MPlacesSaveToCollectionSheet.tsx": 7,
|
||||
"mobile/screens/trip/places/MPlacesToursModeSwitch.tsx": 1,
|
||||
"mobile/screens/trip/plan/MPlanTimeline.tsx": 14,
|
||||
"mobile/screens/trip/plan/MPlanTimelineRows.tsx": 26,
|
||||
"mobile/screens/trip/plan/MPlanTimeline.tsx": 12,
|
||||
"mobile/screens/trip/plan/MPlanTimelineRows.tsx": 24,
|
||||
"mobile/screens/trip/roadtrip/MRoadtripRows.tsx": 31,
|
||||
"mobile/screens/trip/roadtrip/MRoadtripTab.tsx": 16,
|
||||
"mobile/screens/trip/roadtrip/MRtAlternativesBar.tsx": 8,
|
||||
@@ -281,7 +279,7 @@
|
||||
"mobile/screens/trip/tabs/MTransportsTab.tsx": 2,
|
||||
"mobile/screens/trip/tabs/filesModel.ts": 5,
|
||||
"mobile/screens/trip/tabs/tabChrome.tsx": 12,
|
||||
"mobile/screens/vacay/MVacay.tsx": 23,
|
||||
"mobile/screens/vacay/MVacay.tsx": 17,
|
||||
"mobile/screens/vacay/MVacayInviteSheet.tsx": 9,
|
||||
"mobile/screens/vacay/MVacayMonth.tsx": 3,
|
||||
"mobile/screens/vacay/MVacaySettingsSheet.tsx": 35,
|
||||
|
||||
+6
-2
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect, useState, useRef, ReactNode, Suspense } from 'react'
|
||||
import { Routes, Route, Navigate, useLocation } from 'react-router'
|
||||
import { Routes, Route, Navigate, useLocation, useNavigationType } from 'react-router'
|
||||
import { useAuthStore } from './store/authStore'
|
||||
import { useSettingsStore } from './store/settingsStore'
|
||||
import { applyAppearance } from './theme/applyAppearance'
|
||||
@@ -21,6 +21,7 @@ import { lazyWithRetry } from './utils/lazyWithRetry'
|
||||
import { reconcileAppVersion } from './utils/versionHandover'
|
||||
import { useServerVersionStore } from './store/serverVersionStore'
|
||||
import { useIsPhone } from './mobile/useIsPhone'
|
||||
import { useRouteTransition } from './mobile/routeTransition'
|
||||
import { TranslationProvider, useTranslation } from './i18n'
|
||||
import { authApi, isAuthPublicPath } from './api/client'
|
||||
import { tripRepo } from './repo/tripRepo'
|
||||
@@ -411,6 +412,9 @@ export default function App() {
|
||||
}, [settings.dark_mode, settings.appearance, isSharedPage])
|
||||
|
||||
const isPhone = useIsPhone()
|
||||
// On the phone, pages slide like a native navigation stack (mobile/routeTransition.ts).
|
||||
const navigationType = useNavigationType()
|
||||
const routedLocation = useRouteTransition(location, navigationType, isPhone)
|
||||
const keepingTour = useSaveToCollectionStore(s => s.tour !== null)
|
||||
const isAuthPage = location.pathname.startsWith('/login')
|
||||
|| location.pathname.startsWith('/register')
|
||||
@@ -455,7 +459,7 @@ export default function App() {
|
||||
so a mounted boundary keeps the current page on screen instead of flashing
|
||||
a spinner on every jump — the spinner is for the first paint of a deep link. */}
|
||||
<Suspense fallback={<RouteFallback />}>
|
||||
<Routes>
|
||||
<Routes location={routedLocation}>
|
||||
<Route path="/" element={<RootRedirect />} />
|
||||
<Route path="/login" element={<PublicRoute redirectAuthed><LoginPage /></PublicRoute>} />
|
||||
<Route path="/shared/:token" element={<PublicRoute><SharedTripPage /></PublicRoute>} />
|
||||
|
||||
@@ -62,7 +62,7 @@ export default function AppLockGate(): React.ReactElement | null {
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t('native.lock.title')}
|
||||
className="fixed inset-0 z-[100001] flex flex-col items-center justify-center gap-5 bg-surface p-8 text-center backdrop-blur-2xl"
|
||||
className="fixed inset-0 z-[calc(var(--z-toast)+1)] flex flex-col items-center justify-center gap-5 bg-surface p-8 text-center backdrop-blur-2xl"
|
||||
>
|
||||
<span className="flex h-16 w-16 items-center justify-center rounded-full bg-surface-card text-content shadow-lg">
|
||||
<Lock size={26} strokeWidth={2} aria-hidden="true" />
|
||||
|
||||
@@ -53,9 +53,11 @@ export default function NativeImportSheet(): React.ReactElement | null {
|
||||
return (
|
||||
<MSheet open={open} onClose={() => setFile(null)} variant="bottom" ariaLabel={t('native.share.title')}>
|
||||
<div className="px-4 pb-4 pt-6">
|
||||
<div className="mb-1 px-1 text-[0.8125rem] font-bold text-m-ink">{t('native.share.title')}</div>
|
||||
<div className="mb-1 px-1 text-[length:calc(13px*var(--fs-scale-body,1))] font-bold text-m-ink">
|
||||
{t('native.share.title')}
|
||||
</div>
|
||||
{file && (
|
||||
<div className="mb-3 flex items-center gap-2 px-1 font-geist text-[0.6875rem] text-m-muted">
|
||||
<div className="mb-3 flex items-center gap-2 px-1 font-geist text-[length:calc(11px*var(--fs-scale-body,1))] text-m-muted">
|
||||
<FileText size={13} aria-hidden="true" />
|
||||
<span className="truncate">{file.name}</span>
|
||||
</div>
|
||||
@@ -68,11 +70,17 @@ export default function NativeImportSheet(): React.ReactElement | null {
|
||||
onClick={() => pick(trip.id)}
|
||||
className="flex items-center gap-3 rounded-[16px] border border-[color:var(--m-gbr)] bg-[color:var(--m-glass)] px-4 py-3 text-start transition-transform active:scale-[.98]"
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate text-[0.875rem] font-bold text-m-ink">{trip.title}</span>
|
||||
<span className="min-w-0 flex-1 truncate text-[length:calc(14px*var(--fs-scale-body,1))] font-bold text-m-ink">
|
||||
{trip.title}
|
||||
</span>
|
||||
<ChevronRight size={16} className="flex-none text-m-faint" aria-hidden="true" />
|
||||
</button>
|
||||
))}
|
||||
{trips.length === 0 && <p className="px-1 text-[0.75rem] text-m-muted">{t('native.share.noTrips')}</p>}
|
||||
{trips.length === 0 && (
|
||||
<p className="px-1 text-[length:calc(12px*var(--fs-scale-body,1))] text-m-muted">
|
||||
{t('native.share.noTrips')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</MSheet>
|
||||
|
||||
@@ -26,7 +26,7 @@ export default function NativeUpdateNotice(): React.ReactElement | null {
|
||||
<div
|
||||
role="status"
|
||||
style={{ bottom: 'calc(var(--bottom-nav-h) + 56px)' }}
|
||||
className="fixed inset-x-4 z-[9999] flex items-center gap-3 rounded-2xl border border-edge bg-surface-elevated px-4 py-3 text-content shadow-lg"
|
||||
className="fixed inset-x-4 z-[var(--z-notice)] flex items-center gap-3 rounded-2xl border border-edge bg-surface-elevated px-4 py-3 text-content shadow-lg"
|
||||
>
|
||||
<Download size={18} className="shrink-0 text-content-muted" aria-hidden="true" />
|
||||
<div className="min-w-0 flex-1">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from 'react'
|
||||
import { selectionHaptic } from '../../native/haptics'
|
||||
|
||||
/** The track and the knob glide with the same easing the planner's panels use. */
|
||||
const EASE = 'cubic-bezier(0.32, 0.72, 0, 1)'
|
||||
@@ -10,7 +11,7 @@ const EASE = 'cubic-bezier(0.32, 0.72, 0, 1)'
|
||||
*/
|
||||
export default function ToggleSwitch({ on, onToggle, label, describedBy, disabled = false }: { on: boolean; onToggle: () => void; label?: string; describedBy?: string; disabled?: boolean }) {
|
||||
return (
|
||||
<button type="button" onClick={onToggle} aria-pressed={on} aria-label={label} aria-describedby={describedBy}
|
||||
<button type="button" onClick={() => { selectionHaptic(); onToggle() }} aria-pressed={on} aria-label={label} aria-describedby={describedBy}
|
||||
disabled={disabled} aria-disabled={disabled || undefined}
|
||||
// A keyboard focus ring in the text colour, like the fields; the pointer gets none.
|
||||
className="group outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--text-primary)] focus-visible:ring-offset-2 focus-visible:ring-offset-[color:var(--bg-card)]"
|
||||
|
||||
@@ -120,7 +120,7 @@ export default function MBottomNav() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<nav className="fixed inset-x-4 z-40 flex h-[62px] items-center rounded-[31px] border border-[color:var(--m-gbr)] bg-[color:var(--m-glass)] px-3 shadow-[0_16px_44px_-14px_rgba(0,0,0,.35)] backdrop-blur-[30px] backdrop-saturate-[1.8] bottom-[calc(env(safe-area-inset-bottom,0px)+12px)]">
|
||||
<nav className="m-dock-vt fixed inset-x-4 z-40 flex h-[62px] items-center rounded-[31px] border border-[color:var(--m-gbr)] bg-[color:var(--m-glass)] px-3 shadow-[0_16px_44px_-14px_rgba(0,0,0,.35)] backdrop-blur-[30px] backdrop-saturate-[1.8] bottom-[calc(env(safe-area-inset-bottom,0px)+12px)]">
|
||||
<div className="flex min-w-0 flex-1 items-center justify-around">{left.map(renderItem)}</div>
|
||||
|
||||
{logoSlot ? (
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { RefObject } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import { tickHaptic } from '../../native/haptics';
|
||||
import { closeTopOverlay, hasOpenOverlay } from '../overlayStack';
|
||||
import { skipNextRouteTransition } from '../routeTransition';
|
||||
|
||||
/**
|
||||
* The swipe from the left edge that goes back, the way every iOS app has it.
|
||||
@@ -96,6 +97,7 @@ export default function MEdgeBack({ target, enabled }: { target: RefObject<HTMLE
|
||||
}
|
||||
move(width, 'transform 200ms cubic-bezier(0.2, 0.8, 0.2, 1)');
|
||||
window.setTimeout(() => {
|
||||
skipNextRouteTransition();
|
||||
window.history.back();
|
||||
// The page that comes back takes the place without a slide of its own.
|
||||
requestAnimationFrame(() => move(0, 'none'));
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { RefreshCw } from 'lucide-react';
|
||||
import { PULL_THRESHOLD, type PullState } from '../usePullToRefresh';
|
||||
|
||||
/**
|
||||
* The round glass badge under the top bar that a pull-to-refresh reveals: it
|
||||
* fades in and turns with the pull, and spins while the page reloads.
|
||||
*/
|
||||
export default function MPullIndicator({ state, label }: { state: PullState; label: string }) {
|
||||
const progress = Math.min(1, state.distance / PULL_THRESHOLD);
|
||||
const visible = state.refreshing || state.distance > 4;
|
||||
return (
|
||||
<div
|
||||
aria-hidden={!state.refreshing}
|
||||
role={state.refreshing ? 'status' : undefined}
|
||||
aria-label={state.refreshing ? label : undefined}
|
||||
className="pointer-events-none fixed inset-x-0 z-30 flex justify-center transition-opacity duration-200"
|
||||
style={{ top: 'calc(var(--m-safe-top, 12px) + 70px)', opacity: visible ? Math.max(0.35, progress) : 0 }}
|
||||
>
|
||||
<span className="flex h-9 w-9 items-center justify-center rounded-full border border-[color:var(--m-gbr)] bg-[color:var(--m-glass)] text-m-ink shadow-sm backdrop-blur-xl">
|
||||
<RefreshCw
|
||||
size={16}
|
||||
strokeWidth={2.2}
|
||||
className={state.refreshing ? 'animate-spin' : undefined}
|
||||
style={state.refreshing ? undefined : { transform: `rotate(${progress * 270}deg)` }}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,7 +9,7 @@
|
||||
*/
|
||||
export default function MRouteFallback() {
|
||||
return (
|
||||
<div className="m-root flex h-dvh flex-col gap-3 px-4 pt-14 bg-[color:var(--m-bg)]">
|
||||
<div data-route-fallback className="m-root flex h-dvh flex-col gap-3 px-4 pt-14 bg-[color:var(--m-bg)]">
|
||||
<div className="h-7 w-40 animate-pulse rounded-lg bg-m-card" />
|
||||
<div className="h-28 animate-pulse rounded-xl bg-m-card" />
|
||||
<div className="h-28 animate-pulse rounded-xl bg-m-card" />
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ReactNode, useRef } from 'react'
|
||||
import { useActivePill } from './useActivePill'
|
||||
import { selectionHaptic } from '../../native/haptics'
|
||||
|
||||
export interface MSegmentedOption<T extends string = string> {
|
||||
value: T
|
||||
@@ -45,7 +46,10 @@ export default function MSegmented<T extends string = string>({
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
data-active={active}
|
||||
onClick={() => onChange(opt.value)}
|
||||
onClick={() => {
|
||||
if (!active) selectionHaptic()
|
||||
onChange(opt.value)
|
||||
}}
|
||||
className={`relative z-[1] rounded-full py-[7px] text-center ${
|
||||
variant === 'intrinsic'
|
||||
? 'whitespace-nowrap px-[11px] text-[0.75rem]'
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useDiscardGuard } from '../../components/shared/useDiscardGuard'
|
||||
import { keepTabInside, useModalFocus } from './modalFocus'
|
||||
import { useSheetDrag } from './useSheetDrag'
|
||||
import { useOverlay } from '../overlayStack'
|
||||
import { tickHaptic } from '../../native/haptics'
|
||||
|
||||
export type MSheetVariant = 'card' | 'bottom' | 'drawer'
|
||||
export type MSheetMaterial = 'glass' | 'bar-glass' | 'opaque'
|
||||
@@ -106,6 +107,7 @@ export default function MSheet({
|
||||
return false
|
||||
}
|
||||
setFlung(true)
|
||||
tickHaptic()
|
||||
onClose()
|
||||
return true
|
||||
},
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { selectionHaptic } from '../../native/haptics'
|
||||
interface MToggleProps {
|
||||
checked: boolean
|
||||
onChange: (checked: boolean) => void
|
||||
@@ -15,7 +16,10 @@ export default function MToggle({ checked, onChange, ariaLabel, disabled = false
|
||||
aria-checked={checked}
|
||||
aria-label={ariaLabel}
|
||||
disabled={disabled}
|
||||
onClick={() => onChange(!checked)}
|
||||
onClick={() => {
|
||||
selectionHaptic()
|
||||
onChange(!checked)
|
||||
}}
|
||||
className={`box-border h-[22px] w-10 flex-none rounded-full p-[2px] transition-colors duration-[180ms] disabled:opacity-50 ${
|
||||
checked ? 'bg-m-act' : 'bg-[color:var(--m-trackoff)]'
|
||||
} ${className}`}
|
||||
|
||||
@@ -412,3 +412,69 @@
|
||||
.m-hscroll::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ── Page transitions (mobile/routeTransition.ts) ───────────────────────────
|
||||
The way a native navigation stack moves: forward slides the new page in from
|
||||
the trailing edge over the old one, which drifts a little the other way and
|
||||
dims; back is the mirror; switching between the dock's sections only
|
||||
cross-fades. --m-vt-dir flips the travel for right-to-left languages. The
|
||||
dock keeps its own name, so it stands still while the pages move under it. */
|
||||
:root {
|
||||
--m-vt-dir: 1;
|
||||
/* The company-holiday tint of the Vacay cells (vacayDayModel), for the legend chip. */
|
||||
--m-vacay-company: #f5d9a6;
|
||||
}
|
||||
:root[dir='rtl'] {
|
||||
--m-vt-dir: -1;
|
||||
}
|
||||
.m-dock-vt {
|
||||
view-transition-name: m-dock;
|
||||
}
|
||||
@keyframes m-vt-in-trailing {
|
||||
from {
|
||||
transform: translateX(calc(100% * var(--m-vt-dir)));
|
||||
}
|
||||
}
|
||||
@keyframes m-vt-out-leading {
|
||||
to {
|
||||
transform: translateX(calc(-30% * var(--m-vt-dir)));
|
||||
filter: brightness(0.82);
|
||||
}
|
||||
}
|
||||
@keyframes m-vt-in-leading {
|
||||
from {
|
||||
transform: translateX(calc(-30% * var(--m-vt-dir)));
|
||||
filter: brightness(0.82);
|
||||
}
|
||||
}
|
||||
@keyframes m-vt-out-trailing {
|
||||
to {
|
||||
transform: translateX(calc(100% * var(--m-vt-dir)));
|
||||
}
|
||||
}
|
||||
:root[data-route-transition='forward']::view-transition-old(root) {
|
||||
animation: m-vt-out-leading 0.4s cubic-bezier(0.32, 0.72, 0, 1) both;
|
||||
}
|
||||
:root[data-route-transition='forward']::view-transition-new(root) {
|
||||
animation: m-vt-in-trailing 0.4s cubic-bezier(0.32, 0.72, 0, 1) both;
|
||||
box-shadow: -16px 0 40px -12px rgb(0 0 0 / 0.35);
|
||||
}
|
||||
:root[data-route-transition='back']::view-transition-old(root) {
|
||||
z-index: 1;
|
||||
animation: m-vt-out-trailing 0.34s cubic-bezier(0.32, 0.72, 0, 1) both;
|
||||
box-shadow: -16px 0 40px -12px rgb(0 0 0 / 0.35);
|
||||
}
|
||||
:root[data-route-transition='back']::view-transition-new(root) {
|
||||
animation: m-vt-in-leading 0.34s cubic-bezier(0.32, 0.72, 0, 1) both;
|
||||
}
|
||||
:root[data-route-transition='tab']::view-transition-old(root),
|
||||
:root[data-route-transition='tab']::view-transition-new(root) {
|
||||
animation-duration: 0.18s;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
::view-transition-group(*),
|
||||
::view-transition-old(*),
|
||||
::view-transition-new(*) {
|
||||
animation: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import type { Location, NavigationType } from 'react-router';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { skipNextRouteTransition, transitionKind, useRouteTransition } from './routeTransition';
|
||||
|
||||
const loc = (pathname: string, key = pathname): Location => ({ pathname, key, search: '', hash: '', state: null });
|
||||
|
||||
describe('transitionKind', () => {
|
||||
it('slides deeper pages forward and back, cross-fades between sections', () => {
|
||||
expect(transitionKind(loc('/dashboard'), loc('/trips/4'), 'PUSH')).toBe('forward');
|
||||
expect(transitionKind(loc('/trips/4'), loc('/dashboard'), 'POP')).toBe('back');
|
||||
expect(transitionKind(loc('/trips/4'), loc('/trips/4/files'), 'PUSH')).toBe('forward');
|
||||
expect(transitionKind(loc('/dashboard'), loc('/atlas'), 'PUSH')).toBe('tab');
|
||||
expect(transitionKind(loc('/vacay'), loc('/dashboard'), 'POP')).toBe('tab');
|
||||
});
|
||||
});
|
||||
|
||||
describe('useRouteTransition', () => {
|
||||
let start: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
start = vi.fn((update: () => Promise<void>) => {
|
||||
const done = update();
|
||||
return { finished: done, ready: done, updateCallbackDone: done, skipTransition: vi.fn() };
|
||||
});
|
||||
Object.defineProperty(document, 'startViewTransition', { value: start, configurable: true, writable: true });
|
||||
vi.stubGlobal(
|
||||
'matchMedia',
|
||||
vi.fn(() => ({ matches: false }))
|
||||
);
|
||||
});
|
||||
afterEach(() => {
|
||||
delete (document as { startViewTransition?: unknown }).startViewTransition;
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function render(initial: Location, enabled = true) {
|
||||
return renderHook(
|
||||
({ location, type }: { location: Location; type: NavigationType }) => useRouteTransition(location, type, enabled),
|
||||
{ initialProps: { location: initial, type: 'PUSH' as NavigationType } }
|
||||
);
|
||||
}
|
||||
|
||||
it('swaps the page inside a view transition and marks its direction', async () => {
|
||||
const hook = render(loc('/dashboard'));
|
||||
await act(async () => {
|
||||
hook.rerender({ location: loc('/trips/1'), type: 'PUSH' });
|
||||
});
|
||||
expect(start).toHaveBeenCalledTimes(1);
|
||||
expect(hook.result.current.pathname).toBe('/trips/1');
|
||||
});
|
||||
|
||||
it('goes without a transition after the edge swipe, on the same page and when off', async () => {
|
||||
const hook = render(loc('/trips/1'));
|
||||
skipNextRouteTransition();
|
||||
await act(async () => {
|
||||
hook.rerender({ location: loc('/dashboard'), type: 'POP' });
|
||||
});
|
||||
await act(async () => {
|
||||
hook.rerender({ location: { ...loc('/dashboard', 'k2'), search: '?f=1' }, type: 'PUSH' });
|
||||
});
|
||||
expect(start).not.toHaveBeenCalled();
|
||||
expect(hook.result.current.key).toBe('k2');
|
||||
|
||||
const off = render(loc('/dashboard'), false);
|
||||
off.rerender({ location: loc('/trips/2'), type: 'PUSH' });
|
||||
expect(off.result.current.pathname).toBe('/trips/2');
|
||||
expect(start).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import { useLayoutEffect, useRef, useState } from 'react';
|
||||
import { flushSync } from 'react-dom';
|
||||
import type { Location, NavigationType } from 'react-router';
|
||||
|
||||
/**
|
||||
* Page transitions on the phone, the way a native navigation stack moves: a
|
||||
* page opened from another slides in from the trailing edge over the one it
|
||||
* leaves, going back slides it away again, and switching between the main
|
||||
* sections (the dock's destinations) only cross-fades. Built on the View
|
||||
* Transitions API, so the browser animates snapshots and the pages themselves
|
||||
* render as before; mobile.css holds the motion.
|
||||
*
|
||||
* The router is a declarative BrowserRouter, whose `viewTransition` option does
|
||||
* nothing, so App renders <Routes> with the location this hook hands out and
|
||||
* the hook swaps it inside the transition. Routes load lazily: the swap waits
|
||||
* (at most SETTLE_MS) until the new page replaced the route fallback, while the
|
||||
* browser keeps showing the old snapshot, so the skeleton never flashes.
|
||||
*/
|
||||
export type RouteTransitionKind = 'forward' | 'back' | 'tab';
|
||||
|
||||
const SETTLE_MS = 800;
|
||||
let skipNext = false;
|
||||
let current: ViewTransition | null = null;
|
||||
|
||||
/** The edge-swipe back gesture already slid the page away: the next change goes without a transition. */
|
||||
export function skipNextRouteTransition(): void {
|
||||
skipNext = true;
|
||||
}
|
||||
|
||||
function depth(pathname: string): number {
|
||||
return pathname.split('/').filter(Boolean).length;
|
||||
}
|
||||
|
||||
export function transitionKind(from: Location, to: Location, navigationType: NavigationType): RouteTransitionKind {
|
||||
if (depth(from.pathname) <= 1 && depth(to.pathname) <= 1) return 'tab';
|
||||
return navigationType === 'POP' ? 'back' : 'forward';
|
||||
}
|
||||
|
||||
function canAnimate(): boolean {
|
||||
return (
|
||||
typeof document !== 'undefined' &&
|
||||
typeof document.startViewTransition === 'function' &&
|
||||
!window.matchMedia?.('(prefers-reduced-motion: reduce)').matches
|
||||
);
|
||||
}
|
||||
|
||||
/** Resolves once no route fallback is on screen, or after SETTLE_MS. */
|
||||
function settled(): Promise<void> {
|
||||
const began = performance.now();
|
||||
return new Promise((resolve) => {
|
||||
const check = () => {
|
||||
if (!document.querySelector('[data-route-fallback]') || performance.now() - began > SETTLE_MS) resolve();
|
||||
else requestAnimationFrame(check);
|
||||
};
|
||||
check();
|
||||
});
|
||||
}
|
||||
|
||||
export function useRouteTransition(location: Location, navigationType: NavigationType, enabled: boolean): Location {
|
||||
const [shown, setShown] = useState(location);
|
||||
const shownRef = useRef(location);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const from = shownRef.current;
|
||||
if (from.key === location.key) return;
|
||||
shownRef.current = location;
|
||||
const skip = skipNext;
|
||||
skipNext = false;
|
||||
// A changed query or hash on the same page (a tab inside the trip, a filter) is not a navigation.
|
||||
if (!enabled || skip || from.pathname === location.pathname || !canAnimate()) {
|
||||
setShown(location);
|
||||
return;
|
||||
}
|
||||
const root = document.documentElement;
|
||||
root.dataset.routeTransition = transitionKind(from, location, navigationType);
|
||||
const transition = document.startViewTransition(async () => {
|
||||
flushSync(() => setShown(location));
|
||||
await settled();
|
||||
});
|
||||
current = transition;
|
||||
// A newer navigation skips this one: both promises then reject, which is no error.
|
||||
transition.ready.catch(() => {});
|
||||
void transition.finished
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
// Only the newest transition clears the direction, or a skipped one would
|
||||
// take it away from the one that replaced it.
|
||||
if (current === transition) delete root.dataset.routeTransition;
|
||||
});
|
||||
}, [location, navigationType, enabled]);
|
||||
|
||||
return enabled ? shown : location;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
Archive, ArchiveRestore, ArrowRight, Bell, CalendarDays, CalendarPlus, Copy,
|
||||
LayoutGrid, List, MapPin, Pencil, Plus, RefreshCw, Sparkles, Trash2, Users,
|
||||
@@ -26,6 +26,8 @@ import MUserMenu from './MUserMenu'
|
||||
import { useMobileDashOrder, useMobileDashVisibility, MobileDashWidget } from './MDashWidgets'
|
||||
import MNewTripSheet from './MNewTripSheet'
|
||||
import type { MobileDashToken } from '@trek/shared'
|
||||
import { usePullToRefresh } from '../../usePullToRefresh'
|
||||
import MPullIndicator from '../../components/MPullIndicator'
|
||||
|
||||
type TripFilter = 'planned' | 'archive' | 'completed'
|
||||
|
||||
@@ -44,7 +46,7 @@ interface CardAction {
|
||||
export default function MDashboard(): React.ReactElement {
|
||||
const {
|
||||
demoMode, locale, t, navigate,
|
||||
spotlight, upcoming, gridTrips, isLoading, loadError, retryLoad,
|
||||
spotlight, upcoming, gridTrips, isLoading, loadError, retryLoad, refreshQuietly,
|
||||
tripFilter, setTripFilter, viewMode, toggleViewMode,
|
||||
showForm, setShowForm, editingTrip, setEditingTrip,
|
||||
deleteTrip, setDeleteTrip, copyTrip, setCopyTrip, applyCoverUpdate,
|
||||
@@ -70,6 +72,10 @@ export default function MDashboard(): React.ReactElement {
|
||||
const dashVisible = useMobileDashVisibility()
|
||||
|
||||
useEffect(() => { if (isAuthenticated) fetchUnreadCount() }, [isAuthenticated, fetchUnreadCount])
|
||||
const contentRef = useRef<HTMLDivElement>(null)
|
||||
const pull = usePullToRefresh(contentRef, async () => {
|
||||
await Promise.all([refreshQuietly(), fetchUnreadCount()])
|
||||
})
|
||||
|
||||
const openCreate = () => { setEditingTrip(null); setShowForm(true) }
|
||||
const openEdit = (trip: DashboardTrip) => { setEditingTrip(trip); setShowForm(true) }
|
||||
@@ -249,7 +255,8 @@ export default function MDashboard(): React.ReactElement {
|
||||
|
||||
{demoMode && <DemoWelcome />}
|
||||
|
||||
<div className="px-4 pb-[calc(var(--bottom-nav-h,84px)+32px)] pt-[calc(var(--m-safe-top,12px)+82px)]">
|
||||
<MPullIndicator state={pull} label={t('common.loading')} />
|
||||
<div ref={contentRef} className="px-4 pb-[calc(var(--bottom-nav-h,84px)+32px)] pt-[calc(var(--m-safe-top,12px)+82px)]">
|
||||
{loadError && (
|
||||
<div role="alert" className="mb-3 flex items-center gap-3 rounded-[20px] border border-[color:var(--m-gbr)] bg-[color:var(--m-glass)] p-[14px]">
|
||||
<span className="min-w-0 flex-1 text-[0.8125rem] font-medium text-m-ink">{t('dashboard.loadErrorBanner')}</span>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useRef, useState } from 'react'
|
||||
import { X, Image, Plus, Trash2, UserPlus, Link as LinkIcon, List, Grid3x3, MapPin, Route, Archive, ArchiveRestore, Undo2, Link2, ListChecks, RefreshCw } from 'lucide-react'
|
||||
import { X, Image, Plus, Trash2, UserPlus, Link as LinkIcon, List, Grid3x3, MapPin, Route, Archive, ArchiveRestore, Undo2, Link2, ListChecks, RefreshCw, Share as ShareIcon } from 'lucide-react'
|
||||
import { canShareLink, shareLink } from '../../../native/shareLink'
|
||||
import MSheet from '../../components/MSheet'
|
||||
import MIconBtn from '../../components/MIconBtn'
|
||||
import MToggle from '../../components/MToggle'
|
||||
@@ -266,6 +267,16 @@ export default function MJourneySettingsSheet({
|
||||
>
|
||||
{copied ? t('journey.share.copied') : t('journey.share.copy')}
|
||||
</button>
|
||||
{canShareLink() && shareUrl && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.share')}
|
||||
onClick={() => void shareLink(shareUrl)}
|
||||
className="flex h-[26px] w-[26px] flex-none items-center justify-center rounded-full bg-m-act text-m-actfg"
|
||||
>
|
||||
<ShareIcon size={12} strokeWidth={2.4} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{([
|
||||
{ key: 'share_timeline' as const, label: t('journey.share.timeline'), icon: List },
|
||||
|
||||
@@ -28,7 +28,7 @@ export default function MRouteAppsSheet({
|
||||
return (
|
||||
<MSheet open={open} onClose={onClose} variant="bottom" ariaLabel={t('mobileTrip.openRouteIn')}>
|
||||
<div className="px-4 pb-4 pt-6">
|
||||
<div className="mb-3 flex items-center gap-2 px-1 text-[0.8125rem] font-bold text-m-ink">
|
||||
<div className="mb-3 flex items-center gap-2 px-1 text-[length:calc(13px*var(--fs-scale-body,1))] font-bold text-m-ink">
|
||||
<MapPinned size={16} strokeWidth={2.2} className="text-m-muted" aria-hidden="true" />
|
||||
{t('mobileTrip.openRouteIn')}
|
||||
</div>
|
||||
@@ -44,11 +44,11 @@ export default function MRouteAppsSheet({
|
||||
className="flex items-center gap-3 rounded-[18px] border border-[color:var(--m-gbr)] bg-[color:var(--m-glass)] px-4 py-3 text-start transition-transform active:scale-[.98]"
|
||||
>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-[0.9375rem] font-bold text-m-ink">
|
||||
<span className="block truncate text-[length:calc(15px*var(--fs-scale-body,1))] font-bold text-m-ink">
|
||||
{target.labelKey ? t(target.labelKey) : target.label}
|
||||
</span>
|
||||
{target.firstStopOnly && (
|
||||
<span className="mt-0.5 block font-geist text-[0.6875rem] text-m-muted">
|
||||
<span className="mt-0.5 block font-geist text-[length:calc(11px*var(--fs-scale-body,1))] text-m-muted">
|
||||
{t('mobileTrip.firstStopOnly')}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Settings2,
|
||||
Share as ShareIcon,
|
||||
Trash2,
|
||||
UserMinus,
|
||||
UserPlus,
|
||||
@@ -26,6 +27,7 @@ import {
|
||||
type ListedMember,
|
||||
} from '../../../../components/Trips/useTripShare';
|
||||
import { useTranslation } from '../../../../i18n';
|
||||
import { canShareLink, shareLink } from '../../../../native/shareLink';
|
||||
import MChip from '../../../components/MChip';
|
||||
import MSheet from '../../../components/MSheet';
|
||||
import MConfirmSheet from '../../settings/MConfirmSheet';
|
||||
@@ -122,6 +124,16 @@ function LinkField({
|
||||
{copied ? <Check size={13} strokeWidth={2.4} /> : <Copy size={13} strokeWidth={2.2} />}
|
||||
{copied ? t('common.copied') : t('common.copy')}
|
||||
</button>
|
||||
{canShareLink() && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.share')}
|
||||
onClick={() => void shareLink(url)}
|
||||
className={`${PRIMARY} h-[32px] w-[32px] justify-center px-0`}
|
||||
>
|
||||
<ShareIcon size={13} strokeWidth={2.2} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useCollabChatData } from '../../../../components/Collab/useCollabChatDa
|
||||
import { useAuthStore } from '../../../../store/authStore'
|
||||
import { useTranslation } from '../../../../i18n'
|
||||
import type { TripPlanner } from '../MTripShell'
|
||||
import { pressHaptic } from '../../../../native/haptics'
|
||||
import {
|
||||
OTHER_BUBBLE_RADIUS,
|
||||
OWN_BUBBLE_RADIUS,
|
||||
@@ -252,6 +253,7 @@ function ChatBubbleRow({ msg, own, showHeader, isLastInGroup, marginTop, is12h,
|
||||
startRef.current = { x: e.clientX, y: e.clientY }
|
||||
pressTimer.current = setTimeout(() => {
|
||||
firedRef.current = true
|
||||
pressHaptic()
|
||||
onOpenActions(e.clientX, e.clientY)
|
||||
}, LONG_PRESS_MS)
|
||||
}
|
||||
|
||||
@@ -16,9 +16,9 @@ import { FALLBACK_PERSON_COLOR } from './vacayDayModel'
|
||||
import { fmtDays } from '../../../components/Vacay/vacayFormat'
|
||||
|
||||
/** The company-holiday tint the calendar cells use (vacayDayModel). */
|
||||
const COMPANY_COLOR = '#F5D9A6' // theme-lint-disable: the cells' fixed company-day tint
|
||||
const GLASS_PILL_BUTTON = 'inline-flex h-8 min-w-0 flex-1 basis-0 items-center justify-center gap-2 rounded-full border border-[color:var(--m-gbr)] bg-[color:var(--m-glass)] px-3 text-[0.75rem] font-bold text-m-ink backdrop-blur-[24px] backdrop-saturate-[1.7] transition-transform active:scale-[.97]'
|
||||
const SHEET_ROW = 'flex items-center gap-3 rounded-[16px] border border-[color:var(--m-gbr)] bg-[color:var(--m-glass)] px-4 py-3 text-start text-[0.875rem] font-bold transition-transform active:scale-[.98]'
|
||||
const COMPANY_COLOR = 'var(--m-vacay-company)'
|
||||
const GLASS_PILL_BUTTON = 'inline-flex h-8 min-w-0 flex-1 basis-0 items-center justify-center gap-2 rounded-full border border-[color:var(--m-gbr)] bg-[color:var(--m-glass)] px-3 text-[length:calc(12px*var(--fs-scale-body,1))] font-bold text-m-ink backdrop-blur-[24px] backdrop-saturate-[1.7] transition-transform active:scale-[.97]'
|
||||
const SHEET_ROW = 'flex items-center gap-3 rounded-[16px] border border-[color:var(--m-gbr)] bg-[color:var(--m-glass)] px-4 py-3 text-start text-[length:calc(14px*var(--fs-scale-body,1))] font-bold transition-transform active:scale-[.98]'
|
||||
|
||||
const WEEKDAY_KEYS_MONDAY = ['vacay.mon', 'vacay.tue', 'vacay.wed', 'vacay.thu', 'vacay.fri', 'vacay.sat', 'vacay.sun'] as const
|
||||
const WEEKDAY_KEYS_SUNDAY = ['vacay.sun', 'vacay.mon', 'vacay.tue', 'vacay.wed', 'vacay.thu', 'vacay.fri', 'vacay.sat'] as const
|
||||
@@ -203,7 +203,7 @@ export default function MVacay() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => v.openMonthSlot(slot)}
|
||||
className="mb-[6px] w-full rounded-full border border-[color:var(--m-gbr)] bg-[color:var(--m-glass)] py-[6px] text-center text-[0.75rem] font-extrabold capitalize shadow-[0_4px_12px_-8px_rgba(0,0,0,.25)] backdrop-blur-[24px] backdrop-saturate-[1.7] transition-transform active:scale-[.97]"
|
||||
className="mb-[6px] w-full rounded-full border border-[color:var(--m-gbr)] bg-[color:var(--m-glass)] py-[6px] text-center text-[length:calc(12px*var(--fs-scale-body,1))] font-extrabold capitalize shadow-[0_4px_12px_-8px_rgba(0,0,0,.25)] backdrop-blur-[24px] backdrop-saturate-[1.7] transition-transform active:scale-[.97]"
|
||||
>
|
||||
{v.monthNamesShort[slot]}
|
||||
</button>
|
||||
@@ -246,7 +246,7 @@ export default function MVacay() {
|
||||
<button type="button" onClick={v.prevMonth} aria-label={t('mobileVacay.prevMonth')} className="flex h-[30px] w-[30px] items-center justify-center rounded-full bg-[color:var(--m-ic)]">
|
||||
<ChevronLeft size={16} strokeWidth={2.2} />
|
||||
</button>
|
||||
<span className="flex-1 text-center text-[1rem] font-extrabold capitalize">{v.monthNameLongFor(slot)}</span>
|
||||
<span className="flex-1 text-center text-[length:calc(16px*var(--fs-scale-body,1))] font-extrabold capitalize">{v.monthNameLongFor(slot)}</span>
|
||||
<button type="button" onClick={v.nextMonth} aria-label={t('mobileVacay.nextMonth')} className="flex h-[30px] w-[30px] items-center justify-center rounded-full bg-[color:var(--m-ic)]">
|
||||
<ChevronRight size={16} strokeWidth={2.2} />
|
||||
</button>
|
||||
@@ -365,7 +365,7 @@ export default function MVacay() {
|
||||
<MVacayShareSheet open={v.sheet === 'share'} onClose={() => v.setSheet(null)} />
|
||||
<MSheet open={v.sheet === 'people'} onClose={() => v.setSheet(null)} variant="bottom" ariaLabel={t('mobileVacay.people')}>
|
||||
<div className="px-4 pb-4 pt-6">
|
||||
<div className="mb-3 px-1 text-[0.8125rem] font-bold">{t('mobileVacay.people')}</div>
|
||||
<div className="mb-3 px-1 text-[length:calc(13px*var(--fs-scale-body,1))] font-bold">{t('mobileVacay.people')}</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{v.users.map(u => {
|
||||
const color = u.color || FALLBACK_PERSON_COLOR
|
||||
@@ -388,7 +388,7 @@ export default function MVacay() {
|
||||
{/* Shared read-only calendars: a tap shows or hides that person's ring overlay. */}
|
||||
{v.incomingShares.length > 0 && (
|
||||
<>
|
||||
<div className="mb-2 mt-4 px-1 font-geist text-[0.625rem] font-bold uppercase tracking-[.09em] text-m-faint">{t('vacay.sharedCalendars')}</div>
|
||||
<div className="mb-2 mt-4 px-1 font-geist text-[length:calc(10px*var(--fs-scale-body,1))] font-bold uppercase tracking-[.09em] text-m-faint">{t('vacay.sharedCalendars')}</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{v.incomingShares.map(sh => (
|
||||
<button
|
||||
@@ -412,10 +412,10 @@ export default function MVacay() {
|
||||
</MSheet>
|
||||
<MSheet open={v.sheet === 'legend'} onClose={() => v.setSheet(null)} variant="bottom" ariaLabel={t('mobileVacay.legend')}>
|
||||
<div className="px-4 pb-4 pt-6">
|
||||
<div className="mb-3 px-1 text-[0.8125rem] font-bold">{t('mobileVacay.legend')}</div>
|
||||
<div className="mb-3 px-1 text-[length:calc(13px*var(--fs-scale-body,1))] font-bold">{t('mobileVacay.legend')}</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
{legendItems.map(item => (
|
||||
<div key={item.key} className="flex items-center gap-3 px-1 py-2 text-[0.875rem] font-semibold">
|
||||
<div key={item.key} className="flex items-center gap-3 px-1 py-2 text-[length:calc(14px*var(--fs-scale-body,1))] font-semibold">
|
||||
<span className="h-[14px] w-[14px] flex-none rounded-[4px]" style={{ background: item.color }} />
|
||||
<span className="min-w-0 flex-1 truncate">{item.label}</span>
|
||||
</div>
|
||||
@@ -506,7 +506,7 @@ function MonthQuickNav({ names, slot, onPick }: { names: string[]; slot: number;
|
||||
data-active={i === slot}
|
||||
aria-pressed={i === slot}
|
||||
onClick={() => onPick(i)}
|
||||
className={`relative z-[1] whitespace-nowrap rounded-full py-[7px] text-center text-[0.71875rem] font-bold capitalize transition-colors duration-200 ${
|
||||
className={`relative z-[1] whitespace-nowrap rounded-full py-[7px] text-center text-[length:calc(11.5px*var(--fs-scale-body,1))] font-bold capitalize transition-colors duration-200 ${
|
||||
i === slot ? `text-m-actfg ${pill ? '' : 'bg-m-act'}` : 'text-m-ink active:bg-[color:var(--m-ic)]'
|
||||
}`}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('../native/haptics', () => ({ tickHaptic: vi.fn() }));
|
||||
vi.mock('./overlayStack', () => ({ hasOpenOverlay: vi.fn(() => false) }));
|
||||
|
||||
import { tickHaptic } from '../native/haptics';
|
||||
import { hasOpenOverlay } from './overlayStack';
|
||||
import { usePullToRefresh } from './usePullToRefresh';
|
||||
|
||||
function touch(type: string, x = 0, y = 0) {
|
||||
const event = new Event(type, { cancelable: true });
|
||||
Object.defineProperty(event, 'touches', { value: type === 'touchend' ? [] : [{ clientX: x, clientY: y }] });
|
||||
act(() => {
|
||||
window.dispatchEvent(event);
|
||||
});
|
||||
return event;
|
||||
}
|
||||
|
||||
function setup() {
|
||||
const el = document.createElement('div');
|
||||
const ref = { current: el };
|
||||
const onRefresh = vi.fn(async () => {});
|
||||
const hook = renderHook(() => usePullToRefresh(ref, onRefresh));
|
||||
return { el, onRefresh, hook };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
Object.defineProperty(window, 'scrollY', { value: 0, configurable: true });
|
||||
vi.mocked(tickHaptic).mockClear();
|
||||
vi.mocked(hasOpenOverlay).mockReturnValue(false);
|
||||
});
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
describe('usePullToRefresh', () => {
|
||||
it('springs back without refreshing after a short pull', () => {
|
||||
const { el, onRefresh } = setup();
|
||||
touch('touchstart', 100, 100);
|
||||
const move = touch('touchmove', 100, 160);
|
||||
expect(move.defaultPrevented).toBe(true);
|
||||
expect(el.style.transform).toContain('translate3d');
|
||||
touch('touchend');
|
||||
expect(onRefresh).not.toHaveBeenCalled();
|
||||
expect(el.style.transform).toBe('');
|
||||
});
|
||||
|
||||
it('arms with a tick past the threshold and refreshes on release', async () => {
|
||||
const { onRefresh, hook } = setup();
|
||||
touch('touchstart', 100, 100);
|
||||
touch('touchmove', 100, 400);
|
||||
expect(tickHaptic).toHaveBeenCalledTimes(1);
|
||||
expect(hook.result.current.armed).toBe(true);
|
||||
touch('touchend');
|
||||
expect(hook.result.current.refreshing).toBe(true);
|
||||
await act(async () => {
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
expect(onRefresh).toHaveBeenCalledTimes(1);
|
||||
expect(hook.result.current.refreshing).toBe(false);
|
||||
});
|
||||
|
||||
it('leaves sideways drags, scrolled pages and open sheets alone', () => {
|
||||
const { el, onRefresh } = setup();
|
||||
touch('touchstart', 100, 100);
|
||||
expect(touch('touchmove', 200, 120).defaultPrevented).toBe(false);
|
||||
touch('touchend');
|
||||
|
||||
Object.defineProperty(window, 'scrollY', { value: 40, configurable: true });
|
||||
touch('touchstart', 100, 100);
|
||||
touch('touchmove', 100, 400);
|
||||
touch('touchend');
|
||||
|
||||
Object.defineProperty(window, 'scrollY', { value: 0, configurable: true });
|
||||
vi.mocked(hasOpenOverlay).mockReturnValue(true);
|
||||
touch('touchstart', 100, 100);
|
||||
touch('touchmove', 100, 400);
|
||||
touch('touchend');
|
||||
|
||||
expect(onRefresh).not.toHaveBeenCalled();
|
||||
expect(el.style.transform).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
import { useEffect, useRef, useState, type RefObject } from 'react';
|
||||
import { tickHaptic } from '../native/haptics';
|
||||
import { hasOpenOverlay } from './overlayStack';
|
||||
|
||||
/** How far the finger must pull (after resistance) before letting go refreshes. */
|
||||
export const PULL_THRESHOLD = 64;
|
||||
const MAX_PULL = 110;
|
||||
/** The indicator stays at least this long, so a fast answer does not just blink. */
|
||||
const MIN_SPIN_MS = 450;
|
||||
|
||||
export type PullState = { distance: number; refreshing: boolean; armed: boolean };
|
||||
|
||||
/**
|
||||
* Pull-to-refresh on a page whose scroller is the window, as the mobile
|
||||
* dashboard is. A downward drag that starts at the very top moves `contentRef`
|
||||
* down with resistance; past the threshold a light tick tells the finger that
|
||||
* letting go will refresh. Ignored while a sheet is open, for horizontal drags
|
||||
* (chip rows, pagers) and when the page is not scrolled to the top.
|
||||
*
|
||||
* Writes the content's transform directly during the drag, so a pull costs no
|
||||
* React render per frame; `state` only changes at the edges (armed, refreshing).
|
||||
*/
|
||||
export function usePullToRefresh(
|
||||
contentRef: RefObject<HTMLElement | null>,
|
||||
onRefresh: () => Promise<unknown>,
|
||||
enabled = true
|
||||
): PullState {
|
||||
const [state, setState] = useState<PullState>({ distance: 0, refreshing: false, armed: false });
|
||||
const refreshRef = useRef(onRefresh);
|
||||
refreshRef.current = onRefresh;
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
let start: { x: number; y: number } | null = null;
|
||||
let pulling = false;
|
||||
let armed = false;
|
||||
let busy = false;
|
||||
let distance = 0;
|
||||
|
||||
const place = (value: number, animate: boolean) => {
|
||||
const el = contentRef.current;
|
||||
if (!el) return;
|
||||
el.style.transition = animate ? 'transform 0.32s cubic-bezier(0.32, 0.72, 0, 1)' : 'none';
|
||||
el.style.transform = value > 0 ? `translate3d(0, ${value}px, 0)` : '';
|
||||
};
|
||||
|
||||
const onStart = (e: TouchEvent) => {
|
||||
if (busy || e.touches.length !== 1 || window.scrollY > 0 || hasOpenOverlay()) return;
|
||||
start = { x: e.touches[0].clientX, y: e.touches[0].clientY };
|
||||
pulling = false;
|
||||
};
|
||||
|
||||
const onMove = (e: TouchEvent) => {
|
||||
if (!start) return;
|
||||
const dx = e.touches[0].clientX - start.x;
|
||||
const dy = e.touches[0].clientY - start.y;
|
||||
if (!pulling) {
|
||||
// Decide once: a sideways or upward start belongs to whatever is under the finger.
|
||||
if (Math.abs(dy) < 8 && Math.abs(dx) < 8) return;
|
||||
if (dy <= 0 || Math.abs(dx) > Math.abs(dy) || window.scrollY > 0) {
|
||||
start = null;
|
||||
return;
|
||||
}
|
||||
pulling = true;
|
||||
}
|
||||
if (e.cancelable) e.preventDefault();
|
||||
// Resistance grows with the pull, like a rubber band.
|
||||
distance = Math.min(MAX_PULL, dy * 0.5 * (1 - Math.min(dy, 400) / 1000));
|
||||
place(distance, false);
|
||||
const nowArmed = distance >= PULL_THRESHOLD;
|
||||
if (nowArmed !== armed) {
|
||||
armed = nowArmed;
|
||||
if (armed) tickHaptic();
|
||||
setState({ distance, refreshing: false, armed });
|
||||
} else {
|
||||
setState((s) => (Math.abs(s.distance - distance) > 4 ? { ...s, distance } : s));
|
||||
}
|
||||
};
|
||||
|
||||
const onEnd = () => {
|
||||
if (!start) return;
|
||||
start = null;
|
||||
if (!pulling) return;
|
||||
pulling = false;
|
||||
if (!armed) {
|
||||
distance = 0;
|
||||
place(0, true);
|
||||
setState({ distance: 0, refreshing: false, armed: false });
|
||||
return;
|
||||
}
|
||||
busy = true;
|
||||
armed = false;
|
||||
place(PULL_THRESHOLD * 0.75, true);
|
||||
setState({ distance: PULL_THRESHOLD, refreshing: true, armed: false });
|
||||
const began = Date.now();
|
||||
void Promise.resolve()
|
||||
.then(() => refreshRef.current())
|
||||
.catch(() => {})
|
||||
.then(() => new Promise((resolve) => setTimeout(resolve, Math.max(0, MIN_SPIN_MS - (Date.now() - began)))))
|
||||
.then(() => {
|
||||
busy = false;
|
||||
distance = 0;
|
||||
place(0, true);
|
||||
setState({ distance: 0, refreshing: false, armed: false });
|
||||
});
|
||||
};
|
||||
|
||||
window.addEventListener('touchstart', onStart, { passive: true });
|
||||
// Not passive: once a pull has started, the page must not also scroll or bounce.
|
||||
window.addEventListener('touchmove', onMove, { passive: false });
|
||||
window.addEventListener('touchend', onEnd, { passive: true });
|
||||
window.addEventListener('touchcancel', onEnd, { passive: true });
|
||||
return () => {
|
||||
window.removeEventListener('touchstart', onStart);
|
||||
window.removeEventListener('touchmove', onMove);
|
||||
window.removeEventListener('touchend', onEnd);
|
||||
window.removeEventListener('touchcancel', onEnd);
|
||||
place(0, false);
|
||||
};
|
||||
}, [contentRef, enabled]);
|
||||
|
||||
return state;
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Haptics, ImpactStyle, NotificationType } from '@capacitor/haptics';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { tickHaptic, toastHaptic } from './haptics';
|
||||
import { pressHaptic, selectionHaptic, tickHaptic, toastHaptic } from './haptics';
|
||||
import { isNativeApp } from './platform';
|
||||
|
||||
vi.mock('@capacitor/haptics', () => ({
|
||||
Haptics: { notification: vi.fn(), impact: vi.fn() },
|
||||
ImpactStyle: { Light: 'LIGHT' },
|
||||
Haptics: { notification: vi.fn(), impact: vi.fn(), selectionChanged: vi.fn() },
|
||||
ImpactStyle: { Light: 'LIGHT', Medium: 'MEDIUM' },
|
||||
NotificationType: { Success: 'SUCCESS', Warning: 'WARNING', Error: 'ERROR' },
|
||||
}));
|
||||
vi.mock('./platform', () => ({ isNativeApp: vi.fn() }));
|
||||
@@ -13,6 +13,7 @@ vi.mock('./platform', () => ({ isNativeApp: vi.fn() }));
|
||||
beforeEach(() => {
|
||||
vi.mocked(Haptics.notification).mockReset().mockResolvedValue();
|
||||
vi.mocked(Haptics.impact).mockReset().mockResolvedValue();
|
||||
vi.mocked(Haptics.selectionChanged).mockReset().mockResolvedValue();
|
||||
vi.mocked(isNativeApp).mockReturnValue(true);
|
||||
});
|
||||
|
||||
@@ -57,3 +58,17 @@ describe('tickHaptic', () => {
|
||||
expect(Haptics.impact).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('selectionHaptic and pressHaptic', () => {
|
||||
it('click on a changed value and bump on a long press, in the app only', () => {
|
||||
selectionHaptic();
|
||||
pressHaptic();
|
||||
expect(Haptics.selectionChanged).toHaveBeenCalledTimes(1);
|
||||
expect(Haptics.impact).toHaveBeenCalledWith({ style: ImpactStyle.Medium });
|
||||
vi.mocked(isNativeApp).mockReturnValue(false);
|
||||
selectionHaptic();
|
||||
pressHaptic();
|
||||
expect(Haptics.selectionChanged).toHaveBeenCalledTimes(1);
|
||||
expect(Haptics.impact).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,3 +27,21 @@ export function tickHaptic(): void {
|
||||
if (!isNativeApp()) return;
|
||||
Haptics.impact({ style: ImpactStyle.Light }).catch(reportNativeFailure);
|
||||
}
|
||||
|
||||
/**
|
||||
* The selection click a native switch, segmented control or picker gives when
|
||||
* its value changes. Does nothing in a browser.
|
||||
*/
|
||||
export function selectionHaptic(): void {
|
||||
if (!isNativeApp()) return;
|
||||
Haptics.selectionChanged().catch(reportNativeFailure);
|
||||
}
|
||||
|
||||
/**
|
||||
* The firmer bump of a long press that opens a context menu, or of an item
|
||||
* picked up to drag. Does nothing in a browser.
|
||||
*/
|
||||
export function pressHaptic(): void {
|
||||
if (!isNativeApp()) return;
|
||||
Haptics.impact({ style: ImpactStyle.Medium }).catch(reportNativeFailure);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { Share } from '@capacitor/share';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { isNativeApp } from './platform';
|
||||
import { reportNativeFailure } from './reportFailure';
|
||||
import { canShareLink, shareLink } from './shareLink';
|
||||
|
||||
vi.mock('@capacitor/share', () => ({ Share: { share: vi.fn() } }));
|
||||
vi.mock('./platform', () => ({ isNativeApp: vi.fn() }));
|
||||
vi.mock('./reportFailure', () => ({ reportNativeFailure: vi.fn() }));
|
||||
vi.mock('../utils/fileShare', () => ({ canShareText: vi.fn(() => false) }));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(Share.share).mockReset().mockResolvedValue({ activityType: undefined });
|
||||
vi.mocked(reportNativeFailure).mockClear();
|
||||
vi.mocked(isNativeApp).mockReturnValue(true);
|
||||
});
|
||||
|
||||
describe('shareLink', () => {
|
||||
it('uses the native sheet inside the app', async () => {
|
||||
expect(canShareLink()).toBe(true);
|
||||
expect(await shareLink('https://trek.example/s/abc', 'Trip')).toBe(true);
|
||||
expect(Share.share).toHaveBeenCalledWith({ url: 'https://trek.example/s/abc', title: 'Trip', dialogTitle: 'Trip' });
|
||||
});
|
||||
|
||||
it('treats a closed sheet as no share, without a failure report', async () => {
|
||||
vi.mocked(Share.share).mockRejectedValue(new Error('Share canceled'));
|
||||
expect(await shareLink('https://trek.example/s/abc')).toBe(false);
|
||||
expect(reportNativeFailure).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back to the Web Share API in a browser, and offers nothing without one', async () => {
|
||||
vi.mocked(isNativeApp).mockReturnValue(false);
|
||||
expect(canShareLink()).toBe(false);
|
||||
const share = vi.fn(async () => {});
|
||||
vi.stubGlobal('navigator', { share });
|
||||
expect(await shareLink('https://trek.example/s/abc')).toBe(true);
|
||||
expect(share).toHaveBeenCalledWith({ url: 'https://trek.example/s/abc', title: undefined });
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Share } from '@capacitor/share';
|
||||
import { canShareText } from '../utils/fileShare';
|
||||
import { isNativeApp } from './platform';
|
||||
import { reportNativeFailure } from './reportFailure';
|
||||
|
||||
/**
|
||||
* Handing a link to the system share sheet, the way a native app offers
|
||||
* "Share…" next to "Copy": Messages, Mail, AirDrop, WhatsApp. Inside the apps
|
||||
* through Capacitor's Share, in a browser through the Web Share API where it
|
||||
* exists. Where neither does, `canShareLink` is false and the caller shows
|
||||
* only the copy button.
|
||||
*/
|
||||
export function canShareLink(): boolean {
|
||||
return isNativeApp() || canShareText();
|
||||
}
|
||||
|
||||
/** True when a target took the link, false when the sheet was closed or failed. */
|
||||
export async function shareLink(url: string, title?: string): Promise<boolean> {
|
||||
try {
|
||||
if (isNativeApp()) {
|
||||
await Share.share({ url, title, dialogTitle: title });
|
||||
return true;
|
||||
}
|
||||
await navigator.share({ url, title });
|
||||
return true;
|
||||
} catch (err) {
|
||||
// Closing the sheet rejects too; only a real failure is worth a report.
|
||||
const name = (err as { name?: string; message?: string } | null)?.name ?? '';
|
||||
const message = (err as { message?: string } | null)?.message ?? '';
|
||||
if (name !== 'AbortError' && !/cancel/i.test(message)) reportNativeFailure(err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -97,7 +97,7 @@ export function useDashboard() {
|
||||
// client) is read back quietly, one read per burst: no loading state and no
|
||||
// toast, since nobody here asked for it. trips:changed goes to the acting user
|
||||
// only; trip:deleted goes to the trip's room, which this page is never in.
|
||||
useServerPing('trips:changed', true, async () => {
|
||||
const refreshQuietly = async () => {
|
||||
try {
|
||||
const { trips, archivedTrips } = await tripRepo.list()
|
||||
setTrips(sortTrips(trips))
|
||||
@@ -106,7 +106,8 @@ export function useDashboard() {
|
||||
} catch {
|
||||
// The list on screen stays; the next visit reads it again.
|
||||
}
|
||||
})
|
||||
}
|
||||
useServerPing('trips:changed', true, refreshQuietly)
|
||||
|
||||
// Re-run both the trip fetch and the auth check so a recovered backend clears
|
||||
// the error banner (loadUser resets authCheckFailed on success). #1283
|
||||
@@ -240,6 +241,8 @@ export function useDashboard() {
|
||||
// data + derived
|
||||
spotlight, heroBundle, stats, upcoming, gridTrips, isLoading,
|
||||
loadError: loadError || authCheckFailed, retryLoad,
|
||||
// Pull-to-refresh on mobile: the same quiet read, no loading state.
|
||||
refreshQuietly,
|
||||
// ui state
|
||||
tripFilter, setTripFilter, viewMode, toggleViewMode,
|
||||
showForm, setShowForm, editingTrip, setEditingTrip,
|
||||
|
||||
@@ -89,5 +89,6 @@ const common: TranslationStrings = {
|
||||
'common.errorPluginTitle': 'تعذّر عرض هذه الإضافة',
|
||||
'common.needsConnection': 'يتطلب هذا اتصالاً. حاول مرة أخرى عندما يعود TREK للاتصال.',
|
||||
'common.errorDetails': 'التفاصيل',
|
||||
'common.share': 'مشاركة',
|
||||
};
|
||||
export default common;
|
||||
|
||||
@@ -81,5 +81,6 @@ const common: TranslationStrings = {
|
||||
'common.errorPluginTitle': 'Bu plagini göstərmək mümkün olmadı',
|
||||
'common.needsConnection': 'Bunun üçün bağlantı lazımdır. TREK yenidən onlayn olduqda bir daha cəhd edin.',
|
||||
'common.errorDetails': 'Təfərrüatlar',
|
||||
'common.share': 'Paylaş',
|
||||
};
|
||||
export default common;
|
||||
|
||||
@@ -82,5 +82,6 @@ const common: TranslationStrings = {
|
||||
'common.errorPluginTitle': 'Não foi possível exibir este plugin',
|
||||
'common.needsConnection': 'Isso precisa de conexão. Tente de novo quando o TREK voltar a ficar online.',
|
||||
'common.errorDetails': 'Detalhes',
|
||||
'common.share': 'Compartilhar',
|
||||
};
|
||||
export default common;
|
||||
|
||||
@@ -83,5 +83,6 @@ const common: TranslationStrings = {
|
||||
'common.errorPluginTitle': "No s'ha pogut mostrar aquest connector",
|
||||
'common.needsConnection': 'Això necessita connexió. Torna-ho a provar quan TREK torni a estar en línia.',
|
||||
'common.errorDetails': 'Detalls',
|
||||
'common.share': 'Comparteix',
|
||||
};
|
||||
export default common;
|
||||
|
||||
@@ -83,5 +83,6 @@ const common: TranslationStrings = {
|
||||
'common.errorPluginTitle': 'Tento plugin se nepodařilo zobrazit',
|
||||
'common.needsConnection': 'Na tohle je potřeba připojení. Zkus to znovu, až bude TREK zase online.',
|
||||
'common.errorDetails': 'Podrobnosti',
|
||||
'common.share': 'Sdílet',
|
||||
};
|
||||
export default common;
|
||||
|
||||
@@ -81,6 +81,7 @@ const common: TranslationStrings = {
|
||||
'TREK wurde aktualisiert, während dieser Tab offen war. Lade neu, um die neue Version zu erhalten.',
|
||||
'common.errorPluginTitle': 'Dieses Plugin konnte nicht angezeigt werden',
|
||||
'common.needsConnection': 'Dafür braucht es eine Verbindung. Versuch es noch einmal, sobald TREK wieder online ist.',
|
||||
'common.errorDetails': 'Details', // same-as-en
|
||||
'common.errorDetails': 'Details', // same-as-en,
|
||||
'common.share': 'Teilen',
|
||||
};
|
||||
export default common;
|
||||
|
||||
@@ -81,5 +81,6 @@ const common: TranslationStrings = {
|
||||
'common.errorPluginTitle': 'This plugin could not be shown',
|
||||
'common.needsConnection': 'This needs a connection. Try again once TREK is back online.',
|
||||
'common.errorDetails': 'Details',
|
||||
'common.share': 'Share',
|
||||
};
|
||||
export default common;
|
||||
|
||||
@@ -82,5 +82,6 @@ const common: TranslationStrings = {
|
||||
'common.errorPluginTitle': 'No se pudo mostrar este plugin',
|
||||
'common.needsConnection': 'Esto necesita conexión. Vuelve a intentarlo cuando TREK esté de nuevo en línea.',
|
||||
'common.errorDetails': 'Detalles',
|
||||
'common.share': 'Compartir',
|
||||
};
|
||||
export default common;
|
||||
|
||||
@@ -82,5 +82,6 @@ const common: TranslationStrings = {
|
||||
'common.errorPluginTitle': 'Seda pluginat ei saanud kuvada',
|
||||
'common.needsConnection': 'Selleks on vaja ühendust. Proovi uuesti, kui TREK on taas võrgus.',
|
||||
'common.errorDetails': 'Üksikasjad',
|
||||
'common.share': 'Jaga',
|
||||
};
|
||||
export default common;
|
||||
|
||||
@@ -82,5 +82,6 @@ const common: TranslationStrings = {
|
||||
'common.errorPluginTitle': "Ce plugin n'a pas pu être affiché",
|
||||
'common.needsConnection': 'Cela nécessite une connexion. Réessaie dès que TREK sera de nouveau en ligne.',
|
||||
'common.errorDetails': 'Détails',
|
||||
'common.share': 'Partager',
|
||||
};
|
||||
export default common;
|
||||
|
||||
@@ -82,5 +82,6 @@ const common: TranslationStrings = {
|
||||
'common.errorPluginTitle': 'Δεν ήταν δυνατή η εμφάνιση αυτού του πρόσθετου',
|
||||
'common.needsConnection': 'Αυτό χρειάζεται σύνδεση. Δοκίμασε ξανά μόλις το TREK συνδεθεί ξανά.',
|
||||
'common.errorDetails': 'Λεπτομέρειες',
|
||||
'common.share': 'Κοινοποίηση',
|
||||
};
|
||||
export default common;
|
||||
|
||||
@@ -83,6 +83,7 @@ const common: TranslationStrings = {
|
||||
'common.errorPluginTitle': 'לא ניתן להציג את התוסף הזה',
|
||||
'common.needsConnection': 'הפעולה הזו דורשת חיבור. נסו שוב כש-TREK יחזור לרשת.',
|
||||
'common.errorDetails': 'פרטים',
|
||||
'common.share': 'שיתוף',
|
||||
};
|
||||
|
||||
export default common;
|
||||
|
||||
@@ -81,5 +81,6 @@ const common: TranslationStrings = {
|
||||
'common.errorPluginTitle': 'Ezt a bővítményt nem sikerült megjeleníteni',
|
||||
'common.needsConnection': 'Ehhez kapcsolat kell. Próbáld újra, amint a TREK újra online lesz.',
|
||||
'common.errorDetails': 'Részletek',
|
||||
'common.share': 'Megosztás',
|
||||
};
|
||||
export default common;
|
||||
|
||||
@@ -79,5 +79,6 @@ const common: TranslationStrings = {
|
||||
'common.errorPluginTitle': 'Plugin ini tidak dapat ditampilkan',
|
||||
'common.needsConnection': 'Ini memerlukan koneksi. Coba lagi begitu TREK kembali online.',
|
||||
'common.errorDetails': 'Detail',
|
||||
'common.share': 'Bagikan',
|
||||
};
|
||||
export default common;
|
||||
|
||||
@@ -82,5 +82,6 @@ const common: TranslationStrings = {
|
||||
'common.errorPluginTitle': 'Impossibile mostrare questo plugin',
|
||||
'common.needsConnection': 'Serve una connessione. Riprova quando TREK tornerà online.',
|
||||
'common.errorDetails': 'Dettagli',
|
||||
'common.share': 'Condividi',
|
||||
};
|
||||
export default common;
|
||||
|
||||
@@ -80,5 +80,6 @@ const common: TranslationStrings = {
|
||||
'common.errorPluginTitle': 'このプラグインを表示できませんでした',
|
||||
'common.needsConnection': 'この操作には接続が必要です。TREK がオンラインに戻ってから、もう一度お試しください。',
|
||||
'common.errorDetails': '詳細',
|
||||
'common.share': '共有',
|
||||
};
|
||||
export default common;
|
||||
|
||||
@@ -79,5 +79,6 @@ const common: TranslationStrings = {
|
||||
'common.errorPluginTitle': '이 플러그인을 표시할 수 없습니다',
|
||||
'common.needsConnection': '연결이 필요한 작업입니다. TREK가 다시 온라인이 되면 다시 시도하세요.',
|
||||
'common.errorDetails': '세부 정보',
|
||||
'common.share': '공유',
|
||||
};
|
||||
export default common;
|
||||
|
||||
@@ -81,6 +81,7 @@ const common: TranslationStrings = {
|
||||
'TREK is bijgewerkt terwijl dit tabblad open stond. Herlaad om de nieuwe versie te krijgen.',
|
||||
'common.errorPluginTitle': 'Deze plug-in kon niet worden getoond',
|
||||
'common.needsConnection': 'Hiervoor is een verbinding nodig. Probeer het opnieuw zodra TREK weer online is.',
|
||||
'common.errorDetails': 'Details', // same-as-en
|
||||
'common.errorDetails': 'Details', // same-as-en,
|
||||
'common.share': 'Delen',
|
||||
};
|
||||
export default common;
|
||||
|
||||
@@ -85,5 +85,6 @@ const common: TranslationStrings = {
|
||||
'common.errorPluginTitle': 'Nie udało się wyświetlić tej wtyczki',
|
||||
'common.needsConnection': 'To wymaga połączenia. Spróbuj ponownie, gdy TREK znów będzie online.',
|
||||
'common.errorDetails': 'Szczegóły',
|
||||
'common.share': 'Udostępnij',
|
||||
};
|
||||
export default common;
|
||||
|
||||
@@ -86,5 +86,6 @@ const common: TranslationStrings = {
|
||||
'common.errorPluginTitle': 'Не удалось показать этот плагин',
|
||||
'common.needsConnection': 'Для этого нужно подключение. Попробуйте ещё раз, когда TREK снова будет онлайн.',
|
||||
'common.errorDetails': 'Подробности',
|
||||
'common.share': 'Поделиться',
|
||||
};
|
||||
export default common;
|
||||
|
||||
@@ -84,5 +84,6 @@ const common: TranslationStrings = {
|
||||
'common.errorPluginTitle': 'Tento plugin sa nepodarilo zobraziť',
|
||||
'common.needsConnection': 'Na toto je potrebné pripojenie. Skús to znova, keď bude TREK opäť online.',
|
||||
'common.errorDetails': 'Podrobnosti',
|
||||
'common.share': 'Zdieľať',
|
||||
};
|
||||
export default common;
|
||||
|
||||
@@ -82,5 +82,6 @@ const common: TranslationStrings = {
|
||||
'common.errorPluginTitle': 'Det här tillägget kunde inte visas',
|
||||
'common.needsConnection': 'Det här kräver en anslutning. Försök igen när TREK är online igen.',
|
||||
'common.errorDetails': 'Detaljer',
|
||||
'common.share': 'Dela',
|
||||
};
|
||||
export default common;
|
||||
|
||||
@@ -79,5 +79,6 @@ const common: TranslationStrings = {
|
||||
'common.errorPluginTitle': 'ไม่สามารถแสดงปลั๊กอินนี้ได้',
|
||||
'common.needsConnection': 'การทำงานนี้ต้องใช้การเชื่อมต่อ ลองอีกครั้งเมื่อ TREK กลับมาออนไลน์',
|
||||
'common.errorDetails': 'รายละเอียด',
|
||||
'common.share': 'แชร์',
|
||||
};
|
||||
export default common;
|
||||
|
||||
@@ -81,5 +81,6 @@ const common: TranslationStrings = {
|
||||
'common.errorPluginTitle': 'Bu eklenti gösterilemedi',
|
||||
'common.needsConnection': 'Bunun için bağlantı gerekiyor. TREK yeniden çevrimiçi olduğunda tekrar dene.',
|
||||
'common.errorDetails': 'Ayrıntılar',
|
||||
'common.share': 'Paylaş',
|
||||
};
|
||||
export default common;
|
||||
|
||||
@@ -85,5 +85,6 @@ const common: TranslationStrings = {
|
||||
'common.errorPluginTitle': 'Не вдалося показати цей плагін',
|
||||
'common.needsConnection': "Для цього потрібне з'єднання. Спробуйте ще раз, коли TREK знову буде онлайн.",
|
||||
'common.errorDetails': 'Подробиці',
|
||||
'common.share': 'Поділитися',
|
||||
};
|
||||
export default common;
|
||||
|
||||
@@ -79,5 +79,6 @@ const common: TranslationStrings = {
|
||||
'common.errorPluginTitle': 'Không thể hiển thị plugin này',
|
||||
'common.needsConnection': 'Thao tác này cần kết nối. Hãy thử lại khi TREK trực tuyến trở lại.',
|
||||
'common.errorDetails': 'Chi tiết',
|
||||
'common.share': 'Chia sẻ',
|
||||
};
|
||||
export default common;
|
||||
|
||||
@@ -79,5 +79,6 @@ const common: TranslationStrings = {
|
||||
'common.errorPluginTitle': '無法顯示此外掛程式',
|
||||
'common.needsConnection': '此操作需要網路連線。請在 TREK 恢復連線後再試一次。',
|
||||
'common.errorDetails': '詳細資訊',
|
||||
'common.share': '分享',
|
||||
};
|
||||
export default common;
|
||||
|
||||
@@ -79,5 +79,6 @@ const common: TranslationStrings = {
|
||||
'common.errorPluginTitle': '无法显示此插件',
|
||||
'common.needsConnection': '此操作需要网络连接。请在 TREK 恢复联网后重试。',
|
||||
'common.errorDetails': '详细信息',
|
||||
'common.share': '分享',
|
||||
};
|
||||
export default common;
|
||||
|
||||
Reference in New Issue
Block a user