mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2654e651c7 | |||
| ce1fa68c84 |
@@ -47,6 +47,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource/bebas-neue": "^5.3.0",
|
||||
"@fontsource/creepster": "^5.3.0",
|
||||
"@fontsource/eb-garamond": "^5.3.0",
|
||||
"@fontsource/geist-sans": "^5.3.0",
|
||||
"@fontsource/inter": "^5.3.0",
|
||||
|
||||
@@ -24,6 +24,8 @@
|
||||
if (isShared) dark = false;
|
||||
else if (s.darkMode === 'auto') dark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
else dark = s.darkMode === true || s.darkMode === 'dark';
|
||||
// Special themes (APPEARANCE_SPECIAL_SCHEMES in @trek/shared) are dark-only.
|
||||
if (!isShared && s.scheme === 'halloween') dark = true;
|
||||
root.classList.toggle('dark', dark);
|
||||
|
||||
var scheme = isShared ? 'default' : s.scheme;
|
||||
|
||||
@@ -269,7 +269,7 @@
|
||||
"mobile/screens/trip/sheets/PlSheetChrome.tsx": 7,
|
||||
"mobile/screens/trip/sheets/PlTimeFields.tsx": 3,
|
||||
"mobile/screens/trip/tabs/MBagsSheet.tsx": 13,
|
||||
"mobile/screens/trip/tabs/MBookingsTab.tsx": 2,
|
||||
"mobile/screens/trip/tabs/MBookingsTab.tsx": 3,
|
||||
"mobile/screens/trip/tabs/MCollabChat.tsx": 22,
|
||||
"mobile/screens/trip/tabs/MCollabNotes.tsx": 19,
|
||||
"mobile/screens/trip/tabs/MCollabPolls.tsx": 18,
|
||||
|
||||
@@ -33,6 +33,7 @@ import OfflineBanner from './components/Layout/OfflineBanner'
|
||||
import NewVersionNotice from './components/Layout/NewVersionNotice'
|
||||
import { SystemNoticeHost } from './components/SystemNotices/SystemNoticeHost.js'
|
||||
import HelpPanel from './components/Help/HelpPanel'
|
||||
import SpecialThemeLayer from './theme/special/SpecialThemeLayer'
|
||||
// Notice action registrations (side-effect imports):
|
||||
import './pages/Trips/noticeActions.js'
|
||||
import { managedRoutes } from './managed'
|
||||
@@ -405,6 +406,7 @@ export default function App() {
|
||||
{!hideAuthedWidgets && (isPhone ? <MSaveToCollectionSheet /> : <SaveToCollectionModal />)}
|
||||
<ErrorBoundary boundaryId="widget:offline-banner" fallback={null}><OfflineBanner /></ErrorBoundary>
|
||||
<ErrorBoundary boundaryId="widget:new-version" fallback={null}><NewVersionNotice /></ErrorBoundary>
|
||||
<ErrorBoundary boundaryId="widget:special-theme" fallback={null}><SpecialThemeLayer /></ErrorBoundary>
|
||||
{/* One boundary for all route chunks, above <Routes> so it stays mounted
|
||||
across navigations. react-router runs location updates inside a transition,
|
||||
so a mounted boundary keeps the current page on screen instead of flashing
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import { useId, useState } from 'react'
|
||||
import { AlertTriangle, Check, Loader2, Plug, ShieldAlert } from 'lucide-react'
|
||||
import { DialogButton, DialogFooter, DialogHeader, DialogShell, DialogTile, FooterSpacer, NEUTRAL_TINT } from '../../shared/DialogShell'
|
||||
import { Tooltip } from '../../shared/Tooltip'
|
||||
import ToggleSwitch from '../../Settings/ToggleSwitch'
|
||||
import { useTranslation } from '../../../i18n/TranslationContext'
|
||||
import { useConnectForm } from './useConnectForm'
|
||||
import { Badge } from './DocSyncBits'
|
||||
import { DOCUMENT_PROVIDER_ICONS } from '../../shared/DocumentProviderIcons'
|
||||
import type { DocSyncProvider, DocSyncVerdict, useDocSync } from './useDocSync'
|
||||
import type { DocSyncProvider, useDocSync } from './useDocSync'
|
||||
|
||||
/**
|
||||
* Connecting a store, as a dialog rather than a panel that unfolds in place.
|
||||
@@ -33,7 +32,7 @@ export default function DocSyncConnectModal({
|
||||
const Icon = DOCUMENT_PROVIDER_ICONS[provider.id]
|
||||
|
||||
const form = useConnectForm(provider, existing, sync)
|
||||
const [verdict, setVerdict] = useState<DocSyncVerdict | null>(null)
|
||||
const [verdict, setVerdict] = useState<{ connected: boolean; account?: string; error?: string } | null>(null)
|
||||
|
||||
const test = async () => setVerdict(await form.probe())
|
||||
const labelId = useId()
|
||||
@@ -135,7 +134,7 @@ function Verdict({
|
||||
busy,
|
||||
failure,
|
||||
}: {
|
||||
verdict: DocSyncVerdict | null
|
||||
verdict: { connected: boolean; account?: string; error?: string } | null
|
||||
busy: boolean
|
||||
/** A call that failed outside the connection test (saving, most of all). */
|
||||
failure: string | null
|
||||
@@ -173,16 +172,10 @@ function Verdict({
|
||||
)
|
||||
}
|
||||
const blocked = verdict.error === 'ssrf_blocked' || verdict.error === 'tls_untrusted'
|
||||
const message = t(`docsync.error.${verdict.error || 'unknown'}`)
|
||||
// The footer shares its row with the buttons, and a refusal cut to "The
|
||||
// credentials were re..." with no way to read the rest left nothing to go on
|
||||
// (#2666). Two lines, and the whole of it with the store's reason on hover.
|
||||
return (
|
||||
<Tooltip label={verdict.detail ? `${message} ${verdict.detail}` : message} placement="top">
|
||||
<span className="flex min-w-0 items-center gap-2 text-caption text-danger">
|
||||
{blocked ? <ShieldAlert size={14} className="flex-none" /> : <AlertTriangle size={14} className="flex-none" />}
|
||||
<span className="line-clamp-2 [overflow-wrap:anywhere]">{message}</span>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<span className="flex min-w-0 items-center gap-2 text-caption text-danger">
|
||||
{blocked ? <ShieldAlert size={14} /> : <AlertTriangle size={14} />}
|
||||
<span className="truncate">{t(`docsync.error.${verdict.error || 'unknown'}`)}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'react'
|
||||
import type { DocSyncConnection, DocSyncProvider, DocSyncVerdict, useDocSync } from './useDocSync'
|
||||
import type { DocSyncConnection, DocSyncProvider, useDocSync } from './useDocSync'
|
||||
|
||||
/**
|
||||
* The credential form's rules, for both shells.
|
||||
@@ -39,7 +39,7 @@ export interface ConnectForm {
|
||||
setInsecureTls: (on: boolean) => void
|
||||
/** Every required field is either filled in or already stored. */
|
||||
complete: boolean
|
||||
probe: () => Promise<DocSyncVerdict>
|
||||
probe: () => Promise<{ connected: boolean; account?: string; error?: string }>
|
||||
save: () => Promise<boolean>
|
||||
}
|
||||
|
||||
|
||||
@@ -12,14 +12,6 @@ import { useServerPing } from '../../../sync/useServerPing'
|
||||
* reason for the same problem.
|
||||
*/
|
||||
|
||||
/** What a connection test answers; `detail` is the store's own reason, for the person fixing it. */
|
||||
export interface DocSyncVerdict {
|
||||
connected: boolean
|
||||
account?: string
|
||||
error?: string
|
||||
detail?: string
|
||||
}
|
||||
|
||||
export interface DocSyncField {
|
||||
field_key: string
|
||||
label: string
|
||||
@@ -272,9 +264,11 @@ export function useDocSync(tripId: number | string, enabled: boolean) {
|
||||
const testConnection = useCallback(async (providerId: string, baseUrl: string, credentials: Record<string, string>, allowInsecureTls: boolean) => {
|
||||
setBusy('test')
|
||||
try {
|
||||
return await docsyncApi.testConnection(tripId, { providerId, baseUrl, credentials, allowInsecureTls }) as DocSyncVerdict
|
||||
return await docsyncApi.testConnection(tripId, { providerId, baseUrl, credentials, allowInsecureTls }) as {
|
||||
connected: boolean; account?: string; error?: string; detail?: string
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
return { connected: false, error: readError(e) } satisfies DocSyncVerdict
|
||||
return { connected: false, error: readError(e) }
|
||||
} finally {
|
||||
setBusy(null)
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { resolvePluginIcon } from '../shared/PluginIcon'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { visibleManagedNavItems } from '../../managed'
|
||||
import { isNavItemActive, useCreateAction } from './useCreateAction'
|
||||
import { useIsDark } from '../../hooks/useIsDark'
|
||||
|
||||
const ADDON_NAV: Record<string, { icon: LucideIcon; labelKey: string }> = {
|
||||
vacay: { icon: CalendarDays, labelKey: 'admin.addons.catalog.vacay.name' },
|
||||
@@ -22,8 +23,8 @@ interface NavItem { to: string; label: string; icon: LucideIcon }
|
||||
export default function BottomNav() {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const darkMode = useSettingsStore(s => s.settings.dark_mode)
|
||||
const dark = darkMode === true || darkMode === 'dark' || (darkMode === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||
// Off the .dark class: a special theme is dark whatever the stored mode says.
|
||||
const dark = useIsDark()
|
||||
const addons = useAddonStore(s => s.addons)
|
||||
const globalAddons = addons.filter(a => a.type === 'global' && a.enabled)
|
||||
// Page plugins are reachable from the mobile tab bar too, mirroring the desktop
|
||||
|
||||
@@ -7,13 +7,14 @@ import { useInAppNotificationStore } from '../../store/inAppNotificationStore.ts
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import InAppNotificationItem from '../Notifications/InAppNotificationItem.tsx'
|
||||
import { useIsDark } from '../../hooks/useIsDark'
|
||||
|
||||
export default function InAppNotificationBell(): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const { settings } = useSettingsStore()
|
||||
const darkMode = settings.dark_mode
|
||||
const dark = darkMode === true || darkMode === 'dark' || (darkMode === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||
// Off the .dark class: a special theme is dark whatever the stored mode says.
|
||||
const dark = useIsDark()
|
||||
|
||||
const isAuthenticated = useAuthStore(s => s.isAuthenticated)
|
||||
const { notifications, unreadCount, isLoading, fetchNotifications, fetchUnreadCount, markAllRead, deleteAll } = useInAppNotificationStore()
|
||||
|
||||
@@ -13,6 +13,7 @@ import HelpButton from '../Help/HelpButton'
|
||||
import { resolvePluginIcon } from '../shared/PluginIcon'
|
||||
import { visibleManagedNavItems } from '../../managed'
|
||||
import { useCollapseOnOverflow } from '../../hooks/useCollapseOnOverflow'
|
||||
import { useIsDark } from '../../hooks/useIsDark'
|
||||
|
||||
const ADDON_ICONS: Record<string, LucideIcon> = { CalendarDays, Briefcase, Globe, Compass, Bookmark }
|
||||
|
||||
@@ -41,8 +42,8 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
|
||||
const location = useLocation()
|
||||
const [userMenuOpen, setUserMenuOpen] = useState<boolean>(false)
|
||||
const [scrolled, setScrolled] = useState<boolean>(false)
|
||||
const darkMode = settings.dark_mode
|
||||
const dark = darkMode === true || darkMode === 'dark' || (darkMode === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||
// Off the .dark class: a special theme is dark whatever the stored mode says.
|
||||
const dark = useIsDark()
|
||||
|
||||
useEffect(() => {
|
||||
const onScroll = () => setScrolled(window.scrollY > 8 || (document.body.scrollTop || 0) > 8)
|
||||
@@ -121,7 +122,7 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
|
||||
paddingTop: 'env(safe-area-inset-top, 0px)',
|
||||
height: 'var(--nav-h)',
|
||||
transition: 'background 240ms cubic-bezier(0.23,1,0.32,1), backdrop-filter 240ms cubic-bezier(0.23,1,0.32,1), box-shadow 240ms cubic-bezier(0.23,1,0.32,1)',
|
||||
}} className="hidden md:flex items-center px-4 gap-4 fixed top-0 inset-x-0 z-[200]">
|
||||
}} className="trek-navbar hidden md:flex items-center px-4 gap-4 fixed top-0 inset-x-0 z-[200]">
|
||||
{/* Left side. flex-1 basis-0, matching the action cluster on the right, so
|
||||
the tab pill between them sits in the middle of the bar rather than
|
||||
being centred on top of both (#1983). Both columns keep at least their
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { calculateRouteWithLegs, RoutingRefusedError } from './RouteCalculator';
|
||||
import { calculateRouteWithRetries, ROUTE_RETRY_DELAYS_MS } from './routeRetry';
|
||||
|
||||
vi.mock('./RouteCalculator', async (importActual) => {
|
||||
const actual = await importActual<typeof import('./RouteCalculator')>();
|
||||
return { ...actual, calculateRouteWithLegs: vi.fn() };
|
||||
});
|
||||
|
||||
const WAYPOINTS = [
|
||||
{ lat: 47.5, lng: 19.04 },
|
||||
{ lat: 47.49, lng: 19.05 },
|
||||
];
|
||||
const ROUTED = {
|
||||
coordinates: [
|
||||
[47.5, 19.04],
|
||||
[47.49, 19.05],
|
||||
],
|
||||
legs: [],
|
||||
} as unknown as Awaited<ReturnType<typeof calculateRouteWithLegs>>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.mocked(calculateRouteWithLegs).mockReset();
|
||||
});
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
describe('calculateRouteWithRetries', () => {
|
||||
it('waits out a rate limit and answers with the road (Discord, 16.10.2026)', async () => {
|
||||
vi.mocked(calculateRouteWithLegs)
|
||||
.mockRejectedValueOnce(new RoutingRefusedError(429, null))
|
||||
.mockResolvedValueOnce(ROUTED);
|
||||
|
||||
const answer = calculateRouteWithRetries(WAYPOINTS, { profile: 'driving' });
|
||||
await vi.advanceTimersByTimeAsync(ROUTE_RETRY_DELAYS_MS[0]);
|
||||
|
||||
await expect(answer).resolves.toBe(ROUTED);
|
||||
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('waits as long as the host asks when that is longer', async () => {
|
||||
vi.mocked(calculateRouteWithLegs)
|
||||
.mockRejectedValueOnce(new RoutingRefusedError(503, 3000))
|
||||
.mockResolvedValueOnce(ROUTED);
|
||||
|
||||
const answer = calculateRouteWithRetries(WAYPOINTS, {});
|
||||
await vi.advanceTimersByTimeAsync(ROUTE_RETRY_DELAYS_MS[0]);
|
||||
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(1);
|
||||
await vi.advanceTimersByTimeAsync(3000 - ROUTE_RETRY_DELAYS_MS[0]);
|
||||
|
||||
await expect(answer).resolves.toBe(ROUTED);
|
||||
});
|
||||
|
||||
it('gives up after the last delay and throws the refusal', async () => {
|
||||
vi.mocked(calculateRouteWithLegs).mockRejectedValue(new RoutingRefusedError(429, null));
|
||||
|
||||
const answer = calculateRouteWithRetries(WAYPOINTS, {});
|
||||
const settled = expect(answer).rejects.toBeInstanceOf(RoutingRefusedError);
|
||||
for (const ms of ROUTE_RETRY_DELAYS_MS) await vi.advanceTimersByTimeAsync(ms);
|
||||
|
||||
await settled;
|
||||
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(ROUTE_RETRY_DELAYS_MS.length + 1);
|
||||
});
|
||||
|
||||
it('throws a refusal the host meant at once', async () => {
|
||||
vi.mocked(calculateRouteWithLegs).mockRejectedValue(new RoutingRefusedError(400, null));
|
||||
|
||||
await expect(calculateRouteWithRetries(WAYPOINTS, {})).rejects.toBeInstanceOf(RoutingRefusedError);
|
||||
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('stops waiting when the day changes', async () => {
|
||||
const controller = new AbortController();
|
||||
vi.mocked(calculateRouteWithLegs)
|
||||
.mockRejectedValueOnce(new RoutingRefusedError(429, null))
|
||||
.mockRejectedValueOnce(new DOMException('aborted', 'AbortError'));
|
||||
|
||||
const answer = calculateRouteWithRetries(WAYPOINTS, { signal: controller.signal });
|
||||
const settled = expect(answer).rejects.toMatchObject({ name: 'AbortError' });
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
controller.abort();
|
||||
|
||||
await settled;
|
||||
});
|
||||
});
|
||||
@@ -1,48 +0,0 @@
|
||||
import { calculateRouteWithLegs, RoutingRefusedError } from './RouteCalculator';
|
||||
|
||||
// The profile a caller hands in, so a hook routing through here needs nothing else from Map/.
|
||||
export type { RouteProfileKey } from './RouteCalculator';
|
||||
|
||||
/** How often a rate-limited route is asked for again, and how long after. */
|
||||
export const ROUTE_RETRY_DELAYS_MS = [1500, 4000];
|
||||
|
||||
/** A pause that ends early, without throwing, when the signal aborts. */
|
||||
export const sleepUnlessAborted = (ms: number, signal?: AbortSignal): Promise<void> =>
|
||||
new Promise((resolve) => {
|
||||
if (signal?.aborted || ms <= 0) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(done, ms);
|
||||
function done(): void {
|
||||
clearTimeout(timer);
|
||||
signal?.removeEventListener('abort', done);
|
||||
resolve();
|
||||
}
|
||||
signal?.addEventListener('abort', done, { once: true });
|
||||
});
|
||||
|
||||
/**
|
||||
* `calculateRouteWithLegs`, asked again while the host says it is busy.
|
||||
*
|
||||
* The public OSRM hosts allow about one request a second and answer 429 above that. A
|
||||
* day with a change of mode, or a quick switch between days, asks more often than that,
|
||||
* and the day route used to give up on the first refusal and keep its straight lines
|
||||
* until the next edit (reported on Discord, 16.10.2026). A rate limit is waited out, for
|
||||
* as long as the host asks when it says; a refusal the host meant is thrown at once,
|
||||
* because the same request earns the same answer. An abort ends the wait, and the next
|
||||
* attempt then throws the AbortError the caller already handles.
|
||||
*/
|
||||
export async function calculateRouteWithRetries(
|
||||
...[waypoints, opts]: Parameters<typeof calculateRouteWithLegs>
|
||||
): ReturnType<typeof calculateRouteWithLegs> {
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
try {
|
||||
return await calculateRouteWithLegs(waypoints, opts);
|
||||
} catch (err) {
|
||||
const rateLimit = err instanceof RoutingRefusedError && err.isRateLimit ? err : null;
|
||||
if (!rateLimit || attempt >= ROUTE_RETRY_DELAYS_MS.length || opts?.signal?.aborted) throw err;
|
||||
await sleepUnlessAborted(Math.max(ROUTE_RETRY_DELAYS_MS[attempt], rateLimit.retryAfterMs ?? 0), opts?.signal);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { RouteProfileKey } from './RouteCalculator'
|
||||
import { calculateRouteWithRetries, sleepUnlessAborted as sleep } from './routeRetry'
|
||||
import { calculateRouteWithLegs, RoutingRefusedError, type RouteProfileKey } from './RouteCalculator'
|
||||
import { buildDayRouteRuns, type DayRouteInputs, type DayRoutePoint } from './dayRoutePlan'
|
||||
import { dayViaLookup, foldViaLegs, threadVias, viasOnDayLeg, type DayViaLookup } from '../../utils/dayRouteVias'
|
||||
import { projectDayItinerary } from './dayTourProjection'
|
||||
@@ -186,6 +185,20 @@ export function summariseTripRoute(days: TripOverviewDay[]): TripRouteSummary {
|
||||
const REQUEST_SPACING_MS = 1100
|
||||
/** Anything answered faster than this came out of RouteCalculator's cache, not the network. */
|
||||
const CACHE_HIT_MS = 60
|
||||
/** How often a rate-limited leg is tried again, and how long after. */
|
||||
const RETRY_DELAYS_MS = [1500, 4000]
|
||||
|
||||
const sleep = (ms: number, signal?: AbortSignal): Promise<void> =>
|
||||
new Promise(resolve => {
|
||||
if (signal?.aborted || ms <= 0) { resolve(); return }
|
||||
const timer = setTimeout(done, ms)
|
||||
function done(): void {
|
||||
clearTimeout(timer)
|
||||
signal?.removeEventListener('abort', done)
|
||||
resolve()
|
||||
}
|
||||
signal?.addEventListener('abort', done, { once: true })
|
||||
})
|
||||
|
||||
/**
|
||||
* Ask the router for every leg of every day, one at a time.
|
||||
@@ -220,16 +233,23 @@ export async function routeTripLegs(
|
||||
runs.forEach((chunks, r) => {
|
||||
chunks.forEach((chunk, c) => {
|
||||
tasks.push(async () => {
|
||||
if (signal?.aborted) return
|
||||
try {
|
||||
const answer = await calculateRouteWithRetries(
|
||||
chunk.waypoints,
|
||||
{ signal, profile: chunk.mode, tripId, dayId: day.id },
|
||||
)
|
||||
routed[d][r][c] = { coordinates: answer.coordinates, legs: foldViaLegs(answer.legs, chunk.stopAt, unit) }
|
||||
onAnswer?.(routed)
|
||||
} catch {
|
||||
// Refused for good, or aborted: the leg keeps its straight line.
|
||||
for (let attempt = 0; attempt <= RETRY_DELAYS_MS.length; attempt++) {
|
||||
if (signal?.aborted) return
|
||||
try {
|
||||
const answer = await calculateRouteWithLegs(
|
||||
chunk.waypoints,
|
||||
{ signal, profile: chunk.mode, tripId, dayId: day.id },
|
||||
)
|
||||
routed[d][r][c] = { coordinates: answer.coordinates, legs: foldViaLegs(answer.legs, chunk.stopAt, unit) }
|
||||
onAnswer?.(routed)
|
||||
return
|
||||
} catch (err) {
|
||||
if (signal?.aborted) return
|
||||
const rateLimit = err instanceof RoutingRefusedError && err.isRateLimit ? err : null
|
||||
if (!rateLimit || attempt === RETRY_DELAYS_MS.length) return
|
||||
// When the host says how long to wait, waiting less is just a second refusal.
|
||||
await sleep(Math.max(RETRY_DELAYS_MS[attempt], rateLimit.retryAfterMs ?? 0), signal)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useTranslation } from '../../i18n'
|
||||
import type { InAppNotification } from '../../store/inAppNotificationStore'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { compactTime, useNotificationItemActions } from './useNotificationItemActions'
|
||||
import { useIsDark } from '../../hooks/useIsDark'
|
||||
|
||||
function relativeTime(dateStr: string, locale: string): string {
|
||||
return compactTime(dateStr, locale === 'ar' ? 'الآن' : 'just now')
|
||||
@@ -17,8 +18,8 @@ interface NotificationItemProps {
|
||||
export default function InAppNotificationItem({ notification, onClose }: NotificationItemProps): React.ReactElement {
|
||||
const { t, locale } = useTranslation()
|
||||
const { settings } = useSettingsStore()
|
||||
const darkMode = settings.dark_mode
|
||||
const dark = darkMode === true || darkMode === 'dark' || (darkMode === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||
// Off the .dark class: a special theme is dark whatever the stored mode says.
|
||||
const dark = useIsDark()
|
||||
const { responding, handleRespond, handleNavigate, markRead, deleteNotification } =
|
||||
useNotificationItemActions(notification, onClose)
|
||||
|
||||
|
||||
@@ -271,9 +271,7 @@ describe('PackingListPanel', () => {
|
||||
];
|
||||
const { container } = render(<PackingListPanel tripId={1} items={items} />);
|
||||
expect(screen.getByRole('button', { name: 'A to Z' })).toHaveAttribute('aria-pressed', 'true');
|
||||
// Only the category grip stays: the order of the categories is not the A to Z of their items.
|
||||
const draggable = [...container.querySelectorAll('[draggable="true"]')];
|
||||
expect(draggable.map((el) => el.getAttribute('aria-label'))).toEqual(['Drag to reorder the category']);
|
||||
expect(container.querySelector('[draggable="true"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-COMP-PACKING-023: inline edit item name via pencil icon calls PUT', async () => {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useToast } from '../shared/Toast'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import {
|
||||
Trash2, Plus, ChevronDown, ChevronRight,
|
||||
X, Pencil, Check, MoreHorizontal, CheckCheck, RotateCcw, UserPlus, GripVertical,
|
||||
X, Pencil, Check, MoreHorizontal, CheckCheck, RotateCcw, UserPlus,
|
||||
} from 'lucide-react'
|
||||
import type { PackingItem, PackingBag } from '../../types'
|
||||
import { katColor } from './packingListPanel.helpers'
|
||||
@@ -38,16 +38,6 @@ interface KategorieGruppeProps {
|
||||
// Off while the list is shown A-Z: a drop there would move an item in an order
|
||||
// the reader cannot see.
|
||||
reorderable?: boolean
|
||||
// Moving the whole category among the others, wired by the list, which sees every
|
||||
// card. `onDrop` gets the category that was dragged onto this one.
|
||||
categoryDrag?: {
|
||||
isDragging: boolean
|
||||
isOver: boolean
|
||||
onStart: () => void
|
||||
onOver: () => void
|
||||
onEnd: () => void
|
||||
onDrop: (dragged: string) => void
|
||||
}
|
||||
// Three-tier sharing (#858) — threaded down to each item's share control.
|
||||
currentUserId?: number
|
||||
onSetSharing?: (id: number, visibility: 'common' | 'personal' | 'shared', recipientIds: number[]) => void
|
||||
@@ -56,7 +46,7 @@ interface KategorieGruppeProps {
|
||||
onLeave?: (id: number, userId: number) => void
|
||||
}
|
||||
|
||||
export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, onDeleteAll, onDeleteItem, onAddItem, assignees, tripMembers, onSetAssignees, bagTrackingEnabled, bags, onCreateBag, canEdit = true, allItems, onReorder, reorderable = true, categoryDrag, currentUserId, onSetSharing, onClone, onJoin, onLeave }: KategorieGruppeProps) {
|
||||
export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, onDeleteAll, onDeleteItem, onAddItem, assignees, tripMembers, onSetAssignees, bagTrackingEnabled, bags, onCreateBag, canEdit = true, allItems, onReorder, reorderable = true, currentUserId, onSetSharing, onClone, onJoin, onLeave }: KategorieGruppeProps) {
|
||||
const [offen, setOffen] = useState(true)
|
||||
const [dragId, setDragId] = useState<number | null>(null)
|
||||
const [overId, setOverId] = useState<number | null>(null)
|
||||
@@ -130,9 +120,8 @@ export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRen
|
||||
return (
|
||||
// Shaped after the phone's category card: a tinted head band carrying the
|
||||
// name as written, the count, and the fold arrow on the far right.
|
||||
<CategoryDropFrame drag={categoryDrag}>
|
||||
<div style={{ marginBottom: 6, background: 'var(--bg-card)', borderRadius: 16, border: '1px solid var(--border-secondary)', overflow: 'visible' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, paddingBlock: 11, paddingInlineEnd: 12, paddingInlineStart: 14, background: 'var(--bg-tertiary)', borderRadius: offen ? '15px 15px 0 0' : 15 }}>
|
||||
<CategoryGrip drag={categoryDrag} kategorie={kategorie} label={t('packing.dragCategory')} />
|
||||
<span style={{ width: 10, height: 10, borderRadius: '50%', background: dot, flexShrink: 0 }} />
|
||||
|
||||
{editingName && canEdit ? (
|
||||
@@ -355,65 +344,6 @@ export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRen
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CategoryDropFrame>
|
||||
)
|
||||
}
|
||||
|
||||
type CategoryDrag = NonNullable<KategorieGruppeProps['categoryDrag']>
|
||||
|
||||
/** The data type a dragged category carries, its name as the value. */
|
||||
const CATEGORY_DRAG_TYPE = 'application/x-trek-packing-category'
|
||||
|
||||
/**
|
||||
* The card, and while a category is dragged its drop target. Captured, because
|
||||
* the item rows stop their own drops from bubbling.
|
||||
*/
|
||||
function CategoryDropFrame({ drag, children }: { drag?: CategoryDrag; children: React.ReactNode }) {
|
||||
// Told apart by the drag's own data type, which is there from the first event on:
|
||||
// a flag set in state on dragstart can still be unrendered when a quick drag
|
||||
// arrives, and a dragged item must pass through to its rows untouched.
|
||||
const isCategoryDrag = (e: React.DragEvent) => e.dataTransfer.types.includes(CATEGORY_DRAG_TYPE)
|
||||
return (
|
||||
<div
|
||||
onDragOverCapture={drag ? (e => {
|
||||
if (!isCategoryDrag(e)) return
|
||||
e.preventDefault(); e.dataTransfer.dropEffect = 'move'; drag.onOver()
|
||||
}) : undefined}
|
||||
onDropCapture={drag ? (e => {
|
||||
if (!isCategoryDrag(e)) return
|
||||
e.preventDefault(); e.stopPropagation(); drag.onDrop(e.dataTransfer.getData(CATEGORY_DRAG_TYPE))
|
||||
}) : undefined}
|
||||
style={{
|
||||
marginBottom: 6, background: 'var(--bg-card)', borderRadius: 16, border: '1px solid var(--border-secondary)', overflow: 'visible',
|
||||
opacity: drag?.isDragging ? 0.4 : 1,
|
||||
boxShadow: drag?.isOver ? '0 0 0 2px var(--accent)' : 'none',
|
||||
transition: 'opacity 0.15s, box-shadow 0.15s',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** The handle a category is dragged by, in the head band before its colour dot. */
|
||||
function CategoryGrip({ drag, kategorie, label }: { drag?: CategoryDrag; kategorie: string; label: string }) {
|
||||
if (!drag) return null
|
||||
return (
|
||||
<span
|
||||
draggable
|
||||
role="button"
|
||||
aria-label={label}
|
||||
title={label}
|
||||
onDragStart={e => {
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
e.dataTransfer.setData(CATEGORY_DRAG_TYPE, kategorie)
|
||||
e.dataTransfer.setData('text/plain', kategorie)
|
||||
drag.onStart()
|
||||
}}
|
||||
onDragEnd={drag.onEnd}
|
||||
style={{ cursor: 'grab', display: 'flex', alignItems: 'center', color: 'var(--text-faint)', flexShrink: 0, marginInlineStart: -6 }}
|
||||
>
|
||||
<GripVertical size={14} />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useState } from 'react'
|
||||
import type { PackingState } from './usePackingListPanel'
|
||||
import { KategorieGruppe } from './PackingListPanelCategoryGroup'
|
||||
import EmptyState from '../shared/EmptyState'
|
||||
@@ -8,13 +7,8 @@ export function PackingList(S: PackingState) {
|
||||
items, gruppiert, t, tripId, allCategories, handleRenameCategory, handleDeleteCategory, handleDeleteItem,
|
||||
handleAddItemToCategory, categoryAssignees, tripMembers, handleSetAssignees,
|
||||
bagTrackingEnabled, bags, handleCreateBagByName, canEdit, reorderPackingItems,
|
||||
currentUserId, handleSetSharing, handleCloneItem, handleJoinItem, handleLeaveItem, sort, moveCategory,
|
||||
currentUserId, handleSetSharing, handleCloneItem, handleJoinItem, handleLeaveItem, sort,
|
||||
} = S
|
||||
// Which category is being dragged and which card it is over. Held here, above
|
||||
// the cards, because the drag starts in one card and ends in another.
|
||||
const [draggedCategory, setDraggedCategory] = useState<string | null>(null)
|
||||
const [overCategory, setOverCategory] = useState<string | null>(null)
|
||||
const endCategoryDrag = () => { setDraggedCategory(null); setOverCategory(null) }
|
||||
return (
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: '10px 0 16px' }}>
|
||||
{items.length === 0 ? (
|
||||
@@ -46,14 +40,6 @@ export function PackingList(S: PackingState) {
|
||||
allItems={items}
|
||||
onReorder={(orderedIds) => reorderPackingItems(tripId, orderedIds)}
|
||||
reorderable={sort === 'manual'}
|
||||
categoryDrag={canEdit ? {
|
||||
isDragging: draggedCategory === kat,
|
||||
isOver: overCategory === kat && draggedCategory !== null && draggedCategory !== kat,
|
||||
onStart: () => { setDraggedCategory(kat); setOverCategory(null) },
|
||||
onOver: () => setOverCategory(kat),
|
||||
onEnd: endCategoryDrag,
|
||||
onDrop: (dragged) => { moveCategory(dragged, kat); endCategoryDrag() },
|
||||
} : undefined}
|
||||
currentUserId={currentUserId}
|
||||
onSetSharing={handleSetSharing}
|
||||
onClone={handleCloneItem}
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
groupPackingItems,
|
||||
isLastCustomItemInCategory,
|
||||
isPackingPlaceholder,
|
||||
itemOrderWithCategoryMoved,
|
||||
packingCategoryOrder,
|
||||
packingItemWeight,
|
||||
packingProgress,
|
||||
@@ -15,7 +14,7 @@ import {
|
||||
} from './packingListModel';
|
||||
import { PACKING_PLACEHOLDER_NAME } from './packingListPanel.constants';
|
||||
|
||||
// FE-PACK-MODEL-001 to FE-PACK-MODEL-009
|
||||
// FE-PACK-MODEL-001 to FE-PACK-MODEL-008
|
||||
|
||||
const shared = buildPackingItem({ id: 1, name: 'Tent', category: 'Camp', checked: 1 });
|
||||
const mine = buildPackingItem({ id: 2, name: 'Socks', category: 'Clothes', is_private: 1 });
|
||||
@@ -77,28 +76,4 @@ describe('packingListModel', () => {
|
||||
expect(isPackingPlaceholder({ name: PACKING_PLACEHOLDER_NAME })).toBe(true);
|
||||
expect(isPackingPlaceholder({ name: 'Tent' })).toBe(false);
|
||||
});
|
||||
it('FE-PACK-MODEL-009: moving a category moves its items as a block, the other view stays as it was', () => {
|
||||
const day1 = buildPackingItem({ id: 11, name: 'Shirt', category: 'Day 1' });
|
||||
const day2a = buildPackingItem({ id: 12, name: 'Shorts', category: 'Day 2' });
|
||||
const own = buildPackingItem({ id: 13, name: 'Pills', category: 'Day 2', is_private: 1 });
|
||||
const day3 = buildPackingItem({ id: 14, name: 'Jacket', category: 'Day 3' });
|
||||
const day2b = buildPackingItem({ id: 15, name: 'Hat', category: 'Day 2' });
|
||||
const items = [day1, day2a, own, day3, day2b];
|
||||
const common = packingViewItems(items, 'common');
|
||||
|
||||
const order = itemOrderWithCategoryMoved(items, common, 'Day 3', 'Day 1', 'Other');
|
||||
expect(order).toEqual([14, 11, 12, 15, 13]);
|
||||
const moved = (order ?? []).flatMap((id) => items.filter((i) => i.id === id));
|
||||
expect(packingCategoryOrder(packingViewItems(moved, 'common'), 'Other')).toEqual(['Day 3', 'Day 1', 'Day 2']);
|
||||
expect(packingCategoryOrder(packingViewItems(moved, 'personal'), 'Other')).toEqual(['Day 2']);
|
||||
|
||||
expect(itemOrderWithCategoryMoved(items, common, 'Day 1', 'Day 3', 'Other')).toEqual([12, 15, 14, 11, 13]);
|
||||
});
|
||||
|
||||
it('FE-PACK-MODEL-009b: nothing moves for the same category or one the view does not show', () => {
|
||||
const items = [shared, stove, loose];
|
||||
expect(itemOrderWithCategoryMoved(items, items, 'Camp', 'Camp', 'Other')).toBeNull();
|
||||
expect(itemOrderWithCategoryMoved(items, items, 'Clothes', 'Camp', 'Other')).toBeNull();
|
||||
expect(itemOrderWithCategoryMoved(items, items, 'Other', 'Camp', 'Other')).toEqual([4, 1, 3]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,34 +25,6 @@ export function packingCategoryOrder(viewItems: PackingItem[], defaultCategory:
|
||||
return seen;
|
||||
}
|
||||
|
||||
/**
|
||||
* The whole list's item order with category `from` moved to where `to` stands.
|
||||
*
|
||||
* A category has no position of its own: it shows where its first item is. So a
|
||||
* category is moved by moving its items, as one block, through the item reorder
|
||||
* the list already has; nothing new is stored, and the order reaches every device
|
||||
* the way a dragged item does. The view's items come first, grouped in the new
|
||||
* order with each group in its own order; the other view's items follow in theirs,
|
||||
* which leaves that view's categories as they were. Null when nothing would move.
|
||||
*/
|
||||
export function itemOrderWithCategoryMoved(
|
||||
items: PackingItem[],
|
||||
viewItems: PackingItem[],
|
||||
from: string,
|
||||
to: string,
|
||||
defaultCategory: string
|
||||
): number[] | null {
|
||||
const order = packingCategoryOrder(viewItems, defaultCategory);
|
||||
const fromIndex = order.indexOf(from);
|
||||
const toIndex = order.indexOf(to);
|
||||
if (fromIndex < 0 || toIndex < 0 || fromIndex === toIndex) return null;
|
||||
order.splice(fromIndex, 1);
|
||||
order.splice(toIndex, 0, from);
|
||||
const inView = new Set(viewItems.map((i) => i.id));
|
||||
const grouped = order.flatMap((cat) => viewItems.filter((i) => (i.category || defaultCategory) === cat));
|
||||
return [...grouped, ...items.filter((i) => !inView.has(i.id))].map((i) => i.id);
|
||||
}
|
||||
|
||||
export function packingStatusFiltered(items: PackingItem[], status: PackingStatusFilter): PackingItem[] {
|
||||
if (status === 'open') return items.filter((i) => !i.checked);
|
||||
if (status === 'done') return items.filter((i) => !!i.checked);
|
||||
|
||||
@@ -8,7 +8,7 @@ import { tripsApi } from '../../api/client'
|
||||
import { useAddonStore } from '../../store/addonStore'
|
||||
import type { PackingItem } from '../../types'
|
||||
import { parseImportLines, sortItemsByName } from './packingListPanel.helpers'
|
||||
import { groupPackingItems, itemOrderWithCategoryMoved, packingCategoryOrder, packingProgress, packingViewItems } from './packingListModel'
|
||||
import { groupPackingItems, packingCategoryOrder, packingProgress, packingViewItems } from './packingListModel'
|
||||
import { usePackingBags } from './usePackingBags'
|
||||
import { usePackingCategoryAssignees } from './usePackingCategoryAssignees'
|
||||
import { usePackingImport } from './usePackingImport'
|
||||
@@ -106,13 +106,6 @@ export function usePackingList({ tripId, items, openImportSignal = 0, addCategor
|
||||
|
||||
const { checked: abgehakt, pct: fortschritt } = packingProgress(viewItems)
|
||||
|
||||
// A category dropped onto another takes its place; the slice reorders
|
||||
// optimistically and rolls back with a toast of its own.
|
||||
const moveCategory = (from: string, to: string) => {
|
||||
const orderedIds = itemOrderWithCategoryMoved(items, viewItems, from, to, t('packing.defaultCategory'))
|
||||
if (orderedIds) void reorderPackingItems(tripId, orderedIds)
|
||||
}
|
||||
|
||||
const {
|
||||
addingCategory, setAddingCategory, newCategoryName: newCatName, setNewCategoryName: setNewCatName,
|
||||
addItemToCategory: handleAddItemToCategory, deleteItem: handleDeleteItem, addNewCategory: handleAddNewCategory,
|
||||
@@ -220,7 +213,7 @@ export function usePackingList({ tripId, items, openImportSignal = 0, addCategor
|
||||
handleSetSharing, handleCloneItem, handleJoinItem, handleLeaveItem,
|
||||
tripId, items, inlineHeader, t, canEdit, isAdmin, font, reorderPackingItems,
|
||||
filter, setFilter, sort, setSort, addingCategory, setAddingCategory, newCatName, setNewCatName,
|
||||
tripMembers, categoryAssignees, handleSetAssignees, allCategories, gruppiert, abgehakt, fortschritt, moveCategory,
|
||||
tripMembers, categoryAssignees, handleSetAssignees, allCategories, gruppiert, abgehakt, fortschritt,
|
||||
handleAddItemToCategory, handleAddNewCategory, handleRenameCategory, handleDeleteCategory, handleDeleteItem, handleClearChecked,
|
||||
bagTrackingEnabled, bags, unassignedWeightGrams, serverWeightsFresh, newBagName, setNewBagName, showAddBag, setShowAddBag, showBagModal, setShowBagModal,
|
||||
handleCreateBag, handleCreateBagByName, handleDeleteBag, handleUpdateBag, handleSetBagMembers,
|
||||
|
||||
@@ -308,10 +308,9 @@ export default function AccountTab(): React.ReactElement {
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="text"
|
||||
autoComplete="one-time-code"
|
||||
inputMode="numeric"
|
||||
value={mfaDisableCode}
|
||||
onChange={e => setMfaDisableCode(e.target.value.replace(/[^a-zA-Z0-9-]/g, '').slice(0, 12))}
|
||||
onChange={e => setMfaDisableCode(e.target.value.replace(/\D/g, '').slice(0, 8))}
|
||||
placeholder={t('settings.mfa.codePlaceholder')}
|
||||
aria-label={t('settings.mfa.codePlaceholder')}
|
||||
className={`${INPUT} font-geist tabular-nums`}
|
||||
|
||||
@@ -6,7 +6,8 @@ import { Segmented } from '../shared/dialogParts'
|
||||
import Section from './Section'
|
||||
import ToggleSwitch from './ToggleSwitch'
|
||||
import { SETTINGS_BUTTON, SettingRow, SettingRows, StatusPill } from './settingsKit'
|
||||
import { APPEARANCE_SCHEMES, CUSTOM_ACCENT_PRESETS } from '../../theme/schemes'
|
||||
import { APPEARANCE_SCHEMES, CUSTOM_ACCENT_PRESETS, SPECIAL_THEMES } from '../../theme/schemes'
|
||||
import SpecialThemeCard from '../../theme/special/SpecialThemeCard'
|
||||
import { APPEARANCE_SCALE_MIN, APPEARANCE_SCALE_MAX } from '@trek/shared'
|
||||
import { DESKTOP_GROUPS, MOBILE_GROUPS, isHex } from './appearanceModel'
|
||||
import { useAppearanceEditor } from './useAppearanceEditor'
|
||||
@@ -35,7 +36,7 @@ const DOT = 'h-4 w-4 flex-none rounded-full shadow-[inset_0_0_0_1px_var(--border
|
||||
export default function AppearanceSettingsTab(): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const tr = (key: string, fallback: string) => t(key) || fallback
|
||||
const { cfg, darkMode, isDark, update, setMode, setWidget, resetAll, accentLight, accentDark, customRatio } =
|
||||
const { cfg, darkMode, isDark, modeLocked, update, setMode, setWidget, resetAll, accentLight, accentDark, customRatio } =
|
||||
useAppearanceEditor()
|
||||
|
||||
// The stored mode, read the way the old boolean values meant it.
|
||||
@@ -50,7 +51,9 @@ export default function AppearanceSettingsTab(): React.ReactElement {
|
||||
{/* Color mode */}
|
||||
<SettingRow
|
||||
label={tr('settings.colorMode', 'Color mode')}
|
||||
hint={modeLocked ? t('settings.appearance.special.modeLocked') : undefined}
|
||||
control={
|
||||
<div className={modeLocked ? 'pointer-events-none opacity-45' : undefined} aria-disabled={modeLocked || undefined}>
|
||||
<Segmented
|
||||
label={tr('settings.colorMode', 'Color mode')}
|
||||
value={mode}
|
||||
@@ -70,9 +73,20 @@ export default function AppearanceSettingsTab(): React.ReactElement {
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Special themes: a whole look, not just an accent */}
|
||||
<SettingRow label={t('settings.appearance.special')} stacked>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{SPECIAL_THEMES.map((s) => (
|
||||
<SpecialThemeCard key={s.id} theme={s} active={cfg.schemeId === s.id}
|
||||
onSelect={() => update({ schemeId: s.id })} />
|
||||
))}
|
||||
</div>
|
||||
</SettingRow>
|
||||
|
||||
{/* Color scheme swatches */}
|
||||
<SettingRow label={tr('settings.appearance.scheme', 'Color scheme')} stacked>
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||||
@@ -290,6 +304,7 @@ function schemeFallback(id: string): string {
|
||||
rose: 'Rose',
|
||||
amber: 'Amber',
|
||||
violet: 'Violet',
|
||||
halloween: 'Halloween',
|
||||
}
|
||||
return map[id] || id
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { DEFAULT_APPEARANCE, normalizeAppearance, type AppearanceConfig } from '@trek/shared';
|
||||
import { DEFAULT_APPEARANCE, isSpecialScheme, normalizeAppearance, type AppearanceConfig } from '@trek/shared';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { useIsDark } from '../../hooks/useIsDark';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import { applyAppearance } from '../../theme/applyAppearance';
|
||||
@@ -42,10 +43,10 @@ export function useAppearanceEditor() {
|
||||
[updateSetting]
|
||||
);
|
||||
|
||||
const isDark =
|
||||
settings.dark_mode === true ||
|
||||
settings.dark_mode === 'dark' ||
|
||||
(settings.dark_mode === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
// Off the `.dark` class: a special theme forces dark whatever the stored mode says.
|
||||
const isDark = useIsDark();
|
||||
// While a special theme is on, the color mode has nothing to switch.
|
||||
const modeLocked = isSpecialScheme(cfg.schemeId);
|
||||
|
||||
// Live preview now (DOM), persist after a short debounce (API).
|
||||
const update = (patch: Partial<AppearanceConfig>) => {
|
||||
@@ -83,6 +84,7 @@ export function useAppearanceEditor() {
|
||||
cfg,
|
||||
darkMode: settings.dark_mode,
|
||||
isDark,
|
||||
modeLocked,
|
||||
update,
|
||||
setMode,
|
||||
setWidget,
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
const read = (): string =>
|
||||
(typeof document !== 'undefined' && document.documentElement.getAttribute('data-scheme')) || 'default';
|
||||
|
||||
/**
|
||||
* The scheme currently on `<html data-scheme>`, as `applyAppearance()` and the
|
||||
* pre-paint boot script wrote it. Same contract as `useIsDark`: components read
|
||||
* the DOM, never the appearance config, so shared pages (which drop the
|
||||
* attribute) and the live preview in settings are covered for free.
|
||||
*
|
||||
* Only for decor that has to exist in the DOM, like a special theme's overlay
|
||||
* or the mascot's hat. Colours belong on tokens.
|
||||
*/
|
||||
export function useActiveScheme(): string {
|
||||
const [scheme, setScheme] = useState<string>(read);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof document === 'undefined') return;
|
||||
const mo = new MutationObserver(() => setScheme(read()));
|
||||
mo.observe(document.documentElement, { attributes: true, attributeFilter: ['data-scheme'] });
|
||||
return () => mo.disconnect();
|
||||
}, []);
|
||||
|
||||
return scheme;
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { useState, useCallback, useRef, useEffect, useMemo } from 'react'
|
||||
import { useTripStore } from '../store/tripStore'
|
||||
import { useSettingsStore } from '../store/settingsStore'
|
||||
import { useAddonStore } from '../store/addonStore'
|
||||
import { calculateRouteWithRetries, type RouteProfileKey } from '../components/Map/routeRetry'
|
||||
import { calculateRouteWithLegs, type RouteProfileKey } from '../components/Map/RouteCalculator'
|
||||
import { buildDayRouteRuns, hotelBookendOf, TRANSPORT_TYPES } from '../components/Map/dayRoutePlan'
|
||||
import { dayViaLookup, foldViaLegs, threadVias, viaKeyOf, viasOnDayLeg } from '../utils/dayRouteVias'
|
||||
import { resolveLegMode } from '../components/Planner/legMode'
|
||||
@@ -131,8 +131,7 @@ export function useRouteCalculation(tripStore: Pick<TripStoreState, 'assignments
|
||||
const straight = (): [number, number][] => chunk.map(p => [p.lat, p.lng] as [number, number])
|
||||
const { waypoints, stopAt } = threadVias(chunk, (from, to) => viasOnDayLeg(viaLookup, dayId, mode, from, to))
|
||||
try {
|
||||
// A busy router is waited out rather than left as straight lines until the next edit.
|
||||
const r = await calculateRouteWithRetries(waypoints, { signal: controller.signal, profile: mode, tripId, dayId })
|
||||
const r = await calculateRouteWithLegs(waypoints, { signal: controller.signal, profile: mode, tripId, dayId })
|
||||
pushCoords(r.coordinates.length >= 2 ? r.coordinates : straight(), mode === 'walking')
|
||||
// Leg k runs from chunk[k] to chunk[k + 1]; a hotel bookend says so on the
|
||||
// segment, which is how the phone timeline finds it (#2501).
|
||||
|
||||
@@ -25,6 +25,9 @@ import '@fontsource/geist-sans/600.css'
|
||||
// several .leaflet-* styles.
|
||||
import 'leaflet/dist/leaflet.css'
|
||||
import './index.css'
|
||||
// Special themes: the palette is eager (first paint), the decor loads with its layer.
|
||||
import '@fontsource/creepster/400.css'
|
||||
import './theme/special/halloween.tokens.css'
|
||||
import './styles/ferratas.css'
|
||||
import './styles/paddle.css'
|
||||
// Native HTML5 drag-and-drop never fires on touch input, so the planner's place /
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { useActiveScheme } from '../../hooks/useActiveScheme';
|
||||
import { MascotHat, MascotPumpkin } from '../../theme/special/halloween/MascotCostume';
|
||||
import MascotClip from './trek3d/MascotClip';
|
||||
import { findClip, mascot3dEnabled } from './trek3d/mascotClips';
|
||||
|
||||
@@ -66,6 +68,8 @@ type MascotProps = {
|
||||
export default function MDancingTrek(props: MascotProps) {
|
||||
const { size = 96, scene = 'idle', mood, className = '' } = props;
|
||||
const sceneMood = SCENE_MOOD[scene] ?? 'default';
|
||||
// Special themes dress the mascot: the 3D clips come in costume, and so does the SVG.
|
||||
const costume = useActiveScheme() === 'halloween';
|
||||
const clip =
|
||||
import.meta.env.MODE !== 'test' && mascot3dEnabled() ? findClip(scene, mood ?? sceneMood, sceneMood) : null;
|
||||
if (clip)
|
||||
@@ -75,13 +79,20 @@ export default function MDancingTrek(props: MascotProps) {
|
||||
scene={scene}
|
||||
size={size}
|
||||
className={className}
|
||||
fallback={<MDancingTrekSvg {...props} />}
|
||||
costume={costume}
|
||||
fallback={<MDancingTrekSvg {...props} costume={costume} />}
|
||||
/>
|
||||
);
|
||||
return <MDancingTrekSvg {...props} />;
|
||||
return <MDancingTrekSvg {...props} costume={costume} />;
|
||||
}
|
||||
|
||||
function MDancingTrekSvg({ size = 96, scene = 'idle', mood, className = '' }: MascotProps) {
|
||||
function MDancingTrekSvg({
|
||||
size = 96,
|
||||
scene = 'idle',
|
||||
mood,
|
||||
className = '',
|
||||
costume = false,
|
||||
}: MascotProps & { costume?: boolean }) {
|
||||
const face = mood ?? SCENE_MOOD[scene] ?? 'default';
|
||||
// Ground shadow sits under whatever the mascot stands on this scene.
|
||||
const shadowCy = scene === 'transport' ? 86 : 73;
|
||||
@@ -111,8 +122,10 @@ function MDancingTrekSvg({ size = 96, scene = 'idle', mood, className = '' }: Ma
|
||||
</g>
|
||||
<Eyes mood={face} />
|
||||
<SceneOnBody scene={scene} />
|
||||
{costume && <MascotHat />}
|
||||
</g>
|
||||
</g>
|
||||
{costume && <MascotPumpkin groundY={shadowCy} />}
|
||||
|
||||
{/* scene prop in front of the body — animates on its own */}
|
||||
<SceneFront scene={scene} />
|
||||
|
||||
@@ -14,7 +14,8 @@ import { CLIPS } from './mascotClips';
|
||||
* stay 2 px stand-ins: browsers hang their own controls on visible videos.
|
||||
*
|
||||
* Clips live next to this file as `<scene>[--<mood>]-<light|dark>-<loop|poke>.mp4`,
|
||||
* cropped to the area the scene uses; `crop` in clips.meta.json places them back
|
||||
* plus a `halloween` set in costume (hat, pumpkin) whose crops sit under
|
||||
* `<clip>@halloween`. They are cropped to the area the scene uses; `crop` in clips.meta.json places them back
|
||||
* inside the full 88:96 box so every scene keeps the same scale. Only URLs are
|
||||
* bundled; the files load when a mascot actually renders.
|
||||
*/
|
||||
@@ -116,6 +117,7 @@ export default function MascotClip({
|
||||
size,
|
||||
className = '',
|
||||
fallback,
|
||||
costume = false,
|
||||
}: {
|
||||
clip: string;
|
||||
scene: string;
|
||||
@@ -123,12 +125,17 @@ export default function MascotClip({
|
||||
className?: string;
|
||||
/** shown when the device refuses to autoplay (e.g. iOS low power mode) */
|
||||
fallback: ReactNode;
|
||||
/** The Halloween theme is on: play the clips in costume where they exist. */
|
||||
costume?: boolean;
|
||||
}) {
|
||||
const theme = useIsDark() ? 'dark' : 'light';
|
||||
const dark = useIsDark();
|
||||
const dressed = costume && !!CLIPS[`./clips/${clip}-halloween-loop.mp4`];
|
||||
const theme = dressed ? 'halloween' : dark ? 'dark' : 'light';
|
||||
const loopUrl = CLIPS[`./clips/${clip}-${theme}-loop.mp4`];
|
||||
const pokeUrl = CLIPS[`./clips/${scene}-${theme}-poke.mp4`];
|
||||
const pokeMs = (META[scene]?.poke ?? 2) * 1000;
|
||||
const [x, y, w, h] = META[clip]?.crop ?? [0, 0, 1, 1];
|
||||
const metaKey = (key: string) => (dressed ? `${key}@halloween` : key);
|
||||
const pokeMs = (META[metaKey(scene)]?.poke ?? 2) * 1000;
|
||||
const [x, y, w, h] = META[metaKey(clip)]?.crop ?? [0, 0, 1, 1];
|
||||
const boxW = size * CLIP_SCALE,
|
||||
boxH = (boxW * 96) / 88;
|
||||
|
||||
|
||||
@@ -1,122 +1,542 @@
|
||||
{
|
||||
"idle": {
|
||||
"poke": 2.3,
|
||||
"crop": [0.14545, 0.11667, 0.72273, 0.74167]
|
||||
},
|
||||
"idle--sleepy": {
|
||||
"poke": 2.3,
|
||||
"crop": [0.14545, 0.11667, 0.72273, 0.74167]
|
||||
},
|
||||
"idle--happy": {
|
||||
"poke": 2.3,
|
||||
"crop": [0.14545, 0.11667, 0.72273, 0.74167]
|
||||
},
|
||||
"idle--sad": {
|
||||
"poke": 2.3,
|
||||
"crop": [0.14545, 0.11667, 0.72273, 0.74167]
|
||||
},
|
||||
"idle--confused": {
|
||||
"poke": 2.3,
|
||||
"crop": [0.14545, 0.11667, 0.72273, 0.74167]
|
||||
},
|
||||
"idle--error": {
|
||||
"poke": 2.3,
|
||||
"crop": [0.14545, 0.11667, 0.72273, 0.74167]
|
||||
},
|
||||
"dashboard": {
|
||||
"poke": 2,
|
||||
"crop": [0.07273, 0.14583, 0.88182, 0.625]
|
||||
},
|
||||
"dashboard--happy": {
|
||||
"poke": 2,
|
||||
"crop": [0.07273, 0.14583, 0.88182, 0.625]
|
||||
},
|
||||
"transport": {
|
||||
"poke": 1.5,
|
||||
"crop": [0.19545, 0.0, 0.64545, 0.75]
|
||||
},
|
||||
"transport--sad": {
|
||||
"poke": 1.5,
|
||||
"crop": [0.19545, 0.0, 0.64545, 0.75]
|
||||
},
|
||||
"transport--happy": {
|
||||
"poke": 1.5,
|
||||
"crop": [0.19545, 0.0, 0.64545, 0.75]
|
||||
},
|
||||
"bookings": {
|
||||
"poke": 2.1,
|
||||
"crop": [0.16364, 0.19167, 0.83636, 0.575]
|
||||
},
|
||||
"tours": {
|
||||
"poke": 2.2,
|
||||
"crop": [0.08636, 0.11667, 0.81364, 0.65417]
|
||||
},
|
||||
"packing": {
|
||||
"poke": 2.45,
|
||||
"crop": [0.09091, 0.025, 0.82273, 0.7125]
|
||||
},
|
||||
"packing--happy": {
|
||||
"poke": 2.45,
|
||||
"crop": [0.09091, 0.025, 0.82273, 0.7125]
|
||||
},
|
||||
"tasks": {
|
||||
"poke": 2,
|
||||
"crop": [0.13182, 0.1125, 0.85909, 0.80833]
|
||||
},
|
||||
"costs": {
|
||||
"poke": 2.3,
|
||||
"crop": [0.14091, 0.0, 0.85909, 0.7625]
|
||||
},
|
||||
"files": {
|
||||
"poke": 2.1,
|
||||
"crop": [0.16364, 0.21667, 0.76818, 0.52917]
|
||||
},
|
||||
"notes": {
|
||||
"poke": 2.6,
|
||||
"crop": [0.13182, 0.0875, 0.86818, 0.67083]
|
||||
},
|
||||
"links": {
|
||||
"poke": 2.2,
|
||||
"crop": [0.03636, 0.08333, 0.91364, 0.66667]
|
||||
},
|
||||
"polls": {
|
||||
"poke": 2,
|
||||
"crop": [0.08636, 0.12917, 0.76364, 0.6125]
|
||||
},
|
||||
"chat": {
|
||||
"poke": 2.2,
|
||||
"crop": [0.12727, 0.18333, 0.80455, 0.55417]
|
||||
},
|
||||
"guide": {
|
||||
"poke": 2.2,
|
||||
"crop": [0.10909, 0.1875, 0.85909, 0.55]
|
||||
},
|
||||
"journey": {
|
||||
"poke": 2.2,
|
||||
"crop": [0.09545, 0.2, 0.86818, 0.57083]
|
||||
},
|
||||
"collections": {
|
||||
"poke": 2.25,
|
||||
"crop": [0.0, 0.0, 0.95455, 0.75417]
|
||||
},
|
||||
"collections--happy": {
|
||||
"poke": 2.25,
|
||||
"crop": [0.0, 0.0, 0.95455, 0.75417]
|
||||
},
|
||||
"atlas": {
|
||||
"poke": 2.4,
|
||||
"crop": [0.12273, 0.20833, 0.70909, 0.52917]
|
||||
},
|
||||
"notifications": {
|
||||
"poke": 2.4,
|
||||
"crop": [0.00909, 0.05417, 0.86364, 0.6875]
|
||||
},
|
||||
"search": {
|
||||
"poke": 2.2,
|
||||
"crop": [0.14545, 0.0125, 0.75909, 0.75833]
|
||||
},
|
||||
"search--sad": {
|
||||
"poke": 2.2,
|
||||
"crop": [0.14545, 0.0125, 0.75909, 0.75833]
|
||||
}
|
||||
}
|
||||
"idle": {
|
||||
"poke": 2.3,
|
||||
"crop": [
|
||||
0.14545,
|
||||
0.11667,
|
||||
0.72273,
|
||||
0.74167
|
||||
]
|
||||
},
|
||||
"idle--sleepy": {
|
||||
"poke": 2.3,
|
||||
"crop": [
|
||||
0.14545,
|
||||
0.11667,
|
||||
0.72273,
|
||||
0.74167
|
||||
]
|
||||
},
|
||||
"idle--happy": {
|
||||
"poke": 2.3,
|
||||
"crop": [
|
||||
0.14545,
|
||||
0.11667,
|
||||
0.72273,
|
||||
0.74167
|
||||
]
|
||||
},
|
||||
"idle--sad": {
|
||||
"poke": 2.3,
|
||||
"crop": [
|
||||
0.14545,
|
||||
0.11667,
|
||||
0.72273,
|
||||
0.74167
|
||||
]
|
||||
},
|
||||
"idle--confused": {
|
||||
"poke": 2.3,
|
||||
"crop": [
|
||||
0.14545,
|
||||
0.11667,
|
||||
0.72273,
|
||||
0.74167
|
||||
]
|
||||
},
|
||||
"idle--error": {
|
||||
"poke": 2.3,
|
||||
"crop": [
|
||||
0.14545,
|
||||
0.11667,
|
||||
0.72273,
|
||||
0.74167
|
||||
]
|
||||
},
|
||||
"dashboard": {
|
||||
"poke": 2,
|
||||
"crop": [
|
||||
0.07273,
|
||||
0.14583,
|
||||
0.88182,
|
||||
0.625
|
||||
]
|
||||
},
|
||||
"dashboard--happy": {
|
||||
"poke": 2,
|
||||
"crop": [
|
||||
0.07273,
|
||||
0.14583,
|
||||
0.88182,
|
||||
0.625
|
||||
]
|
||||
},
|
||||
"transport": {
|
||||
"poke": 1.5,
|
||||
"crop": [
|
||||
0.19545,
|
||||
0.0,
|
||||
0.64545,
|
||||
0.75
|
||||
]
|
||||
},
|
||||
"transport--sad": {
|
||||
"poke": 1.5,
|
||||
"crop": [
|
||||
0.19545,
|
||||
0.0,
|
||||
0.64545,
|
||||
0.75
|
||||
]
|
||||
},
|
||||
"transport--happy": {
|
||||
"poke": 1.5,
|
||||
"crop": [
|
||||
0.19545,
|
||||
0.0,
|
||||
0.64545,
|
||||
0.75
|
||||
]
|
||||
},
|
||||
"bookings": {
|
||||
"poke": 2.1,
|
||||
"crop": [
|
||||
0.16364,
|
||||
0.19167,
|
||||
0.83636,
|
||||
0.575
|
||||
]
|
||||
},
|
||||
"tours": {
|
||||
"poke": 2.2,
|
||||
"crop": [
|
||||
0.08636,
|
||||
0.11667,
|
||||
0.81364,
|
||||
0.65417
|
||||
]
|
||||
},
|
||||
"packing": {
|
||||
"poke": 2.45,
|
||||
"crop": [
|
||||
0.09091,
|
||||
0.025,
|
||||
0.82273,
|
||||
0.7125
|
||||
]
|
||||
},
|
||||
"packing--happy": {
|
||||
"poke": 2.45,
|
||||
"crop": [
|
||||
0.09091,
|
||||
0.025,
|
||||
0.82273,
|
||||
0.7125
|
||||
]
|
||||
},
|
||||
"tasks": {
|
||||
"poke": 2,
|
||||
"crop": [
|
||||
0.13182,
|
||||
0.1125,
|
||||
0.85909,
|
||||
0.80833
|
||||
]
|
||||
},
|
||||
"costs": {
|
||||
"poke": 2.3,
|
||||
"crop": [
|
||||
0.14091,
|
||||
0.0,
|
||||
0.85909,
|
||||
0.7625
|
||||
]
|
||||
},
|
||||
"files": {
|
||||
"poke": 2.1,
|
||||
"crop": [
|
||||
0.16364,
|
||||
0.21667,
|
||||
0.76818,
|
||||
0.52917
|
||||
]
|
||||
},
|
||||
"notes": {
|
||||
"poke": 2.6,
|
||||
"crop": [
|
||||
0.13182,
|
||||
0.0875,
|
||||
0.86818,
|
||||
0.67083
|
||||
]
|
||||
},
|
||||
"links": {
|
||||
"poke": 2.2,
|
||||
"crop": [
|
||||
0.03636,
|
||||
0.08333,
|
||||
0.91364,
|
||||
0.66667
|
||||
]
|
||||
},
|
||||
"polls": {
|
||||
"poke": 2,
|
||||
"crop": [
|
||||
0.08636,
|
||||
0.12917,
|
||||
0.76364,
|
||||
0.6125
|
||||
]
|
||||
},
|
||||
"chat": {
|
||||
"poke": 2.2,
|
||||
"crop": [
|
||||
0.12727,
|
||||
0.18333,
|
||||
0.80455,
|
||||
0.55417
|
||||
]
|
||||
},
|
||||
"guide": {
|
||||
"poke": 2.2,
|
||||
"crop": [
|
||||
0.10909,
|
||||
0.1875,
|
||||
0.85909,
|
||||
0.55
|
||||
]
|
||||
},
|
||||
"journey": {
|
||||
"poke": 2.2,
|
||||
"crop": [
|
||||
0.09545,
|
||||
0.2,
|
||||
0.86818,
|
||||
0.57083
|
||||
]
|
||||
},
|
||||
"collections": {
|
||||
"poke": 2.25,
|
||||
"crop": [
|
||||
0.0,
|
||||
0.0,
|
||||
0.95455,
|
||||
0.75417
|
||||
]
|
||||
},
|
||||
"collections--happy": {
|
||||
"poke": 2.25,
|
||||
"crop": [
|
||||
0.0,
|
||||
0.0,
|
||||
0.95455,
|
||||
0.75417
|
||||
]
|
||||
},
|
||||
"atlas": {
|
||||
"poke": 2.4,
|
||||
"crop": [
|
||||
0.12273,
|
||||
0.20833,
|
||||
0.70909,
|
||||
0.52917
|
||||
]
|
||||
},
|
||||
"notifications": {
|
||||
"poke": 2.4,
|
||||
"crop": [
|
||||
0.00909,
|
||||
0.05417,
|
||||
0.86364,
|
||||
0.6875
|
||||
]
|
||||
},
|
||||
"search": {
|
||||
"poke": 2.2,
|
||||
"crop": [
|
||||
0.14545,
|
||||
0.0125,
|
||||
0.75909,
|
||||
0.75833
|
||||
]
|
||||
},
|
||||
"search--sad": {
|
||||
"poke": 2.2,
|
||||
"crop": [
|
||||
0.14545,
|
||||
0.0125,
|
||||
0.75909,
|
||||
0.75833
|
||||
]
|
||||
},
|
||||
"costs@halloween": {
|
||||
"poke": 2.3,
|
||||
"crop": [
|
||||
0.08182,
|
||||
-0.02917,
|
||||
0.92273,
|
||||
0.79583
|
||||
]
|
||||
},
|
||||
"idle@halloween": {
|
||||
"poke": 2.3,
|
||||
"crop": [
|
||||
0.11364,
|
||||
-0.07083,
|
||||
0.75455,
|
||||
0.92917
|
||||
]
|
||||
},
|
||||
"idle--happy@halloween": {
|
||||
"poke": 2.3,
|
||||
"crop": [
|
||||
0.11364,
|
||||
-0.07083,
|
||||
0.75455,
|
||||
0.92917
|
||||
]
|
||||
},
|
||||
"idle--sad@halloween": {
|
||||
"poke": 2.3,
|
||||
"crop": [
|
||||
0.11364,
|
||||
-0.07083,
|
||||
0.75455,
|
||||
0.92917
|
||||
]
|
||||
},
|
||||
"idle--sleepy@halloween": {
|
||||
"poke": 2.3,
|
||||
"crop": [
|
||||
0.11364,
|
||||
-0.07083,
|
||||
0.75455,
|
||||
0.92917
|
||||
]
|
||||
},
|
||||
"idle--confused@halloween": {
|
||||
"poke": 2.3,
|
||||
"crop": [
|
||||
0.11364,
|
||||
-0.07083,
|
||||
0.75455,
|
||||
0.92917
|
||||
]
|
||||
},
|
||||
"idle--error@halloween": {
|
||||
"poke": 2.3,
|
||||
"crop": [
|
||||
0.11364,
|
||||
-0.07083,
|
||||
0.75455,
|
||||
0.92917
|
||||
]
|
||||
},
|
||||
"dashboard@halloween": {
|
||||
"poke": 2,
|
||||
"crop": [
|
||||
0.07273,
|
||||
-0.04167,
|
||||
0.88182,
|
||||
0.8125
|
||||
]
|
||||
},
|
||||
"dashboard--happy@halloween": {
|
||||
"poke": 2,
|
||||
"crop": [
|
||||
0.07273,
|
||||
-0.04167,
|
||||
0.88182,
|
||||
0.8125
|
||||
]
|
||||
},
|
||||
"transport@halloween": {
|
||||
"poke": 1.5,
|
||||
"crop": [
|
||||
0.11364,
|
||||
-0.17917,
|
||||
0.72727,
|
||||
0.94583
|
||||
]
|
||||
},
|
||||
"transport--happy@halloween": {
|
||||
"poke": 1.5,
|
||||
"crop": [
|
||||
0.11364,
|
||||
-0.17917,
|
||||
0.72727,
|
||||
0.94583
|
||||
]
|
||||
},
|
||||
"transport--sad@halloween": {
|
||||
"poke": 1.5,
|
||||
"crop": [
|
||||
0.11364,
|
||||
-0.17917,
|
||||
0.72727,
|
||||
0.94583
|
||||
]
|
||||
},
|
||||
"bookings@halloween": {
|
||||
"poke": 2.1,
|
||||
"crop": [
|
||||
0.09091,
|
||||
0.0125,
|
||||
0.96818,
|
||||
0.75417
|
||||
]
|
||||
},
|
||||
"tours@halloween": {
|
||||
"poke": 2.2,
|
||||
"crop": [
|
||||
0.08636,
|
||||
-0.06667,
|
||||
0.81364,
|
||||
0.8375
|
||||
]
|
||||
},
|
||||
"packing@halloween": {
|
||||
"poke": 2.45,
|
||||
"crop": [
|
||||
-0.06364,
|
||||
-0.12083,
|
||||
0.97727,
|
||||
0.8875
|
||||
]
|
||||
},
|
||||
"packing--happy@halloween": {
|
||||
"poke": 2.45,
|
||||
"crop": [
|
||||
-0.06364,
|
||||
-0.12083,
|
||||
0.97727,
|
||||
0.8875
|
||||
]
|
||||
},
|
||||
"tasks@halloween": {
|
||||
"poke": 2,
|
||||
"crop": [
|
||||
0.07727,
|
||||
-0.05833,
|
||||
0.91364,
|
||||
0.97917
|
||||
]
|
||||
},
|
||||
"files@halloween": {
|
||||
"poke": 2.1,
|
||||
"crop": [
|
||||
0.09091,
|
||||
0.04583,
|
||||
0.84091,
|
||||
0.72083
|
||||
]
|
||||
},
|
||||
"notes@halloween": {
|
||||
"poke": 2.6,
|
||||
"crop": [
|
||||
0.07727,
|
||||
0.04583,
|
||||
0.95,
|
||||
0.72083
|
||||
]
|
||||
},
|
||||
"links@halloween": {
|
||||
"poke": 2.2,
|
||||
"crop": [
|
||||
0.03636,
|
||||
0.04583,
|
||||
0.91364,
|
||||
0.725
|
||||
]
|
||||
},
|
||||
"polls@halloween": {
|
||||
"poke": 2,
|
||||
"crop": [
|
||||
0.05909,
|
||||
-0.0625,
|
||||
0.79091,
|
||||
0.83333
|
||||
]
|
||||
},
|
||||
"chat@halloween": {
|
||||
"poke": 2.2,
|
||||
"crop": [
|
||||
0.07727,
|
||||
0.00417,
|
||||
0.85455,
|
||||
0.7625
|
||||
]
|
||||
},
|
||||
"guide@halloween": {
|
||||
"poke": 2.2,
|
||||
"crop": [
|
||||
0.06818,
|
||||
0.02083,
|
||||
0.9,
|
||||
0.75
|
||||
]
|
||||
},
|
||||
"journey@halloween": {
|
||||
"poke": 2.2,
|
||||
"crop": [
|
||||
0.06364,
|
||||
0.03333,
|
||||
0.9,
|
||||
0.7375
|
||||
]
|
||||
},
|
||||
"collections@halloween": {
|
||||
"poke": 2.25,
|
||||
"crop": [
|
||||
-0.04545,
|
||||
-0.29167,
|
||||
1.0,
|
||||
1.0625
|
||||
]
|
||||
},
|
||||
"collections--happy@halloween": {
|
||||
"poke": 2.25,
|
||||
"crop": [
|
||||
-0.04545,
|
||||
-0.29167,
|
||||
1.0,
|
||||
1.0625
|
||||
]
|
||||
},
|
||||
"atlas@halloween": {
|
||||
"poke": 2.4,
|
||||
"crop": [
|
||||
0.07273,
|
||||
0.03333,
|
||||
0.75909,
|
||||
0.73333
|
||||
]
|
||||
},
|
||||
"notifications@halloween": {
|
||||
"poke": 2.4,
|
||||
"crop": [
|
||||
0.00909,
|
||||
-0.05417,
|
||||
0.86364,
|
||||
0.825
|
||||
]
|
||||
},
|
||||
"search@halloween": {
|
||||
"poke": 2.2,
|
||||
"crop": [
|
||||
0.08182,
|
||||
0.0125,
|
||||
0.82273,
|
||||
0.75833
|
||||
]
|
||||
},
|
||||
"search--sad@halloween": {
|
||||
"poke": 2.2,
|
||||
"crop": [
|
||||
0.08182,
|
||||
0.0125,
|
||||
0.82273,
|
||||
0.75833
|
||||
]
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -244,10 +244,9 @@ export default function MSettingsAccount() {
|
||||
/>
|
||||
<MSetInput
|
||||
className="mt-2"
|
||||
inputMode="text"
|
||||
autoComplete="one-time-code"
|
||||
inputMode="numeric"
|
||||
value={mfaDisableCode}
|
||||
onChange={(e) => setMfaDisableCode(e.target.value.replace(/[^a-zA-Z0-9-]/g, '').slice(0, 12))}
|
||||
onChange={(e) => setMfaDisableCode(e.target.value.replace(/\D/g, '').slice(0, 8))}
|
||||
placeholder={t('settings.mfa.codePlaceholder')}
|
||||
/>
|
||||
<MSetButton
|
||||
|
||||
@@ -4,7 +4,7 @@ import { RES_ICONS } from '../../../../components/Planner/DayPlanSidebar.constan
|
||||
import { formatTime, formatPriceText } from '../../../../utils/formatters'
|
||||
import { useTranslation } from '../../../../i18n'
|
||||
import type { Reservation } from '../../../../types'
|
||||
import { CardActions, CardDeleteSheet, CardFiles, CardNotes, CardTitleButton, CardWhenAndCode } from './MReservationCardParts'
|
||||
import { CardActions, CardDeleteSheet, CardFiles, CardTitleButton, CardWhenAndCode } from './MReservationCardParts'
|
||||
import { Field, ReservationPluginSlots, SectionHeader, StatusDot, TabScroller, TravelerAvatars, TravelerFilterRow } from './tabChrome'
|
||||
import { STATUS_COLOR, type MTabScreenProps } from './tabModel'
|
||||
import { cardWhen, parseTransportMeta } from './transportsModel'
|
||||
@@ -173,7 +173,11 @@ function BookingCard({ res, planner, canEdit, compact }: {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CardNotes notes={res.notes} />
|
||||
{res.notes && (
|
||||
<div className="mt-2 rounded-[10px] border border-[color:var(--m-rowbr)] bg-m-card px-[10px] py-2">
|
||||
<p className="whitespace-pre-wrap font-geist text-[0.6875rem] leading-[1.5] text-m-muted">{res.notes}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CardFiles files={files} card={card} t={t} />
|
||||
</button>
|
||||
|
||||
@@ -12,7 +12,7 @@ import { useTranslation } from '../../../../i18n'
|
||||
import { DOCUMENT_PROVIDER_ICONS } from '../../../../components/shared/DocumentProviderIcons'
|
||||
import TrekIcon from '../../../../components/shared/TrekIcon'
|
||||
import {
|
||||
bindingNotice, needsReauth, storeName, useDocSync, type DocSyncLink, type DocSyncProvider, type DocSyncVerdict,
|
||||
bindingNotice, needsReauth, storeName, useDocSync, type DocSyncLink, type DocSyncProvider,
|
||||
} from '../../../../components/Files/docsync/useDocSync'
|
||||
import { useConnectForm } from '../../../../components/Files/docsync/useConnectForm'
|
||||
import { conflictPolicyKey, nextConflictPolicy } from '../../../../components/Files/docsync/DocSyncBits'
|
||||
@@ -450,7 +450,7 @@ function ConnectView({
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const form = useConnectForm(provider, sync.connectionFor(provider.id), sync)
|
||||
const [verdict, setVerdict] = useState<DocSyncVerdict | null>(null)
|
||||
const [verdict, setVerdict] = useState<{ connected: boolean; account?: string; error?: string } | null>(null)
|
||||
|
||||
return (
|
||||
<div className="mt-3 flex flex-col gap-3">
|
||||
@@ -503,10 +503,6 @@ function ConnectView({
|
||||
? t('docsync.connect.okAs', { account: verdict.account })
|
||||
: t('docsync.connected')
|
||||
: t(`docsync.error.${verdict.error ?? 'unknown'}`)}
|
||||
{/* The store's own reason, so a refusal can be told apart from a typo (#2666). */}
|
||||
{!verdict.connected && verdict.detail && (
|
||||
<span className="mt-0.5 block text-m-muted [overflow-wrap:anywhere]">{verdict.detail}</span>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,18 +1,11 @@
|
||||
// FE-COMP-MRESCARD-001 to FE-COMP-MRESCARD-010
|
||||
// FE-COMP-MRESCARD-001 to FE-COMP-MRESCARD-009
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { Hotel, Plane } from 'lucide-react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { useReservationCard } from '../../../../components/Planner/bookings/useReservationCard';
|
||||
import type { Reservation } from '../../../../types';
|
||||
import {
|
||||
CardActions,
|
||||
CardDeleteSheet,
|
||||
CardFiles,
|
||||
CardNotes,
|
||||
CardTitleButton,
|
||||
CardWhenAndCode,
|
||||
} from './MReservationCardParts';
|
||||
import { CardActions, CardDeleteSheet, CardFiles, CardTitleButton, CardWhenAndCode } from './MReservationCardParts';
|
||||
|
||||
const t = (key: string, params?: Record<string, string | number>) =>
|
||||
params ? `${key}:${Object.values(params).join(',')}` : key;
|
||||
@@ -156,15 +149,4 @@ describe('MReservationCardParts', () => {
|
||||
expect(title.contains(date)).toBe(false);
|
||||
expect(container.querySelector('button > div')).toHaveClass('flex-col');
|
||||
});
|
||||
|
||||
it('FE-COMP-MRESCARD-010: shows the notes of a booking, and nothing for empty ones (Discord, 15.10.2026)', () => {
|
||||
const notes = 'Gate closes 40 min early\nBag drop at counter C';
|
||||
const { container, rerender } = render(<CardNotes notes={notes} />);
|
||||
expect(container.querySelector('p')?.textContent).toBe(notes);
|
||||
|
||||
rerender(<CardNotes notes=" " />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
rerender(<CardNotes notes={null} />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -128,22 +128,6 @@ export function CardWhenAndCode({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The booking's notes, inside the card body button. A transport card on the phone
|
||||
* never showed them, so notes typed into a flight were nowhere to be read
|
||||
* (reported on Discord, 15.10.2026); both cards now take them from here.
|
||||
*/
|
||||
export function CardNotes({ notes }: { notes: string | null | undefined }) {
|
||||
if (!notes?.trim()) return null;
|
||||
return (
|
||||
<div className="mt-2 rounded-[10px] border border-[color:var(--m-rowbr)] bg-m-card px-[10px] py-2">
|
||||
<p className="whitespace-pre-wrap font-geist text-caption leading-[1.5] text-m-muted [overflow-wrap:anywhere]">
|
||||
{notes}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Attached files, listed inside the card body button. */
|
||||
export function CardFiles({ files, card, t }: { files: ReturnType<typeof filesFor>; card: CardState; t: T }) {
|
||||
if (files.length === 0) return null;
|
||||
|
||||
@@ -3,7 +3,7 @@ import { RES_ICONS } from '../../../../components/Planner/DayPlanSidebar.constan
|
||||
import { formatPriceText } from '../../../../utils/formatters'
|
||||
import { useTranslation } from '../../../../i18n'
|
||||
import type { Reservation } from '../../../../types'
|
||||
import { CardActions, CardDeleteSheet, CardFiles, CardNotes, CardTitleButton, CardWhenAndCode } from './MReservationCardParts'
|
||||
import { CardActions, CardDeleteSheet, CardFiles, CardTitleButton, CardWhenAndCode } from './MReservationCardParts'
|
||||
import { Field, ReservationPluginSlots, SectionHeader, StatusDot, TabScroller, TravelerAvatars, TravelerFilterRow } from './tabChrome'
|
||||
import { STATUS_COLOR, type MTabScreenProps } from './tabModel'
|
||||
import { cardWhen, parseTransportMeta } from './transportsModel'
|
||||
@@ -189,8 +189,6 @@ function TransportCard({ res, planner, shell, canEdit, compact }: {
|
||||
|
||||
<TravelerAvatars travelers={res.travelers || []} label={t('reservations.travelers.label')} />
|
||||
|
||||
<CardNotes notes={res.notes} />
|
||||
|
||||
<CardFiles files={files} card={card} t={t} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -12,8 +12,6 @@ import { useAddonStore } from '../store/addonStore';
|
||||
import { usePermissionsStore } from '../store/permissionsStore';
|
||||
import JourneyPage from './JourneyPage';
|
||||
|
||||
const { mockToastError } = vi.hoisted(() => ({ mockToastError: vi.fn() }));
|
||||
|
||||
// ── Mocks ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const mockNavigate = vi.fn();
|
||||
@@ -26,10 +24,6 @@ vi.mock('../components/Layout/Navbar', () => ({
|
||||
default: () => <nav data-testid="navbar" />,
|
||||
}));
|
||||
|
||||
vi.mock('../components/shared/Toast', () => ({
|
||||
useToast: () => ({ error: mockToastError }),
|
||||
}));
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
let _seq = 500;
|
||||
@@ -84,7 +78,6 @@ function setupDefaultHandlers(journeys: ReturnType<typeof buildJourneyListItem>[
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
window.localStorage.clear();
|
||||
mockNavigate.mockReset();
|
||||
resetAllStores();
|
||||
seedDefaults();
|
||||
@@ -200,75 +193,6 @@ describe('JourneyPage', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-PAGE-JOURNEY-007c: keeps a dismissed suggestion hidden after remount', async () => {
|
||||
const user = userEvent.setup();
|
||||
const suggestions = [
|
||||
{ id: 991, title: 'Kyoto Weekend', start_date: '2026-03-01', end_date: '2026-03-02', place_count: 2 },
|
||||
{ id: 993, title: 'Lisbon Loop', start_date: '2026-04-01', end_date: '2026-04-02', place_count: 3 },
|
||||
];
|
||||
server.use(
|
||||
http.get('/api/journeys', () => HttpResponse.json({ journeys: [] })),
|
||||
http.get('/api/journeys/suggestions', () => HttpResponse.json({ trips: suggestions })),
|
||||
);
|
||||
|
||||
const firstRender = render(<JourneyPage />);
|
||||
const dismissButton = await screen.findByRole('button', { name: 'Dismiss' });
|
||||
await user.click(dismissButton);
|
||||
|
||||
expect(await screen.findByText(/Lisbon Loop/)).toBeInTheDocument();
|
||||
const dismissedRecord = JSON.parse(window.localStorage.getItem(`trek:journey:dismissed-suggestions:v1:${useAuthStore.getState().user?.id}`) || '[]');
|
||||
expect(dismissedRecord).toContain(991);
|
||||
firstRender.unmount();
|
||||
|
||||
render(<JourneyPage />);
|
||||
expect(await screen.findByText(/Lisbon Loop/)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Kyoto Weekend/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PAGE-JOURNEY-007d: keeps the suggestion visible and reports a storage failure', async () => {
|
||||
const user = userEvent.setup();
|
||||
const suggestion = { id: 992, title: 'Coast Trip', start_date: '2026-03-01', end_date: '2026-03-02', place_count: 2 };
|
||||
mockToastError.mockClear();
|
||||
const setItem = vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
|
||||
throw new DOMException('Storage is full', 'QuotaExceededError');
|
||||
});
|
||||
server.use(
|
||||
http.get('/api/journeys', () => HttpResponse.json({ journeys: [] })),
|
||||
http.get('/api/journeys/suggestions', () => HttpResponse.json({ trips: [suggestion] })),
|
||||
);
|
||||
|
||||
render(<JourneyPage />);
|
||||
await user.click(await screen.findByRole('button', { name: 'Dismiss' }));
|
||||
|
||||
expect(mockToastError).toHaveBeenCalledWith('Could not dismiss this suggestion');
|
||||
expect(screen.getByText(/Trip just ended/i)).toBeInTheDocument();
|
||||
setItem.mockRestore();
|
||||
});
|
||||
|
||||
it('FE-PAGE-JOURNEY-007e: deletes dismissed IDs absent from the suggestions response', async () => {
|
||||
const userId = useAuthStore.getState().user?.id;
|
||||
const absentTripId = 994;
|
||||
const stillDismissedTripId = 995;
|
||||
const returnedTripId = 996;
|
||||
const key = `trek:journey:dismissed-suggestions:v1:${userId}`;
|
||||
window.localStorage.setItem(key, JSON.stringify([absentTripId, stillDismissedTripId]));
|
||||
server.use(
|
||||
http.get('/api/journeys', () => HttpResponse.json({ journeys: [] })),
|
||||
http.get('/api/journeys/suggestions', () => HttpResponse.json({
|
||||
trips: [
|
||||
{ id: returnedTripId, title: 'Returned Trip', start_date: '2026-04-01', end_date: '2026-04-02', place_count: 2 },
|
||||
{ id: stillDismissedTripId, title: 'Still Dismissed', start_date: '2026-05-01', end_date: '2026-05-02', place_count: 2 },
|
||||
],
|
||||
})),
|
||||
);
|
||||
|
||||
render(<JourneyPage />);
|
||||
|
||||
expect(await screen.findByText(/Returned Trip/)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Still Dismissed/)).not.toBeInTheDocument();
|
||||
expect(JSON.parse(window.localStorage.getItem(key) || '[]')).toEqual([stillDismissedTripId]);
|
||||
});
|
||||
|
||||
// FE-PAGE-JOURNEY-007b — XSS regression: the suggestion banner interpolates
|
||||
// a user-controlled trip title into an HTML template that is later passed to
|
||||
// dangerouslySetInnerHTML. The sanitiser in @trek/shared must drop any script
|
||||
@@ -342,7 +266,7 @@ describe('JourneyPage', () => {
|
||||
});
|
||||
|
||||
// Click dismiss
|
||||
await user.click(screen.getByRole('button', { name: 'Dismiss' }));
|
||||
await user.click(screen.getByText(/Dismiss/i));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(/Trip just ended/i)).not.toBeInTheDocument();
|
||||
|
||||
@@ -45,7 +45,7 @@ function JourneyPageDesktop() {
|
||||
showCreate, setShowCreate, newTitle, setNewTitle, newSubtitle, setNewSubtitle,
|
||||
availableTrips, selectedTripIds, setSelectedTripIds,
|
||||
searchOpen, setSearchOpen, searchQuery, setSearchQuery, searchInputRef,
|
||||
activeSuggestion, dismissSuggestion,
|
||||
activeSuggestion, setDismissedSuggestions,
|
||||
activeJourney, activeJourneyIsLive, filteredJourneys,
|
||||
openCreateModal, handleCreate, totalPlaces,
|
||||
} = useJourney()
|
||||
@@ -112,7 +112,7 @@ function JourneyPageDesktop() {
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<button type="button"
|
||||
onClick={() => dismissSuggestion(activeSuggestion.id)}
|
||||
onClick={() => setDismissedSuggestions(prev => new Set([...prev, activeSuggestion.id]))}
|
||||
className="px-3 py-1.5 rounded-lg bg-white/10 border border-white/20 text-[12px] font-medium text-white hover:bg-white/20"
|
||||
>
|
||||
{t('journey.frontpage.dismiss')}
|
||||
|
||||
@@ -38,7 +38,7 @@ function buildHook(over: Record<string, unknown> = {}): Record<string, unknown>
|
||||
availableTrips: [], selectedTripIds: new Set<number>(), setSelectedTripIds: vi.fn(),
|
||||
searchOpen: false, setSearchOpen: vi.fn(), searchQuery: '', setSearchQuery: vi.fn(),
|
||||
searchInputRef: { current: null },
|
||||
activeSuggestion: undefined, dismissSuggestion: vi.fn(),
|
||||
activeSuggestion: undefined, setDismissedSuggestions: vi.fn(),
|
||||
activeJourney: null, activeJourneyIsLive: false,
|
||||
filteredJourneys: journeys,
|
||||
openCreateModal: vi.fn(), handleCreate: vi.fn(), totalPlaces: 0,
|
||||
@@ -116,7 +116,9 @@ describe('JourneyPage wiring', () => {
|
||||
expect(screen.getByText('Trip just ended')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Dismiss' }));
|
||||
expect(hook.dismissSuggestion).toHaveBeenCalledWith(42);
|
||||
const updater = (hook.setDismissedSuggestions as ReturnType<typeof vi.fn>).mock.calls[0][0] as
|
||||
(prev: Set<number>) => Set<number>;
|
||||
expect([...updater(new Set<number>())]).toEqual([42]);
|
||||
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'Create Journey' })[1]);
|
||||
expect(hook.openCreateModal).toHaveBeenCalledWith(42);
|
||||
|
||||
@@ -1586,9 +1586,8 @@ describe('SharedTripPage', () => {
|
||||
id: 301, day_id: 21, order_index: 0, notes: 'go early, before the coaches',
|
||||
place: {
|
||||
id: 501, name: 'Louvre', description: 'The big one', address: 'Rue de Rivoli, Paris',
|
||||
// The stay comes from the window; the place default of an hour is not a plan (#2676).
|
||||
lat: 48.86, lng: 2.34, category_id: null, price: null, place_time: '09:00', end_time: '11:30',
|
||||
duration_minutes: 60, notes: 'Skip the pyramid queue', website: 'https://louvre.fr',
|
||||
lat: 48.86, lng: 2.34, category_id: null, price: null, place_time: '09:00', end_time: null,
|
||||
duration_minutes: 150, notes: 'Skip the pyramid queue', website: 'https://louvre.fr',
|
||||
phone: '+33 1 40 20 50 50', image_url: null, transport_mode: 'walking', category: null, tags: [],
|
||||
},
|
||||
},
|
||||
@@ -1640,23 +1639,6 @@ describe('SharedTripPage', () => {
|
||||
expect(document.querySelector('a[href^="javascript:"]')).toBeNull();
|
||||
expect(screen.queryByText(/min$/)).toBeNull();
|
||||
});
|
||||
|
||||
it('shows no stay for a stop with only a start, whatever the place default says (#2676)', async () => {
|
||||
const startOnly = {
|
||||
21: [{
|
||||
id: 303, day_id: 21, order_index: 0, notes: null,
|
||||
place: {
|
||||
id: 503, name: 'Café', description: null, address: 'Somewhere 1', lat: null, lng: null,
|
||||
duration_minutes: 60, notes: null, website: null, phone: null,
|
||||
category_id: null, price: null, place_time: '15:00', end_time: null, image_url: null,
|
||||
transport_mode: 'walking', category: null, tags: [],
|
||||
},
|
||||
}],
|
||||
};
|
||||
await open('start-only-token', payload({ days, assignments: startOnly }));
|
||||
await waitFor(() => expect(screen.getByText('Somewhere 1')).toBeInTheDocument());
|
||||
expect(screen.queryByText('1 h')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-PAGE-SHARED-039: a booking shows its note and its link, and nothing that is not http(s) (#2320)', () => {
|
||||
|
||||
@@ -295,7 +295,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
<div className="hidden md:block" style={{ position: 'absolute', left: 10, top: 10, bottom: 10, zIndex: 20 }}>
|
||||
<PanelTab hidden={leftHidden} label={leftHidden ? t('trip.mobilePlan') : t('common.collapse')} onToggle={toggleLeft} />
|
||||
|
||||
<div style={{
|
||||
<div className="trek-planner-panel" style={{
|
||||
width: leftHidden ? 0 : leftWidth, height: '100%',
|
||||
background: 'var(--sidebar-bg)',
|
||||
backdropFilter: 'blur(24px) saturate(180%)',
|
||||
@@ -420,7 +420,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
<div className="hidden md:block" style={{ position: 'absolute', right: 10, top: 10, bottom: 10, zIndex: 20 }}>
|
||||
<PanelTab side="right" hidden={rightHidden} label={rightHidden ? t('trip.mobilePlaces') : t('common.collapse')} onToggle={toggleRight} />
|
||||
|
||||
<div style={{
|
||||
<div className="trek-planner-panel" style={{
|
||||
width: rightHidden ? 0 : rightWidth, height: '100%',
|
||||
background: 'var(--sidebar-bg)',
|
||||
backdropFilter: 'blur(24px) saturate(180%)',
|
||||
@@ -638,7 +638,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
/>
|
||||
<div className="hidden md:block" style={{ position: 'absolute', left: 10, top: 10, bottom: 10, zIndex: 20 }}>
|
||||
<PanelTab hidden={leftHidden} label={leftHidden ? t('trip.mobilePlan') : t('common.collapse')} onToggle={toggleLeft} />
|
||||
<div style={{
|
||||
<div className="trek-planner-panel" style={{
|
||||
width: leftHidden ? 0 : leftPanelPx, height: '100%', position: 'relative',
|
||||
background: 'var(--sidebar-bg)', backdropFilter: 'blur(24px) saturate(180%)',
|
||||
WebkitBackdropFilter: 'blur(24px) saturate(180%)', boxShadow: leftHidden ? 'none' : 'var(--sidebar-shadow)',
|
||||
|
||||
@@ -17,6 +17,7 @@ import { useToast } from '../../components/shared/Toast'
|
||||
import { getApiErrorMessage } from '../../types'
|
||||
import { usePlaceLanguage } from '../../hooks/usePlaceLanguage'
|
||||
import type { AtlasConfirmAction, VisitedRegionMap } from './useAtlasCountryActions'
|
||||
import { useIsDark } from '../../hooks/useIsDark'
|
||||
|
||||
const PLANNED_KEY = 'trek_atlas_show_planned'
|
||||
|
||||
@@ -126,8 +127,8 @@ export function useAtlas() {
|
||||
const navigate = useNavigate()
|
||||
const toast = useToast()
|
||||
const resolveName = useCountryNames(language)
|
||||
const dm = settings.dark_mode
|
||||
const dark = dm === true || dm === 'dark' || (dm === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||
// Off the .dark class: a special theme is dark whatever the stored mode says.
|
||||
const dark = useIsDark()
|
||||
// Label-free tiles on purpose (the country fills carry the names here), so the
|
||||
// user's own template is deliberately not read, only their CARTO key.
|
||||
const tileUrl = useTileUrl(dark ? OFM_DARK : OFM_POSITRON, true)
|
||||
|
||||
@@ -17,6 +17,7 @@ import type { Collection, CollectionStatus, CollectionFile } from '@trek/shared'
|
||||
import type { Category, Place } from '../../types'
|
||||
import { filterPlaces, sortPlaces, statusCounts, mappablePlaces, presentCategories, presentLabels } from './collectionsModel'
|
||||
import type { CollectionLabelUpdateRequest } from '@trek/shared'
|
||||
import { useIsDark } from '../../hooks/useIsDark'
|
||||
|
||||
/** Quiet time after the last collections:* event before the page reloads. */
|
||||
const WS_RELOAD_SETTLE_MS = 400
|
||||
@@ -34,8 +35,9 @@ export function useCollections() {
|
||||
const { id: routeId } = useParams<{ id: string }>()
|
||||
const toast = useToast()
|
||||
|
||||
const dm = useSettingsStore(s => s.settings.dark_mode)
|
||||
const dark = dm === true || dm === 'dark' || (dm === 'auto' && typeof window !== 'undefined' && window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||
// Off the .dark class: a special theme is dark whatever the stored mode says.
|
||||
|
||||
const dark = useIsDark()
|
||||
|
||||
// Desktop breakpoint for the list+map split (list view only). Below it the
|
||||
// list stays single-column and the map is its own view.
|
||||
|
||||
@@ -1,35 +1,11 @@
|
||||
import { useEffect, useState, useMemo, useRef } from 'react'
|
||||
import { useNavigate, useSearchParams } from 'react-router'
|
||||
import { useJourneyStore } from '../../store/journeyStore'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { journeyApi } from '../../api/client'
|
||||
import { useToast } from '../../components/shared/Toast'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { computeJourneyLifecycle } from '../../utils/journeyLifecycle'
|
||||
|
||||
function dismissedSuggestionsKey(userId: number | undefined): string {
|
||||
return `trek:journey:dismissed-suggestions:v1:${userId ?? 'anonymous'}`
|
||||
}
|
||||
|
||||
/** The trips this user put away, as stored here; anything unreadable reads as none. */
|
||||
function readDismissedSuggestions(userId: number | undefined): Set<number> {
|
||||
try {
|
||||
const value: unknown = JSON.parse(window.localStorage.getItem(dismissedSuggestionsKey(userId)) || '[]')
|
||||
return new Set(Array.isArray(value) ? value.filter((id): id is number => Number.isInteger(id)) : [])
|
||||
} catch {
|
||||
return new Set()
|
||||
}
|
||||
}
|
||||
|
||||
function saveDismissedSuggestions(userId: number | undefined, dismissed: Set<number>): void {
|
||||
const key = dismissedSuggestionsKey(userId)
|
||||
if (dismissed.size === 0) {
|
||||
window.localStorage.removeItem(key)
|
||||
return
|
||||
}
|
||||
window.localStorage.setItem(key, JSON.stringify([...dismissed]))
|
||||
}
|
||||
|
||||
/**
|
||||
* Journey list page logic — owns the journey store load, the create-journey
|
||||
* modal (title, available trips, selection), the search box, the trip
|
||||
@@ -42,7 +18,6 @@ export function useJourney() {
|
||||
const toast = useToast()
|
||||
const { t } = useTranslation()
|
||||
const { journeys, loading, loadJourneys, createJourney } = useJourneyStore()
|
||||
const userId = useAuthStore(s => s.user?.id)
|
||||
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
const [newTitle, setNewTitle] = useState('')
|
||||
@@ -55,35 +30,15 @@ export function useJourney() {
|
||||
|
||||
// suggestion
|
||||
const [suggestions, setSuggestions] = useState<any[]>([])
|
||||
const [dismissedSuggestions, setDismissedSuggestions] = useState<Set<number>>(() => readDismissedSuggestions(userId))
|
||||
const [dismissedSuggestions, setDismissedSuggestions] = useState<Set<number>>(new Set())
|
||||
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
|
||||
useEffect(() => {
|
||||
loadJourneys()
|
||||
journeyApi.suggestions().then(d => setSuggestions(d.trips || [])).catch(() => {})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
journeyApi.suggestions().then(d => {
|
||||
if (cancelled) return
|
||||
const trips = d.trips || []
|
||||
const suggestionIds = new Set<number>(trips.map((trip: { id: number }) => trip.id))
|
||||
const stored = readDismissedSuggestions(userId)
|
||||
const retained = new Set([...stored].filter(id => suggestionIds.has(id)))
|
||||
setSuggestions(trips)
|
||||
setDismissedSuggestions(retained)
|
||||
if (retained.size !== stored.size) {
|
||||
try {
|
||||
saveDismissedSuggestions(userId, retained)
|
||||
} catch {
|
||||
// Pruning is best effort; a failed write leaves the old IDs to retry later.
|
||||
}
|
||||
}
|
||||
}).catch(() => {})
|
||||
return () => { cancelled = true }
|
||||
}, [userId])
|
||||
|
||||
// The bottom-nav "+" opens the new-journey modal via ?create=1.
|
||||
useEffect(() => {
|
||||
if (searchParams.get('create') === '1') {
|
||||
@@ -94,17 +49,6 @@ export function useJourney() {
|
||||
|
||||
const activeSuggestion = suggestions.find(s => !dismissedSuggestions.has(s.id))
|
||||
|
||||
const dismissSuggestion = (tripId: number) => {
|
||||
const next = new Set(dismissedSuggestions)
|
||||
next.add(tripId)
|
||||
try {
|
||||
saveDismissedSuggestions(userId, next)
|
||||
setDismissedSuggestions(next)
|
||||
} catch {
|
||||
toast.error(t('journey.frontpage.dismissError'))
|
||||
}
|
||||
}
|
||||
|
||||
// The frontpage always features one journey as the hero. A journey tied to a
|
||||
// currently-running trip wins; otherwise we fall back to the most recent one
|
||||
// (the list arrives sorted by updated_at DESC, so journeys[0] is the newest).
|
||||
@@ -174,7 +118,7 @@ export function useJourney() {
|
||||
showCreate, setShowCreate, newTitle, setNewTitle, newSubtitle, setNewSubtitle,
|
||||
availableTrips, selectedTripIds, setSelectedTripIds,
|
||||
searchOpen, setSearchOpen, searchQuery, setSearchQuery, searchInputRef,
|
||||
activeSuggestion, dismissSuggestion,
|
||||
activeSuggestion, setDismissedSuggestions,
|
||||
activeJourney, activeJourneyIsLive, filteredJourneys,
|
||||
openCreateModal, handleCreate, totalPlaces,
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { fs } from '../../components/shared/DialogShell'
|
||||
import { Tooltip } from '../../components/shared/Tooltip'
|
||||
import { WhiteBadge } from '../../components/Planner/planParts'
|
||||
import { getGoogleMapsUrlForPlace } from '../../components/Planner/placeGoogleMaps'
|
||||
import { formatDurationMinutes, isHttpUrl, plannedStayMinutes } from './sharedTripModel'
|
||||
import { formatDurationMinutes, isHttpUrl } from './sharedTripModel'
|
||||
|
||||
/**
|
||||
* The read-only detail of one stop on a shared plan (#2320).
|
||||
@@ -14,8 +14,7 @@ import { formatDurationMinutes, isHttpUrl, plannedStayMinutes } from './sharedTr
|
||||
* usually also left a note on the place and another on the day it was
|
||||
* planned for, and a link would show none of that. This shows what the owner
|
||||
* chose to share, laid out the way the planner lays it out: address, then
|
||||
* description, then the two notes, then how long they meant to stay (from the
|
||||
* stop's start and end time), then
|
||||
* description, then the two notes, then how long they meant to stay, then
|
||||
* the ways to reach the place.
|
||||
*
|
||||
* Nothing here is editable and nothing here is a route into the app: every
|
||||
@@ -47,7 +46,7 @@ export function SharedPlaceDetails({ place, assignmentNotes }: { place: SharedPl
|
||||
name: place.name, address: place.address ?? null, lat: place.lat ?? null, lng: place.lng ?? null,
|
||||
google_place_id: null, google_ftid: null,
|
||||
})
|
||||
const duration = formatDurationMinutes(plannedStayMinutes(place.place_time, place.end_time))
|
||||
const duration = formatDurationMinutes(place.duration_minutes)
|
||||
const dayNote = assignmentNotes?.trim() || null
|
||||
const placeNote = place.notes?.trim() || null
|
||||
const hasLinks = !!(website || phone || mapsUrl)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { AxiosError, AxiosHeaders } from 'axios'
|
||||
import {
|
||||
coverSrc, dayHasEntries, formatDateRange, formatDurationMinutes, groupInOrder, isHttpUrl, isSharedTripPayload, legFacts, linkHost, plannedStayMinutes,
|
||||
coverSrc, dayHasEntries, formatDateRange, formatDurationMinutes, groupInOrder, isHttpUrl, isSharedTripPayload, legFacts, linkHost,
|
||||
sharedTripLoadError, spanLabelKey, stopNumbers, transportFacts, unplannedPlaces,
|
||||
} from './sharedTripModel'
|
||||
|
||||
@@ -25,15 +25,6 @@ describe('sharedTripModel (#2320)', () => {
|
||||
expect(formatDurationMinutes(89.6)).toBe('1 h 30 min')
|
||||
})
|
||||
|
||||
it('reads the planned stay off the time window, never off the place default (#2676)', () => {
|
||||
expect(plannedStayMinutes('10:00', '12:30')).toBe(150)
|
||||
expect(plannedStayMinutes('22:30', '01:00')).toBe(150)
|
||||
expect(plannedStayMinutes('10:00', null)).toBeNull()
|
||||
expect(plannedStayMinutes(null, '12:00')).toBeNull()
|
||||
expect(plannedStayMinutes('10:00', '10:00')).toBeNull()
|
||||
expect(plannedStayMinutes(undefined, undefined)).toBeNull()
|
||||
})
|
||||
|
||||
it('writes nothing for a missing, zero or nonsense figure', () => {
|
||||
expect(formatDurationMinutes(null)).toBeNull()
|
||||
expect(formatDurationMinutes(undefined)).toBeNull()
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
* page and its detail blocks can share them and a test can drive them
|
||||
* without rendering.
|
||||
*/
|
||||
import { parseTimeToMinutes } from '../../utils/dayMerge'
|
||||
|
||||
/**
|
||||
* Whether a string is a link the page may render as one.
|
||||
@@ -23,22 +22,6 @@ export function isHttpUrl(value: unknown): value is string {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* How long the owner planned to stay, read off the stop's time window.
|
||||
*
|
||||
* Not `places.duration_minutes`: every place carries one, 60 unless someone
|
||||
* set a road-trip stay, so a link showed "1 h" under every stop (#2676). The
|
||||
* window is what the owner actually planned, and it can differ per visit.
|
||||
* Without an end there is no stay to show. An end before the start runs past
|
||||
* midnight.
|
||||
*/
|
||||
export function plannedStayMinutes(start: string | null | undefined, end: string | null | undefined): number | null {
|
||||
const from = parseTimeToMinutes(start)
|
||||
const to = parseTimeToMinutes(end)
|
||||
if (from == null || to == null || from === to) return null
|
||||
return to > from ? to - from : to + 24 * 60 - from
|
||||
}
|
||||
|
||||
/**
|
||||
* "45 min", "2 h", "1 h 30 min" — the planned time at a place.
|
||||
*
|
||||
|
||||
@@ -19,6 +19,24 @@ free **if they follow the contract below**.
|
||||
- Schemes are data: swatch metadata in `src/theme/schemes.ts`, token values in
|
||||
the `[data-scheme="…"]` blocks in `src/index.css`.
|
||||
|
||||
## Special themes
|
||||
|
||||
A special theme (today `halloween`) is a scheme on the same `data-scheme` axis,
|
||||
so it needs no applier of its own. Three things set it apart, all keyed to
|
||||
`APPEARANCE_SPECIAL_SCHEMES` in `@trek/shared`:
|
||||
|
||||
- **Always dark.** `applyAppearance()` and the boot script force `.dark` while
|
||||
one is on; the color-mode control in settings is dimmed with a hint.
|
||||
- **Palette** in `src/theme/special/<id>.tokens.css`, loaded eagerly next to
|
||||
`index.css` so first paint is right. It overrides surfaces, text and the
|
||||
feature palettes (`.trek-dash`, `--vg-*`, `--journal-*`, mobile `--m-*`).
|
||||
- **Decor** in `src/theme/special/<id>/`, a lazy chunk that `SpecialThemeLayer`
|
||||
mounts while the scheme is on `<html>` (read through `useActiveScheme`, never
|
||||
from the config). It respects reduced motion and transparency off, and never
|
||||
draws over a map: the Atlas gets none, the trip planner dresses only its side
|
||||
panels. Figures are pre-rendered 3D sprite sheets; the mascot plays its own
|
||||
`-<id>-` clips.
|
||||
|
||||
## Token taxonomy
|
||||
|
||||
| Purpose | Token / utility |
|
||||
@@ -40,7 +58,9 @@ Fixed and sticky surfaces read their stacking order from one scale in
|
||||
| Token | Value | For |
|
||||
| --- | --- | --- |
|
||||
| `--z-bar` | 60 | `BottomNav`, page-bound sticky bars |
|
||||
| `--z-decor-ambient` | 190 | a special theme's sky, over the content and under the navbar (only while one is on) |
|
||||
| `--z-nav` | 200 | the desktop navbar |
|
||||
| `--z-decor` | 9000 | a special theme's webs and spiders, over the navbar and under its menus (only while one is on) |
|
||||
| `--z-modal` | 10000 | `shared/Modal`, `ConfirmDialog` |
|
||||
| `--z-overlay` | 99990 | page-owned full-screen overlays |
|
||||
| `--z-notice` | 99998 | app-wide system and release notices |
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { normalizeAppearance, type AppearanceConfig } from '@trek/shared'
|
||||
import { isSpecialScheme, normalizeAppearance, type AppearanceConfig } from '@trek/shared'
|
||||
|
||||
/**
|
||||
* The ONE place that writes appearance state to the DOM.
|
||||
@@ -97,7 +97,8 @@ export function applyAppearance(input: ApplyAppearanceInput): AppearanceConfig {
|
||||
? { ...cfg, schemeId: 'default', accent: null, transparency: true }
|
||||
: cfg
|
||||
|
||||
const dark = resolveDark(input.darkMode, isShared)
|
||||
// Special themes ship only a dark variant, whatever the color mode says.
|
||||
const dark = isSpecialScheme(eff.schemeId) || resolveDark(input.darkMode, isShared)
|
||||
const root = document.documentElement
|
||||
|
||||
root.classList.toggle('dark', dark)
|
||||
@@ -147,9 +148,9 @@ export function applyAppearance(input: ApplyAppearanceInput): AppearanceConfig {
|
||||
if (eff.fontScale === 1) root.style.removeProperty('font-size')
|
||||
else root.style.fontSize = `${eff.fontScale * 100}%`
|
||||
|
||||
// theme-color meta — unchanged from before (dark #09090b / light #ffffff).
|
||||
// theme-color meta — dark #09090b / light #ffffff, special themes their own night.
|
||||
const meta = document.querySelector('meta[name="theme-color"]')
|
||||
if (meta) meta.setAttribute('content', dark ? '#09090b' : '#ffffff')
|
||||
if (meta) meta.setAttribute('content', eff.schemeId === 'halloween' ? '#0c0812' : dark ? '#09090b' : '#ffffff')
|
||||
|
||||
writeSnapshot({
|
||||
v: 1,
|
||||
|
||||
@@ -7,11 +7,29 @@ import type { AppearanceSchemeId } from '@trek/shared'
|
||||
* dot. Labels come from i18n (settings.appearance.scheme.<id>).
|
||||
*/
|
||||
export interface SchemeSwatch {
|
||||
id: Exclude<AppearanceSchemeId, 'custom'>
|
||||
id: Exclude<AppearanceSchemeId, 'custom' | 'halloween'>
|
||||
/** Representative accent color shown in the picker, per color mode. */
|
||||
swatch: { light: string; dark: string }
|
||||
}
|
||||
|
||||
/**
|
||||
* Special themes get their own row in the picker with a larger preview card.
|
||||
* Their palette lives in index.css like every scheme; the decor (cobwebs, bats,
|
||||
* the mascot's hat) hangs off `html[data-scheme="<id>"]` in theme/special/.
|
||||
*/
|
||||
export interface SpecialTheme {
|
||||
id: 'halloween'
|
||||
/** Background of the preview card in the picker. */
|
||||
preview: string
|
||||
}
|
||||
|
||||
export const SPECIAL_THEMES: SpecialTheme[] = [
|
||||
{
|
||||
id: 'halloween',
|
||||
preview: 'radial-gradient(circle at 78% 26%, #fde8c8 0 7%, #f9a14a 8%, transparent 22%), linear-gradient(160deg, #2a1640 0%, #120a1c 55%, #07040b 100%)',
|
||||
},
|
||||
]
|
||||
|
||||
export const APPEARANCE_SCHEMES: SchemeSwatch[] = [
|
||||
{ id: 'default', swatch: { light: '#111827', dark: '#e4e4e7' } },
|
||||
{ id: 'highContrast', swatch: { light: '#1d4ed8', dark: '#60a5fa' } },
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { Sparkles } from 'lucide-react';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import type { SpecialTheme } from '../schemes';
|
||||
import { Cobweb } from './halloween/art';
|
||||
import Sprite from './halloween/Sprite';
|
||||
import { SPRITES } from './halloween/sprites';
|
||||
import './specialThemeCard.css';
|
||||
|
||||
/**
|
||||
* The picker tile for a special theme, shared by the desktop tab and the phone
|
||||
* screen. Larger than a scheme swatch on purpose: it previews the theme itself,
|
||||
* a moonlit night with webs, bats and a lantern, and wakes up on hover.
|
||||
*
|
||||
* The --hw-* tokens it paints with are only defined while the theme is on, so
|
||||
* the card sets them on itself (specialThemeCard.css) and looks the same before
|
||||
* and after it is chosen.
|
||||
*/
|
||||
export default function SpecialThemeCard({
|
||||
theme,
|
||||
active,
|
||||
onSelect,
|
||||
}: {
|
||||
theme: SpecialTheme;
|
||||
active: boolean;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={active}
|
||||
onClick={onSelect}
|
||||
className={`stc stc--${theme.id} ${active ? 'stc--active' : ''}`}
|
||||
>
|
||||
<span className="stc-border" aria-hidden="true" />
|
||||
<span className="stc-sky" aria-hidden="true">
|
||||
<span className="stc-aurora" />
|
||||
<span className="stc-moon" />
|
||||
<span className="stc-fog" />
|
||||
<Cobweb size={58} seed={7} className="stc-web stc-web--l" />
|
||||
<Cobweb size={44} seed={19} className="stc-web stc-web--r" />
|
||||
<span className="stc-bats">
|
||||
<Sprite sheet={SPRITES.bat} height={17} offset={0} className="stc-bat stc-bat--1" />
|
||||
<Sprite sheet={SPRITES.bat} height={12} offset={0.4} className="stc-bat stc-bat--2" />
|
||||
<Sprite sheet={SPRITES.bat} height={14} offset={0.7} className="stc-bat stc-bat--3" />
|
||||
</span>
|
||||
<Sprite sheet={SPRITES.ghost} height={44} className="stc-ghost" />
|
||||
<span className="stc-pumpkins">
|
||||
<Sprite sheet={SPRITES.pumpkin} height={34} className="stc-pumpkin stc-pumpkin--1" />
|
||||
<Sprite sheet={SPRITES.pumpkin} height={24} offset={0.5} className="stc-pumpkin stc-pumpkin--2" />
|
||||
</span>
|
||||
</span>
|
||||
<span className="stc-copy">
|
||||
<span className="stc-title">{t(`settings.appearance.scheme.${theme.id}`)}</span>
|
||||
<span className="stc-hint">{t(`settings.appearance.special.${theme.id}.hint`)}</span>
|
||||
</span>
|
||||
<span className={`stc-badge ${active ? 'stc-badge--on' : ''}`}>
|
||||
<Sparkles size={12} strokeWidth={2.2} />
|
||||
{active ? t('settings.appearance.special.active') : t('settings.appearance.special.try')}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { lazy, Suspense, useCallback, useState } from 'react';
|
||||
import { useLocation } from 'react-router';
|
||||
import { useActiveScheme } from '../../hooks/useActiveScheme';
|
||||
|
||||
const HalloweenLayer = lazy(() => import('./halloween/HalloweenLayer'));
|
||||
|
||||
/**
|
||||
* Mounts a special theme's decor while its scheme is on `<html>`. The decor and
|
||||
* its stylesheet are their own chunk, so nobody downloads bats they never see.
|
||||
*
|
||||
* `intro` is true right after the theme was switched on during this visit,
|
||||
* which plays the transformation once.
|
||||
*
|
||||
* Nothing is drawn over a map. The Atlas world map gets no decor at all; the
|
||||
* trip planner keeps its map clear and dresses only its side panels (CSS in
|
||||
* halloween.css, which still has to load there).
|
||||
*/
|
||||
const NO_DECOR = [/^\/atlas(\/|$)/];
|
||||
const PANELS_ONLY = [/^\/trips\/[^/]+\/?$/];
|
||||
export default function SpecialThemeLayer() {
|
||||
const scheme = useActiveScheme();
|
||||
// Armed while another scheme is on; switching to Halloween then plays the
|
||||
// transformation once (not on a page load that starts in costume, and not
|
||||
// again every time a map page hands the layer back).
|
||||
const [armed, setArmed] = useState(scheme !== 'halloween');
|
||||
if (scheme !== 'halloween' && !armed) setArmed(true);
|
||||
const onIntro = useCallback(() => setArmed(false), []);
|
||||
const { pathname } = useLocation();
|
||||
if (scheme !== 'halloween') return null;
|
||||
if (NO_DECOR.some((re) => re.test(pathname))) return null;
|
||||
const panelsOnly = PANELS_ONLY.some((re) => re.test(pathname));
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<HalloweenLayer panelsOnly={panelsOnly} intro={armed && !panelsOnly} onIntro={onIntro} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user