mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2654e651c7 | |||
| ce1fa68c84 |
@@ -257,15 +257,6 @@ jobs:
|
||||
# migration that changes a column regenerates it in the same change.
|
||||
run: cd server && npm run check:db-types
|
||||
|
||||
- name: Fetch the base the warnings are compared with
|
||||
# lint:warnings names the file behind a grown count by linting it as
|
||||
# it is on the base: the pushed-over commit on a push, the target
|
||||
# branch on a pull request. A miss only drops that hint.
|
||||
run: >-
|
||||
git fetch --no-tags --depth=1 origin
|
||||
"+${{ github.event_name == 'push' && github.event.before || format('refs/heads/{0}', github.base_ref || 'dev') }}:refs/remotes/lint-base"
|
||||
|| true
|
||||
|
||||
- name: Lint
|
||||
# Errors fail, and the warnings per rule may not pass
|
||||
# scripts/eslint-baseline.json (src, tests and eslint-disable'd
|
||||
@@ -273,7 +264,6 @@ jobs:
|
||||
# program, which outgrows Node's default ~4 GB heap on the runner.
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
TREK_LINT_BASE: refs/remotes/lint-base
|
||||
run: cd server && npm run lint:warnings
|
||||
|
||||
- name: Formatting check
|
||||
@@ -405,15 +395,6 @@ jobs:
|
||||
# one included, has to be clean (scripts/strict-lint.mjs).
|
||||
run: cd client && npm run lint:strict
|
||||
|
||||
- name: Fetch the base the warnings are compared with
|
||||
# lint:warnings names the file behind a grown count by linting it as
|
||||
# it is on the base: the pushed-over commit on a push, the target
|
||||
# branch on a pull request. A miss only drops that hint.
|
||||
run: >-
|
||||
git fetch --no-tags --depth=1 origin
|
||||
"+${{ github.event_name == 'push' && github.event.before || format('refs/heads/{0}', github.base_ref || 'dev') }}:refs/remotes/lint-base"
|
||||
|| true
|
||||
|
||||
- name: Lint
|
||||
# Fails on any ESLint error, like lint:check did, and on any rule
|
||||
# whose warning count rises above scripts/eslint-baseline.json.
|
||||
@@ -421,7 +402,6 @@ jobs:
|
||||
# whole TypeScript program, so it gets the heap the server lint gets.
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
TREK_LINT_BASE: refs/remotes/lint-base
|
||||
run: cd client && npm run lint:warnings
|
||||
|
||||
- name: Page pattern check
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
* The tally and the comparison behind lint:warnings (scripts/eslint-ratchet.mjs).
|
||||
*/
|
||||
import { ESLint } from 'eslint';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { join } from 'node:path';
|
||||
import ts from 'typescript';
|
||||
import {
|
||||
@@ -138,78 +137,6 @@ export async function lintClient(root) {
|
||||
}));
|
||||
}
|
||||
|
||||
/** The ref new warnings are measured against: TREK_LINT_BASE (the CI sets it), else origin/dev. */
|
||||
const baseRef = () => process.env.TREK_LINT_BASE || 'origin/dev';
|
||||
|
||||
/** Git in the workspace: its output, or null when it fails (no repository, no such ref). */
|
||||
function gitIn(root, args) {
|
||||
try {
|
||||
return execFileSync('git', args, {
|
||||
cwd: root,
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
maxBuffer: 1 << 28,
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** The messages of one rule in one area of a single file's lint result. */
|
||||
function ofRule(results, rule, area) {
|
||||
const result = results[0];
|
||||
if (!result) return [];
|
||||
const pool =
|
||||
area === 'suppressed' ? (result.suppressedMessages ?? []) : result.messages.filter((m) => m.severity === 1);
|
||||
return pool.filter((m) => ruleOf(m) === rule);
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a grown rule's new warnings sit: every file among `keys` (the files
|
||||
* holding the rule now) that differs from `ref` and holds more of the rule
|
||||
* than it did there, each of its warnings of the rule as `file:line message`.
|
||||
* The count alone names no file, and the files holding the most are old ones;
|
||||
* this names the file that tipped it. Null when `ref` is not at hand (a clone
|
||||
* without it), and the report then keeps to the files holding the most.
|
||||
*/
|
||||
export async function newWarningsOf({ root, rule, area, keys, lintText, ref = baseRef(), git = gitIn }) {
|
||||
const changed = git(root, ['diff', '--name-only', '--relative', ref, '--', '.']);
|
||||
if (changed === null) return null;
|
||||
const differs = new Set(changed.split('\n').filter(Boolean));
|
||||
const found = await Promise.all(
|
||||
keys
|
||||
.filter((key) => differs.has(key))
|
||||
.map(async (key) => {
|
||||
const path = join(root, key);
|
||||
const now = ofRule(await lintText(readText(path), path), rule, area);
|
||||
const before = git(root, ['show', `${ref}:./${key}`]);
|
||||
const was = before === null ? [] : ofRule(await lintText(before, path), rule, area);
|
||||
return now.length > was.length ? now.map((m) => `${key}:${m.line ?? 0} ${m.message}`) : [];
|
||||
})
|
||||
);
|
||||
return found.flat();
|
||||
}
|
||||
|
||||
/** Prints, under the grown counts, the files and lines behind each one (see newWarningsOf). */
|
||||
async function reportNew(grown, { root, files, lintText, error }) {
|
||||
if (!grown.length || !lintText) return;
|
||||
const found = await Promise.all(
|
||||
grown.map(({ area, rule }) => newWarningsOf({ root, rule, area, keys: Object.keys(files[area][rule]), lintText }))
|
||||
);
|
||||
grown.forEach(({ area, rule }, i) => {
|
||||
const lines = found[i];
|
||||
if (!lines?.length) return;
|
||||
error(`New ${rule} warning(s) in ${area}, against ${baseRef()}:`);
|
||||
for (const line of lines) error(` ${line}`);
|
||||
});
|
||||
}
|
||||
|
||||
/** Lints one file's text with the client's own config, for newWarningsOf. */
|
||||
function lintTextClient(root) {
|
||||
const eslint = new ESLint({ cwd: root });
|
||||
return (text, filePath) => eslint.lintText(text, { filePath });
|
||||
}
|
||||
|
||||
const sum = (map) => Object.values(map).reduce((a, b) => a + b, 0);
|
||||
|
||||
/**
|
||||
@@ -221,7 +148,6 @@ export async function check({
|
||||
baselinePath = join(root, 'scripts/eslint-baseline.json'),
|
||||
update = false,
|
||||
lint = lintClient,
|
||||
lintText = lint === lintClient ? lintTextClient : null,
|
||||
log = console.log,
|
||||
error = console.error,
|
||||
}) {
|
||||
@@ -234,13 +160,13 @@ export async function check({
|
||||
}
|
||||
|
||||
for (const line of errors) error(`FAIL ${line}`);
|
||||
const grown = [];
|
||||
let grown = 0;
|
||||
let stale = 0;
|
||||
for (const area of AREAS) {
|
||||
for (const [rule, n] of Object.entries(counts[area])) {
|
||||
const allowed = baseline[area][rule] ?? 0;
|
||||
if (n <= allowed) continue;
|
||||
grown.push({ area, rule });
|
||||
grown++;
|
||||
const top = Object.entries(files[area][rule])
|
||||
.sort(([, a], [, b]) => b - a)
|
||||
.slice(0, 5)
|
||||
@@ -256,8 +182,7 @@ export async function check({
|
||||
});
|
||||
stale += fallen.length;
|
||||
}
|
||||
await reportNew(grown, { root, files, lintText: lintText && lintText(root), error });
|
||||
if (grown.length) {
|
||||
if (grown) {
|
||||
error(
|
||||
'Fix the new warning rather than silencing it: an eslint-disable comment is counted under "suppressed". ' +
|
||||
'Run npx eslint <file> to see each one.'
|
||||
@@ -266,5 +191,5 @@ export async function check({
|
||||
log(
|
||||
`eslint: ${errors.length} error(s); warnings ${AREAS.map((area) => `${area} ${sum(counts[area])}/${sum(baseline[area])}`).join(', ')} (now/baseline)`
|
||||
);
|
||||
return errors.length || grown.length || stale ? 1 : 0;
|
||||
return errors.length || grown || stale ? 1 : 0;
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@
|
||||
"components/Layout/DemoBanner.tsx": 25,
|
||||
"components/Layout/InAppNotificationBell.tsx": 10,
|
||||
"components/Layout/MobileTopBar.tsx": 51,
|
||||
"components/Layout/Navbar.tsx": 21,
|
||||
"components/Layout/Navbar.tsx": 22,
|
||||
"components/Layout/NewVersionNotice.tsx": 3,
|
||||
"components/Layout/OfflineBanner.tsx": 2,
|
||||
"components/Layout/PageSidebar.tsx": 3,
|
||||
@@ -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,31 +0,0 @@
|
||||
import type {
|
||||
TripJournalCandidatesResponse,
|
||||
TripJournalLinkMemberRequest,
|
||||
TripJournalLinkResponse,
|
||||
} from '@trek/shared';
|
||||
import apiClient from './client';
|
||||
|
||||
/**
|
||||
* `/api/trips/:tripId/journal`: the journal from the Journey addon a trip shows,
|
||||
* and who it is open to. In its own file rather than in the `client.ts` god
|
||||
* module; repo/tripJournalLinkRepo.ts is the one caller.
|
||||
*/
|
||||
export const tripJournalApi = {
|
||||
get: (tripId: number): Promise<TripJournalLinkResponse> =>
|
||||
apiClient.get(`/trips/${tripId}/journal`).then((r) => r.data),
|
||||
candidates: (tripId: number, journeyId: number): Promise<TripJournalCandidatesResponse> =>
|
||||
apiClient.get(`/trips/${tripId}/journal/candidates`, { params: { journey_id: journeyId } }).then((r) => r.data),
|
||||
link: (
|
||||
tripId: number,
|
||||
journeyId: number,
|
||||
members: TripJournalLinkMemberRequest[]
|
||||
): Promise<TripJournalLinkResponse> =>
|
||||
apiClient.put(`/trips/${tripId}/journal`, { journey_id: journeyId, members }).then((r) => r.data),
|
||||
setMembers: (tripId: number, members: TripJournalLinkMemberRequest[]): Promise<TripJournalLinkResponse> =>
|
||||
apiClient.patch(`/trips/${tripId}/journal/members`, { members }).then((r) => r.data),
|
||||
unlink: (tripId: number): Promise<TripJournalLinkResponse> =>
|
||||
apiClient.delete(`/trips/${tripId}/journal`).then((r) => r.data),
|
||||
/** The journals this person can open; the dialog offers the ones they own. */
|
||||
journals: (): Promise<{ journeys: { id: number; user_id: number; title: string; subtitle?: string | null }[] }> =>
|
||||
apiClient.get('/journeys').then((r) => r.data),
|
||||
};
|
||||
@@ -93,10 +93,6 @@ export const HANDLED_OUTSIDE_TRIP_STORE = [
|
||||
// the bag ping: what a run changed is per-binding server state, and the
|
||||
// files it moved arrive as file:* events.
|
||||
'docsync:changed',
|
||||
// Linked journal: components/Trips/useTripJournalLink asks again which journal
|
||||
// the trip shows and who it is open to. Content-free, because that answer is
|
||||
// per person (a member left off the link must not learn its title).
|
||||
'trip:journal:changed',
|
||||
// Booking import — BackgroundTasks/BackgroundTasksWidget ('import:' prefix listener)
|
||||
'import:progress',
|
||||
'import:done',
|
||||
|
||||
@@ -24,7 +24,7 @@ export const CATEGORIES = [
|
||||
{ id: 'members', keys: ['member_manage'] },
|
||||
{ id: 'files', keys: ['file_upload', 'file_edit', 'file_delete'] },
|
||||
{ id: 'content', keys: ['place_edit', 'day_edit', 'reservation_edit'] },
|
||||
{ id: 'extras', keys: ['budget_edit', 'packing_edit', 'collab_edit', 'share_manage', 'journal_link'] },
|
||||
{ id: 'extras', keys: ['budget_edit', 'packing_edit', 'collab_edit', 'share_manage'] },
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -98,19 +98,6 @@ describe('JourneySettingsDialog', () => {
|
||||
await screen.findByRole('button', { name: /create share link/i })
|
||||
})
|
||||
|
||||
it('FE-JRN-SETTINGS-2683: lists the people a linked trip lets in under their own heading, and nothing without', () => {
|
||||
const { unmount } = mountDialog()
|
||||
expect(screen.queryByText('Through trips')).not.toBeInTheDocument()
|
||||
unmount()
|
||||
|
||||
mountDialog(buildJourney({
|
||||
trip_access: [{ user_id: 7, role: 'viewer', username: 'lena', avatar: null, trip_id: 5, trip_title: 'Italy Trip' }],
|
||||
}))
|
||||
expect(screen.getByText('Through trips')).toBeInTheDocument()
|
||||
expect(screen.getByText('lena')).toBeInTheDocument()
|
||||
expect(screen.getByText('via Italy Trip')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-JRN-SETTINGS-002: saves the edited title and subtitle', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { onSaved } = mountDialog()
|
||||
|
||||
@@ -7,7 +7,7 @@ import { DeleteButton, DialogButton, DialogFooter, DialogHeader, DialogSection,
|
||||
import { AddRowButton, EditorField, INPUT, Segmented } from '../shared/dialogParts'
|
||||
import JourneyShareSection from './JourneyShareSection'
|
||||
import { useJourneySettings } from './useJourneySettings'
|
||||
import type { JourneyContributor, JourneyDetail, JourneyTripAccess } from '../../store/journeyStore'
|
||||
import type { JourneyContributor, JourneyDetail } from '../../store/journeyStore'
|
||||
import { pickGradient } from '../../pages/journeyDetail/JourneyDetailPage.helpers'
|
||||
import { AddTripDialog } from './JourneyDetailPageAddTripDialog'
|
||||
import ToggleSwitch from '../Settings/ToggleSwitch'
|
||||
@@ -277,8 +277,6 @@ export function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite,
|
||||
</div>
|
||||
<AddRowButton onClick={onOpenInvite}>{t('journey.contributors.invite')}</AddRowButton>
|
||||
</DialogSection>
|
||||
|
||||
<TripAccessSection access={journey.trip_access} roleLabel={roleLabel} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -326,32 +324,3 @@ export function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite,
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Who a trip's linked journal lets in (#2683), under its own heading below the
|
||||
* contributors: shown here, but managed in that trip's settings, so read only.
|
||||
*/
|
||||
function TripAccessSection({ access, roleLabel }: {
|
||||
access: JourneyTripAccess[] | undefined
|
||||
roleLabel: (role: JourneyContributor['role']) => string
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
if (!access?.length) return null
|
||||
return (
|
||||
<DialogSection label={t('journey.contributors.viaTrips')}>
|
||||
<p className="m-0 mb-2.5 leading-normal text-content-faint" style={fs(11.5)}>{t('journey.contributors.viaTripsHint')}</p>
|
||||
<div className={ROWS}>
|
||||
{access.map(a => (
|
||||
<div key={`${a.user_id}-${a.trip_id}`} className={ROW}>
|
||||
<TripMemberAvatar username={a.username} avatarUrl={avatarSrc(a.avatar)} size={28} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate font-semibold text-content" style={fs(13, 'body')}>{a.username}</div>
|
||||
<div className="truncate text-content-faint" style={fs(11)}>{t('journey.contributors.viaTrip', { trip: a.trip_title })}</div>
|
||||
</div>
|
||||
<span className={`${BADGE} bg-surface-tertiary text-content-muted`} style={fs(10.5)}>{roleLabel(a.role)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</DialogSection>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -169,24 +169,23 @@ describe('Navbar', () => {
|
||||
expect(adminLink).toHaveAttribute('href', '/admin');
|
||||
});
|
||||
|
||||
it('FE-COMP-NAVBAR-019: the trip settings button sits beside the back pill when onSettings is given', () => {
|
||||
render(<Navbar tripTitle="Japan" showBack onBack={vi.fn()} onSettings={vi.fn()} />);
|
||||
const back = screen.getByRole('button', { name: /back/i });
|
||||
const settings = screen.getByRole('button', { name: 'Trip settings' });
|
||||
expect(back.parentElement).toBe(settings.closest('div'));
|
||||
it('FE-COMP-NAVBAR-019: share button rendered when onShare prop provided', () => {
|
||||
render(<Navbar onShare={vi.fn()} />);
|
||||
const shareBtn = screen.getByRole('button', { name: /share/i });
|
||||
expect(shareBtn).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-NAVBAR-020: the trip settings button calls onSettings', async () => {
|
||||
it('FE-COMP-NAVBAR-020: share button click calls onShare', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSettings = vi.fn();
|
||||
render(<Navbar onSettings={onSettings} />);
|
||||
await user.click(screen.getByRole('button', { name: 'Trip settings' }));
|
||||
expect(onSettings).toHaveBeenCalled();
|
||||
const onShare = vi.fn();
|
||||
render(<Navbar onShare={onShare} />);
|
||||
const shareBtn = screen.getByRole('button', { name: /share/i });
|
||||
await user.click(shareBtn);
|
||||
expect(onShare).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-COMP-NAVBAR-021: no trip settings button without onSettings, and no share button any more', () => {
|
||||
it('FE-COMP-NAVBAR-021: share button NOT rendered when onShare prop omitted', () => {
|
||||
render(<Navbar />);
|
||||
expect(screen.queryByRole('button', { name: 'Trip settings' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /share/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -454,8 +453,8 @@ describe('Navbar styling and menu details', () => {
|
||||
expect(screen.queryByText('3.5.0-rc1')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-W5NAV-009: the back and theme buttons reset their hover background', () => {
|
||||
render(<Navbar showBack onBack={vi.fn()} onSettings={vi.fn()} />);
|
||||
it('FE-W5NAV-009: the back, share and theme buttons reset their hover background', () => {
|
||||
render(<Navbar showBack onBack={vi.fn()} onShare={vi.fn()} />);
|
||||
|
||||
const back = screen.getByRole('button', { name: /back/i });
|
||||
fireEvent.mouseEnter(back);
|
||||
@@ -463,6 +462,11 @@ describe('Navbar styling and menu details', () => {
|
||||
fireEvent.mouseLeave(back);
|
||||
expect(back.style.background).toBe('transparent');
|
||||
|
||||
const share = screen.getByRole('button', { name: /share/i });
|
||||
fireEvent.mouseEnter(share);
|
||||
expect(share.style.background).toBe('var(--bg-hover)');
|
||||
fireEvent.mouseLeave(share);
|
||||
expect(share.style.background).toBe('var(--bg-card)');
|
||||
|
||||
const theme = document.querySelector('button[title]:not([data-help-button])') as HTMLElement;
|
||||
fireEvent.mouseEnter(theme);
|
||||
|
||||
@@ -6,13 +6,14 @@ import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useAddonStore } from '../../store/addonStore'
|
||||
import { usePluginStore } from '../../store/pluginStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { Plane, LogOut, Settings, ChevronDown, Shield, ArrowLeft, ChevronLeft, Moon, Sun, Monitor, CalendarDays, Briefcase, Globe, Compass, BookOpen, Bookmark } from 'lucide-react'
|
||||
import { Plane, LogOut, Settings, ChevronDown, Shield, ArrowLeft, ChevronLeft, Users, Moon, Sun, Monitor, CalendarDays, Briefcase, Globe, Compass, BookOpen, Bookmark } from 'lucide-react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import InAppNotificationBell from './InAppNotificationBell.tsx'
|
||||
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 }
|
||||
|
||||
@@ -21,8 +22,7 @@ interface NavbarProps {
|
||||
tripId?: number | string
|
||||
onBack?: () => void
|
||||
showBack?: boolean
|
||||
/** The trip's Settings: who is on it, its links and its journal. */
|
||||
onSettings?: () => void
|
||||
onShare?: () => void
|
||||
}
|
||||
|
||||
interface Addon {
|
||||
@@ -33,7 +33,7 @@ interface Addon {
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export default function Navbar({ tripTitle, tripId, onBack, showBack, onSettings }: NavbarProps): React.ReactElement {
|
||||
export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }: NavbarProps): React.ReactElement {
|
||||
const { user, logout, isPrerelease, appVersion } = useAuthStore()
|
||||
const { settings, updateSetting } = useSettingsStore()
|
||||
const { addons: allAddons, loadAddons } = useAddonStore()
|
||||
@@ -42,8 +42,8 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onSettings
|
||||
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)
|
||||
@@ -122,7 +122,7 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onSettings
|
||||
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
|
||||
@@ -174,21 +174,6 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onSettings
|
||||
<span className="truncate text-caption font-semibold text-content">{tripTitle}</span>
|
||||
</span>
|
||||
))}
|
||||
|
||||
{/* The trip's Settings sit with the trip, beside its pill: they are about this
|
||||
trip, not about the app the right side of the bar belongs to. */}
|
||||
{onSettings && (
|
||||
// The same pill as the trip's own beside it, so the two read as one pair.
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSettings}
|
||||
aria-label={t('trip.settings.title')}
|
||||
className="inline-flex flex-none items-center gap-1.5 rounded-full bg-surface-card py-1 ps-2 pe-3 text-content shadow-sm ring-1 ring-inset ring-edge-faint transition-colors hover:bg-surface-hover focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
|
||||
>
|
||||
<Settings className="h-4 w-4 flex-none text-content-muted" aria-hidden="true" />
|
||||
<span className="text-caption font-semibold">{t('nav.settings')}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Centred liquid-glass tab menu (design handoff).
|
||||
@@ -261,6 +246,17 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onSettings
|
||||
itself on top of them. */}
|
||||
<div className="flex items-center gap-4 flex-1 basis-0 min-w-max justify-end">
|
||||
|
||||
{/* Share button */}
|
||||
{onShare && (
|
||||
<button type="button" onClick={onShare}
|
||||
className="flex items-center gap-1.5 py-1.5 px-3 rounded-lg border transition-colors text-sm font-medium flex-shrink-0 border-edge text-content-secondary bg-surface-card"
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-card)'}>
|
||||
<Users className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">{t('nav.share')}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Contextual help for the current screen — same footprint as the theme toggle beside it */}
|
||||
{user && <HelpButton />}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
import { BookOpen, Link2 } from 'lucide-react';
|
||||
|
||||
/**
|
||||
* The trip's Journal tab: a book with a link on its corner, because the journal
|
||||
* is not the trip's own but one from the Journey addon tied to it. Takes the
|
||||
* props the tab bars hand a lucide icon.
|
||||
*
|
||||
* The book is masked out under the link rather than the link given a backing:
|
||||
* a backing has to match whatever the icon sits on (a card, the accent of a
|
||||
* picked tab), and the moment it does not, the link vanishes into it.
|
||||
*/
|
||||
export function LinkedJournalIcon({
|
||||
size = 16,
|
||||
strokeWidth = 2,
|
||||
className,
|
||||
}: {
|
||||
size?: number;
|
||||
strokeWidth?: number;
|
||||
className?: string;
|
||||
}) {
|
||||
const badge = Math.round(size * 0.6);
|
||||
// The hole: a circle round the link's centre, a little wider than the link.
|
||||
const hole = `radial-gradient(circle at calc(100% + 2px) calc(100% + 1px), transparent ${badge * 0.62}px, currentColor ${badge * 0.62 + 0.5}px)`;
|
||||
return (
|
||||
// dir="ltr": the hole is cut on the right, so the link has to stay there in Arabic too.
|
||||
<span
|
||||
dir="ltr"
|
||||
className={`relative inline-flex flex-none ${className ?? ''}`}
|
||||
style={{ width: size, height: size }}
|
||||
>
|
||||
<span className="inline-flex" style={{ maskImage: hole, WebkitMaskImage: hole }}>
|
||||
<BookOpen size={size} strokeWidth={strokeWidth} />
|
||||
</span>
|
||||
<Link2 size={badge} strokeWidth={strokeWidth + 0.6} className="absolute -bottom-[3px] -end-[4px]" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,150 +0,0 @@
|
||||
// FE-TJL-SECTION-001 to FE-TJL-SECTION-007
|
||||
|
||||
/**
|
||||
* The Journal part of the trip's Settings, rendered: what someone sees with no
|
||||
* journal linked, with one linked, while picking, and the warning that names
|
||||
* the people a save would let in for the first time.
|
||||
*/
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import type { TripJournalCandidate, TripJournalLinkResponse } from '@trek/shared';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { buildUser } from '../../../tests/helpers/factories';
|
||||
import { server } from '../../../tests/helpers/msw/server';
|
||||
import { render, screen, waitFor } from '../../../tests/helpers/render';
|
||||
import { seedStore } from '../../../tests/helpers/store';
|
||||
import { useAddonStore } from '../../store/addonStore';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { useTripJournalLinkStore } from '../../store/tripJournalLinkStore';
|
||||
import { LinkedJournalIcon } from './LinkedJournalIcon';
|
||||
import { TripJournalLinkSection } from './TripJournalLinkSection';
|
||||
|
||||
const toast = { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() };
|
||||
vi.mock('../shared/Toast', () => ({ useToast: () => toast }));
|
||||
|
||||
const TRIP = 4;
|
||||
const EMPTY: TripJournalLinkResponse = { link: null, can_link: true, can_unlink: false, can_edit_members: false };
|
||||
const LINKED: TripJournalLinkResponse = {
|
||||
link: {
|
||||
journey_id: 9,
|
||||
title: 'Japan',
|
||||
subtitle: null,
|
||||
cover_image: 'covers/j.jpg',
|
||||
owner_id: 1,
|
||||
linked_by: 1,
|
||||
created_at: 1,
|
||||
my_role: 'owner',
|
||||
members: [{ user_id: 2, role: 'editor', username: 'lena', avatar_url: null }],
|
||||
},
|
||||
can_link: true,
|
||||
can_unlink: true,
|
||||
can_edit_members: true,
|
||||
};
|
||||
const CANDIDATES: TripJournalCandidate[] = [
|
||||
{ user_id: 2, username: 'lena', avatar_url: null, is_trip_owner: true, access: 'editor' },
|
||||
{ user_id: 3, username: 'tom', avatar_url: null, is_trip_owner: false, access: null },
|
||||
];
|
||||
|
||||
let answer: TripJournalLinkResponse = EMPTY;
|
||||
let deletes = 0;
|
||||
|
||||
beforeEach(() => {
|
||||
answer = EMPTY;
|
||||
deletes = 0;
|
||||
vi.clearAllMocks();
|
||||
useTripJournalLinkStore.setState({ byTrip: {} });
|
||||
seedStore(useAuthStore, { user: buildUser({ id: 1 }) });
|
||||
useAddonStore.setState({
|
||||
loaded: true,
|
||||
addons: [{ id: 'journey', name: 'Journey', type: 'global', icon: 'BookOpen', enabled: true }],
|
||||
});
|
||||
server.use(
|
||||
http.get('/api/trips/:tripId/journal', () => HttpResponse.json(answer)),
|
||||
http.get('/api/trips/:tripId/journal/candidates', () => HttpResponse.json({ candidates: CANDIDATES })),
|
||||
http.get('/api/journeys', () => HttpResponse.json({ journeys: [{ id: 9, user_id: 1, title: 'Japan' }] })),
|
||||
http.delete('/api/trips/:tripId/journal', () => {
|
||||
deletes += 1;
|
||||
return HttpResponse.json(EMPTY);
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
describe('TripJournalLinkSection', () => {
|
||||
it('FE-TJL-SECTION-001: renders nothing with the Journey addon off', async () => {
|
||||
useAddonStore.setState({ addons: [] });
|
||||
const { container } = render(<TripJournalLinkSection tripId={TRIP} />);
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('FE-TJL-SECTION-002: with no journal linked it offers to link one, in its own card', async () => {
|
||||
render(<TripJournalLinkSection tripId={TRIP} />);
|
||||
expect(await screen.findByText('Link a journal')).toBeInTheDocument();
|
||||
expect(screen.getByText('Journal')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-TJL-SECTION-003: picking a journal lists the trip and warns about who gets in for the first time', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<TripJournalLinkSection tripId={TRIP} framed={false} />);
|
||||
await user.click(await screen.findByText('Link a journal'));
|
||||
await user.click(await screen.findByText('Choose a journal'));
|
||||
// The select opens its list in a portal, one button per journal.
|
||||
await user.click(await screen.findByRole('button', { name: 'Japan' }));
|
||||
|
||||
expect(await screen.findByText('tom')).toBeInTheDocument();
|
||||
expect(screen.getByText('Has access')).toBeInTheDocument();
|
||||
expect(screen.getByRole('note')).toHaveTextContent('tom will get access to this journal for the first time');
|
||||
|
||||
await user.click(screen.getAllByRole('radio', { name: 'No access' })[1]);
|
||||
expect(screen.queryByRole('note')).not.toBeInTheDocument();
|
||||
await user.click(screen.getByText('Everyone can read'));
|
||||
expect(screen.getByRole('note')).toBeInTheDocument();
|
||||
await user.click(screen.getByText('Nobody'));
|
||||
expect(screen.queryByRole('note')).not.toBeInTheDocument();
|
||||
await user.click(screen.getByText('Cancel'));
|
||||
expect(screen.getByText('Link a journal')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-TJL-SECTION-004: a linked journal shows its cover, my role and who it is open to', async () => {
|
||||
answer = LINKED;
|
||||
render(<TripJournalLinkSection tripId={TRIP} />);
|
||||
expect(await screen.findByText('Japan')).toBeInTheDocument();
|
||||
expect(screen.getByText('Your journal')).toBeInTheDocument();
|
||||
expect(screen.getByText('Linked')).toBeInTheDocument();
|
||||
expect(screen.getByText('lena')).toBeInTheDocument();
|
||||
expect(document.querySelector('img[src="/uploads/covers/j.jpg"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('FE-TJL-SECTION-005: unlinking asks first, then takes it off', async () => {
|
||||
const user = userEvent.setup();
|
||||
answer = LINKED;
|
||||
render(<TripJournalLinkSection tripId={TRIP} />);
|
||||
await user.click(await screen.findByRole('button', { name: 'Unlink' }));
|
||||
expect(screen.getByText('Unlink this journal?')).toBeInTheDocument();
|
||||
const buttons = screen.getAllByRole('button', { name: 'Unlink' });
|
||||
await user.click(buttons[buttons.length - 1]);
|
||||
await waitFor(() => expect(deletes).toBe(1));
|
||||
expect(await screen.findByText('Link a journal')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-TJL-SECTION-006: editing access lists the trip with each one as the link holds them', async () => {
|
||||
const user = userEvent.setup();
|
||||
answer = { ...LINKED, link: LINKED.link && { ...LINKED.link, members: [] } };
|
||||
render(<TripJournalLinkSection tripId={TRIP} />);
|
||||
expect(await screen.findByText('Nobody else from this trip has access yet.')).toBeInTheDocument();
|
||||
await user.click(screen.getByText('Edit access'));
|
||||
expect(await screen.findByText('tom')).toBeInTheDocument();
|
||||
expect(screen.getByText('Owner')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getAllByRole('radio', { name: 'No access' }).every((r) => r.getAttribute('aria-checked') === 'true')
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('FE-TJL-SECTION-007: the linked-journal icon draws a book with the link on its corner', () => {
|
||||
const { container } = render(<LinkedJournalIcon size={20} className="x" />);
|
||||
const root = container.firstElementChild as HTMLElement;
|
||||
expect(root.getAttribute('dir')).toBe('ltr');
|
||||
expect(root.style.width).toBe('20px');
|
||||
expect(container.querySelectorAll('svg')).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -1,324 +0,0 @@
|
||||
import type { TripJournalCandidate, TripJournalLinkRole } from '@trek/shared';
|
||||
import { AlertTriangle, BookOpen, Link2, Unlink, Users } from 'lucide-react';
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import ConfirmDialog from '../shared/ConfirmDialog';
|
||||
import CustomSelect from '../shared/CustomSelect';
|
||||
import { DialogButton, fs } from '../shared/DialogShell';
|
||||
import { AddRowButton } from '../shared/dialogParts';
|
||||
import { LinkedJournalIcon } from './LinkedJournalIcon';
|
||||
import { TripMemberAvatar } from './TripMemberAvatar';
|
||||
import { LinkCard } from './TripShareDialog';
|
||||
import { useTripJournalLink, type JournalMemberChoice } from './useTripJournalLink';
|
||||
|
||||
const ROWS = 'flex flex-col gap-0.5 rounded-[14px] border border-edge-faint bg-surface-secondary p-1.5';
|
||||
const ROW = 'flex min-h-[48px] items-center gap-3 rounded-[10px] px-2.5 py-1.5';
|
||||
const SEGMENT = 'rounded-full px-2.5 py-1 font-semibold';
|
||||
|
||||
const ROLES: { value: TripJournalLinkRole | null; labelKey: string }[] = [
|
||||
{ value: null, labelKey: 'trip.journal.roleNone' },
|
||||
{ value: 'viewer', labelKey: 'trip.journal.roleViewer' },
|
||||
{ value: 'editor', labelKey: 'trip.journal.roleEditor' },
|
||||
];
|
||||
|
||||
/**
|
||||
* The Journal part of the trip's Settings, for the desktop dialog and the phone
|
||||
* sheet alike: the journal the trip shows, and who of the trip it is open to.
|
||||
* Hidden with the Journey addon off, and for someone who can neither link one
|
||||
* nor see one that is linked.
|
||||
*/
|
||||
export function TripJournalLinkSection({ tripId, framed = true }: { tripId: number; framed?: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const j = useTripJournalLink(tripId);
|
||||
const [askUnlink, setAskUnlink] = useState(false);
|
||||
if (!j.visible) return null;
|
||||
|
||||
// The desktop dialog names the area above it already; the phone sheet gets the card with its own title.
|
||||
const Frame = framed ? JournalCard : Bare;
|
||||
return (
|
||||
<>
|
||||
<Frame>
|
||||
{j.mode === 'view' && j.link && <LinkedJournal j={j} />}
|
||||
{j.mode === 'view' && !j.link && j.canLink && (
|
||||
<AddRowButton onClick={j.startPick}>{t('trip.journal.link')}</AddRowButton>
|
||||
)}
|
||||
{j.mode === 'view' && j.link && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{j.canEditMembers && (
|
||||
<DialogButton onClick={j.editMembers} icon={<Users size={14} strokeWidth={2.2} />}>
|
||||
{t('trip.journal.editMembers')}
|
||||
</DialogButton>
|
||||
)}
|
||||
{j.canLink && (
|
||||
<DialogButton onClick={j.startPick} icon={<BookOpen size={14} strokeWidth={2.2} />}>
|
||||
{t('trip.journal.change')}
|
||||
</DialogButton>
|
||||
)}
|
||||
{j.canUnlink && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAskUnlink(true)}
|
||||
disabled={j.busy}
|
||||
className="inline-flex items-center gap-1.5 rounded-[10px] bg-surface-card px-3.5 py-2 font-medium text-danger shadow-sm ring-1 ring-edge-faint hover:bg-danger-soft disabled:opacity-50"
|
||||
style={fs(12.5, 'body')}
|
||||
>
|
||||
<Unlink size={13} strokeWidth={2.2} />
|
||||
{t('trip.journal.unlink')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{j.mode === 'pick' && <JournalPicker j={j} />}
|
||||
{(j.mode === 'members' || (j.mode === 'pick' && j.pickedId != null)) && <MemberChooser j={j} />}
|
||||
{j.mode !== 'view' && (
|
||||
<div className="flex justify-end gap-2">
|
||||
<DialogButton onClick={j.cancel} disabled={j.busy}>
|
||||
{t('common.cancel')}
|
||||
</DialogButton>
|
||||
<DialogButton
|
||||
variant="primary"
|
||||
onClick={() => void j.save()}
|
||||
disabled={!j.canSave}
|
||||
icon={<Link2 size={14} strokeWidth={2.2} />}
|
||||
>
|
||||
{j.mode === 'pick' ? t('trip.journal.linkSubmit') : t('common.save')}
|
||||
</DialogButton>
|
||||
</div>
|
||||
)}
|
||||
</Frame>
|
||||
<ConfirmDialog
|
||||
isOpen={askUnlink}
|
||||
onClose={() => setAskUnlink(false)}
|
||||
onConfirm={() => {
|
||||
setAskUnlink(false);
|
||||
void j.unlink();
|
||||
}}
|
||||
title={t('trip.journal.unlinkTitle')}
|
||||
message={t('trip.journal.unlinkMessage')}
|
||||
confirmLabel={t('trip.journal.unlink')}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function JournalCard({ children }: { children: ReactNode }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<LinkCard
|
||||
icon={<LinkedJournalIcon size={15} strokeWidth={2.2} />}
|
||||
title={t('trip.journal.title')}
|
||||
hint={t('trip.journal.hint')}
|
||||
>
|
||||
{children}
|
||||
</LinkCard>
|
||||
);
|
||||
}
|
||||
|
||||
function Bare({ children }: { children: ReactNode }) {
|
||||
return <div className="flex flex-col gap-4">{children}</div>;
|
||||
}
|
||||
|
||||
type JournalLink = ReturnType<typeof useTripJournalLink>;
|
||||
|
||||
/** The linked journal: cover, title, what the reader may do in it, and who it is open to. */
|
||||
function LinkedJournal({ j }: { j: JournalLink }) {
|
||||
const { t } = useTranslation();
|
||||
const link = j.link;
|
||||
if (!link) return null;
|
||||
return (
|
||||
<div className="flex flex-col gap-2.5">
|
||||
<div className="flex items-center gap-3 rounded-[14px] border border-edge-faint bg-surface-card p-2.5">
|
||||
<span className="grid h-11 w-11 flex-none place-items-center overflow-hidden rounded-[10px] bg-surface-tertiary text-content-muted">
|
||||
{link.cover_image ? (
|
||||
<img src={`/uploads/${link.cover_image}`} alt="" className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<BookOpen size={18} strokeWidth={2} />
|
||||
)}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate font-semibold text-content" style={fs(13.5, 'body')}>
|
||||
{link.title}
|
||||
</div>
|
||||
{link.my_role && (
|
||||
<div className="truncate text-content-faint" style={fs(11.5)}>
|
||||
{t(`trip.journal.yourRole.${link.my_role}`)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className="inline-flex flex-none items-center gap-1 rounded-full bg-success-soft px-2 py-[2px] font-semibold text-success"
|
||||
style={fs(10.5)}
|
||||
>
|
||||
<Link2 size={10} strokeWidth={2.6} />
|
||||
{t('trip.journal.linkedBadge')}
|
||||
</span>
|
||||
</div>
|
||||
{link.members && link.members.length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{link.members.map((m) => (
|
||||
<span
|
||||
key={m.user_id}
|
||||
className="inline-flex items-center gap-1.5 rounded-full bg-surface-card py-[3px] pe-2.5 ps-[3px] shadow-sm ring-1 ring-edge-faint"
|
||||
style={fs(11.5)}
|
||||
>
|
||||
<TripMemberAvatar username={m.username} avatarUrl={m.avatar_url} size={20} />
|
||||
<span className="font-medium text-content">{m.username}</span>
|
||||
<span className="text-content-faint">
|
||||
{t(m.role === 'editor' ? 'trip.journal.roleEditor' : 'trip.journal.roleViewer')}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{link.members && link.members.length === 0 && (
|
||||
<p className="m-0 text-content-faint" style={fs(11.5)}>
|
||||
{t('trip.journal.onlyYou')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The journals of one's own to choose from; one only contributed to is not one's to give away. */
|
||||
function JournalPicker({ j }: { j: JournalLink }) {
|
||||
const { t } = useTranslation();
|
||||
if (j.journals == null) return <div className="h-10 animate-pulse rounded-[10px] bg-surface-tertiary" />;
|
||||
if (j.journals.length === 0)
|
||||
return (
|
||||
<p className="m-0 text-content-faint" style={fs(12)}>
|
||||
{t('trip.journal.noJournals')}
|
||||
</p>
|
||||
);
|
||||
return (
|
||||
<CustomSelect
|
||||
value={j.pickedId == null ? '' : String(j.pickedId)}
|
||||
onChange={(value) => j.pickJournal(value === '' ? null : Number(value))}
|
||||
placeholder={t('trip.journal.pickPlaceholder')}
|
||||
options={[
|
||||
{ value: '', label: t('trip.journal.pickPlaceholder') },
|
||||
...j.journals.map((journal) => ({ value: String(journal.id), label: journal.title })),
|
||||
]}
|
||||
searchable
|
||||
size="sm"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Everyone of the trip, each with no access, read or edit. Before saving, the
|
||||
* people the link would let in for the first time are named: from then on they
|
||||
* see the journal in their own Journey list as well.
|
||||
*/
|
||||
function MemberChooser({ j }: { j: JournalLink }) {
|
||||
const { t } = useTranslation();
|
||||
if (j.candidates == null) return <div className="h-24 animate-pulse rounded-[14px] bg-surface-tertiary" />;
|
||||
if (j.candidates.length === 0)
|
||||
return (
|
||||
<p className="m-0 text-content-faint" style={fs(12)}>
|
||||
{t('trip.journal.noMembers')}
|
||||
</p>
|
||||
);
|
||||
return (
|
||||
<div className="flex flex-col gap-2.5">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="font-semibold text-content-secondary" style={fs(12, 'body')}>
|
||||
{t('trip.journal.members')}
|
||||
</span>
|
||||
<div className="flex gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => j.setAll('viewer')}
|
||||
className="text-content-muted hover:text-content"
|
||||
style={fs(11.5)}
|
||||
>
|
||||
{t('trip.journal.allRead')}
|
||||
</button>
|
||||
<span className="text-content-faint" aria-hidden>
|
||||
/
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => j.setAll(null)}
|
||||
className="text-content-muted hover:text-content"
|
||||
style={fs(11.5)}
|
||||
>
|
||||
{t('trip.journal.allNone')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className={ROWS}>
|
||||
{j.candidates.map((c) => (
|
||||
<MemberRow key={c.user_id} c={c} choice={j.choice} onRole={j.setRole} showAccess={j.mode === 'pick'} />
|
||||
))}
|
||||
</div>
|
||||
{j.newlyGranted.length > 0 && (
|
||||
<div role="note" className="flex items-start gap-2 rounded-[12px] bg-warning-soft px-3 py-2.5 text-warning">
|
||||
<AlertTriangle size={15} strokeWidth={2.2} className="mt-px flex-none" />
|
||||
<p className="m-0 leading-normal" style={fs(12)}>
|
||||
{t('trip.journal.warnNew', {
|
||||
count: j.newlyGranted.length,
|
||||
names: j.newlyGranted.map((c) => c.username).join(', '),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MemberRow({
|
||||
c,
|
||||
choice,
|
||||
onRole,
|
||||
showAccess,
|
||||
}: {
|
||||
c: TripJournalCandidate;
|
||||
choice: JournalMemberChoice;
|
||||
onRole: (userId: number, role: TripJournalLinkRole | null) => void;
|
||||
showAccess: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const current = choice[c.user_id] ?? null;
|
||||
return (
|
||||
<div className={ROW}>
|
||||
<TripMemberAvatar username={c.username} avatarUrl={c.avatar_url} />
|
||||
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-1.5">
|
||||
<span className="truncate font-semibold text-content" style={fs(13, 'body')}>
|
||||
{c.username}
|
||||
</span>
|
||||
{c.is_trip_owner && (
|
||||
<span
|
||||
className="rounded-full bg-surface-tertiary px-2 py-[2px] font-semibold text-content-muted"
|
||||
style={fs(10.5)}
|
||||
>
|
||||
{t('members.owner')}
|
||||
</span>
|
||||
)}
|
||||
{showAccess && c.access && (
|
||||
<span className="rounded-full bg-success-soft px-2 py-[2px] font-semibold text-success" style={fs(10.5)}>
|
||||
{t('trip.journal.hasAccess')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label={c.username}
|
||||
className="flex flex-none gap-0.5 rounded-full bg-surface-card p-0.5 ring-1 ring-edge-faint"
|
||||
>
|
||||
{ROLES.map((r) => (
|
||||
<button
|
||||
key={r.labelKey}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={current === r.value}
|
||||
onClick={() => onRole(c.user_id, r.value)}
|
||||
className={`${SEGMENT} ${current === r.value ? 'bg-accent text-accent-text' : 'text-content-muted hover:text-content'}`}
|
||||
style={fs(11, 'body')}
|
||||
>
|
||||
{t(r.labelKey)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -122,10 +122,10 @@ describe('TripMembersModal', () => {
|
||||
expect(document.body).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-MEMBERS-002: shows the Trip settings title', () => {
|
||||
it('FE-COMP-MEMBERS-002: shows Share Trip title', () => {
|
||||
render(<TripMembersModal {...defaultProps} />);
|
||||
// trip.settings.title = "Trip settings"
|
||||
expect(screen.getByText('Trip settings')).toBeInTheDocument();
|
||||
// members.shareTrip = "Share Trip"
|
||||
expect(screen.getByText('Share Trip')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-MEMBERS-003: shows owner username after load', async () => {
|
||||
@@ -245,7 +245,7 @@ describe('TripMembersModal', () => {
|
||||
|
||||
it('FE-COMP-MEMBERS-015: modal renders when isOpen is true', () => {
|
||||
render(<TripMembersModal {...defaultProps} isOpen={true} />);
|
||||
expect(screen.getByText('Trip settings')).toBeInTheDocument();
|
||||
expect(screen.getByText('Share Trip')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// ── Share Link Section (016-021) ───────────────────────────────────────────
|
||||
@@ -291,7 +291,6 @@ describe('TripMembersModal', () => {
|
||||
);
|
||||
|
||||
render(<TripMembersModal {...defaultProps} />);
|
||||
await user.click(await screen.findByRole('button', { name: 'Sharing' }));
|
||||
const createBtn = await screen.findByText('Create link');
|
||||
await user.click(createBtn);
|
||||
|
||||
@@ -690,7 +689,6 @@ describe('TripMembersModal', () => {
|
||||
http.delete('/api/trips/1/invite-link', () => HttpResponse.json({ success: true })),
|
||||
);
|
||||
const view = render(<TripMembersModal {...defaultProps} />);
|
||||
await user.click(await screen.findByRole('button', { name: 'Sharing' }));
|
||||
|
||||
const createBtn = await screen.findByRole('button', { name: /Create invite link/i });
|
||||
fireEvent.click(createBtn);
|
||||
@@ -721,7 +719,6 @@ describe('TripMembersModal', () => {
|
||||
asShareOwner();
|
||||
server.use(http.post('/api/trips/1/invite-link', () => HttpResponse.json({}, { status: 500 })));
|
||||
render(<TripMembersModal {...defaultProps} />);
|
||||
await user.click(await screen.findByRole('button', { name: 'Sharing' }));
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: /Create invite link/i }));
|
||||
|
||||
@@ -737,7 +734,6 @@ describe('TripMembersModal', () => {
|
||||
http.delete('/api/trips/1/invite-link', () => HttpResponse.json({}, { status: 500 })),
|
||||
);
|
||||
render(<TripMembersModal {...defaultProps} />);
|
||||
await user.click(await screen.findByRole('button', { name: 'Sharing' }));
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: /Disable/i }));
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { TripJournalLinkSection } from './TripJournalLinkSection'
|
||||
import Modal from '../shared/Modal'
|
||||
import { Crown, UserMinus, UserPlus, Users, LogOut, Link2, Trash2, Copy, Check, UserRound, Pencil, Plus } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
@@ -182,7 +181,7 @@ function TripMembersSheet({ isOpen, onClose, tripId, tripTitle, onMembersChanged
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title={t('trip.settings.title')} size="3xl">
|
||||
<Modal isOpen={isOpen} onClose={onClose} title={t('members.shareTrip')} size="3xl">
|
||||
<div style={{ display: 'grid', gridTemplateColumns: canManageShare ? '1fr 1fr' : '1fr', gap: 24, fontFamily: "var(--font-system)" }} className="share-modal-grid">
|
||||
<style>{`@media (max-width: 640px) { .share-modal-grid { grid-template-columns: 1fr !important; } }`}</style>
|
||||
|
||||
@@ -403,8 +402,6 @@ function TripMembersSheet({ isOpen, onClose, tripId, tripTitle, onMembersChanged
|
||||
<TripInviteLinkSection tripId={tripId} t={t} />
|
||||
</div>}
|
||||
|
||||
<TripJournalLinkSection tripId={tripId} />
|
||||
|
||||
<style>{`@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.5} }`}</style>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
import { useId, useState, type ReactNode } from 'react'
|
||||
import { Check, Copy, Crown, Link2, LogOut, Pencil, Plus, Settings, Trash2, UserMinus, UserPlus, UserRound, Users } from 'lucide-react'
|
||||
import { Check, Copy, Crown, Link2, LogOut, Pencil, Plus, Trash2, UserMinus, UserPlus, UserRound, Users } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import ConfirmDialog from '../shared/ConfirmDialog'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
import { DialogButton, DialogHeader, DialogSection, DialogShell, DialogTile, NEUTRAL_TINT, PILL, fs } from '../shared/DialogShell'
|
||||
import { AddRowButton, INPUT, LABEL, PANEL } from '../shared/dialogParts'
|
||||
import { LinkedJournalIcon } from './LinkedJournalIcon'
|
||||
import { TripJournalLinkSection } from './TripJournalLinkSection'
|
||||
import { TripMemberAvatar } from './TripMemberAvatar'
|
||||
import { useTripJournalSectionVisible } from './useTripJournalLink'
|
||||
import { SHARE_OPTIONS, SHARE_SECTIONS, useShareLink, useTripInviteLink, useTripMembers, type TripMembersState } from './useTripShare'
|
||||
|
||||
interface TripShareDialogProps {
|
||||
@@ -32,38 +29,18 @@ const COUNT = 'rounded-full bg-surface-tertiary px-2 py-[2px] font-geist font-bo
|
||||
const ROWS = 'flex flex-col gap-0.5 rounded-[14px] border border-edge-faint bg-surface-secondary p-1.5'
|
||||
const ROW = 'flex min-h-[48px] items-center gap-3 rounded-[10px] px-2.5 py-1.5 hover:bg-surface-card'
|
||||
const BADGE = 'inline-flex flex-none items-center gap-1 rounded-full px-2 py-[2px] font-semibold'
|
||||
/** A fixed height on a wide screen, so the dialog does not jump when another area is picked. */
|
||||
const BODY = 'flex min-h-0 flex-1 max-md:flex-col md:h-[min(640px,calc(100dvh-240px))]'
|
||||
const NAV = 'flex flex-none gap-1 border-edge-faint bg-surface-secondary p-3 md:w-[236px] md:flex-col md:border-e max-md:overflow-x-auto max-md:border-b'
|
||||
const DANGER_BUTTON = 'inline-flex items-center gap-1.5 rounded-[10px] bg-surface-card px-3.5 py-2 font-medium text-danger shadow-sm ring-1 ring-edge-faint hover:bg-danger-soft disabled:cursor-default disabled:opacity-50'
|
||||
|
||||
/**
|
||||
* The trip's Settings on the desktop: who is on the trip, its guests, the two
|
||||
* links that let others in, and the journal the trip shows.
|
||||
*/
|
||||
/** The desktop dialog behind Share: who is on the trip, its guests, and the two links that let others in. */
|
||||
export default function TripShareDialog(props: TripShareDialogProps) {
|
||||
return props.isOpen ? <OpenDialog {...props} /> : null
|
||||
}
|
||||
|
||||
type Area = 'people' | 'sharing' | 'journal'
|
||||
|
||||
/** The areas of the dialog, each with its icon, its name and the line under the name. */
|
||||
const AREAS: { id: Area; icon: (on: boolean) => ReactNode; labelKey: string; hintKey: string }[] = [
|
||||
{ id: 'people', icon: () => <Users size={16} strokeWidth={2} />, labelKey: 'trip.settings.people', hintKey: 'trip.settings.peopleHint' },
|
||||
{ id: 'sharing', icon: () => <Link2 size={16} strokeWidth={2} />, labelKey: 'trip.settings.sharing', hintKey: 'trip.settings.sharingHint' },
|
||||
{ id: 'journal', icon: () => <LinkedJournalIcon size={16} />, labelKey: 'trip.journal.title', hintKey: 'trip.journal.hint' },
|
||||
]
|
||||
|
||||
function OpenDialog({ onClose, tripId, tripTitle, onMembersChanged }: TripShareDialogProps) {
|
||||
const { t } = useTranslation()
|
||||
const labelId = useId()
|
||||
const m = useTripMembers({ isOpen: true, tripId, onClose, onMembersChanged })
|
||||
const [ask, setAsk] = useState<Ask | null>(null)
|
||||
const [area, setArea] = useState<Area>('people')
|
||||
const journalSection = useTripJournalSectionVisible(tripId)
|
||||
const areas = AREAS.filter(a => a.id === 'people' || (a.id === 'sharing' ? m.canManageShare : journalSection))
|
||||
const current = areas.find(a => a.id === area) ?? areas[0]
|
||||
const panelOf = (id: Area) => areas.find(a => a.id === id)
|
||||
const persons = `${m.realMembers.length} ${t('members.persons', { count: m.realMembers.length })}`
|
||||
|
||||
return (
|
||||
@@ -71,16 +48,15 @@ function OpenDialog({ onClose, tripId, tripTitle, onMembersChanged }: TripShareD
|
||||
<DialogShell
|
||||
onClose={onClose}
|
||||
labelledBy={labelId}
|
||||
width={areas.length > 1 ? 'wide' : 'detail'}
|
||||
width={m.canManageShare ? 'wide' : 'detail'}
|
||||
blocked={!!ask}
|
||||
bodyClassName={BODY}
|
||||
header={(
|
||||
<DialogHeader
|
||||
tile={<DialogTile><Settings size={20} strokeWidth={1.9} className="text-content-muted" /></DialogTile>}
|
||||
tile={<DialogTile><Users size={20} strokeWidth={1.9} className="text-content-muted" /></DialogTile>}
|
||||
tint={NEUTRAL_TINT}
|
||||
labelId={labelId}
|
||||
onClose={onClose}
|
||||
eyebrow={t('trip.settings.title')}
|
||||
eyebrow={t('members.shareTrip')}
|
||||
title={tripTitle}
|
||||
pills={m.loading ? undefined : (
|
||||
<>
|
||||
@@ -91,24 +67,17 @@ function OpenDialog({ onClose, tripId, tripTitle, onMembersChanged }: TripShareD
|
||||
/>
|
||||
)}
|
||||
>
|
||||
{areas.length > 1 && <AreaNav areas={areas} current={current.id} onPick={setArea} />}
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-7 pb-7 pt-6 max-sm:px-5">
|
||||
{/* Every area stays mounted, so its links and lists are loaded by the time it is picked. */}
|
||||
<AreaPanel area={panelOf('people')} shown={current.id === 'people'} titled={areas.length > 1}>
|
||||
<div className={m.canManageShare ? 'grid grid-cols-2 items-start gap-6 max-md:grid-cols-1' : ''}>
|
||||
<div className="flex flex-col gap-5">
|
||||
{m.canManageMembers && <InviteUser m={m} />}
|
||||
<MembersList m={m} onAsk={setAsk} />
|
||||
{(m.isCurrentOwner || m.guests.length > 0) && <GuestsList m={m} onAsk={setAsk} />}
|
||||
</AreaPanel>
|
||||
</div>
|
||||
{m.canManageShare && (
|
||||
<AreaPanel area={panelOf('sharing')} shown={current.id === 'sharing'} titled>
|
||||
<div className="flex flex-col gap-5">
|
||||
<PublicLink tripId={tripId} />
|
||||
<InviteLink tripId={tripId} />
|
||||
</AreaPanel>
|
||||
)}
|
||||
{journalSection && (
|
||||
<AreaPanel area={panelOf('journal')} shown={current.id === 'journal'} titled>
|
||||
<TripJournalLinkSection tripId={tripId} framed={false} />
|
||||
</AreaPanel>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogShell>
|
||||
@@ -124,45 +93,6 @@ function OpenDialog({ onClose, tripId, tripTitle, onMembersChanged }: TripShareD
|
||||
)
|
||||
}
|
||||
|
||||
/** The areas down the side on a wide screen, a row of pills above the content on a narrow one. */
|
||||
function AreaNav({ areas, current, onPick }: { areas: typeof AREAS; current: Area; onPick: (area: Area) => void }) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<nav aria-label={t('trip.settings.title')} className={NAV}>
|
||||
{areas.map(a => {
|
||||
const on = a.id === current
|
||||
return (
|
||||
<button key={a.id} type="button" aria-current={on ? 'page' : undefined} aria-label={t(a.labelKey)} onClick={() => onPick(a.id)}
|
||||
className={`flex flex-none items-center gap-2.5 rounded-[12px] px-2.5 py-2 text-start transition-colors ${on ? 'bg-surface-card text-content shadow-sm ring-1 ring-edge-faint' : 'text-content-muted hover:bg-surface-hover hover:text-content'}`}>
|
||||
<span className={`grid h-8 w-8 flex-none place-items-center rounded-[9px] ${on ? 'bg-accent text-accent-text' : 'bg-surface-tertiary'}`}>{a.icon(on)}</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate font-semibold" style={fs(13, 'body')}>{t(a.labelKey)}</span>
|
||||
<span className="block truncate text-content-faint max-md:hidden" style={fs(11)}>{t(a.hintKey)}</span>
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
/** One area: its name and what it is for, then its parts. Hidden rather than unmounted while another one shows. */
|
||||
function AreaPanel({ area, shown, titled, children }: { area: (typeof AREAS)[number] | undefined; shown: boolean; titled: boolean; children: ReactNode }) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
// The class, not the hidden attribute alone: flex would win over the attribute's display:none.
|
||||
<section hidden={!shown} className={shown ? 'flex flex-col gap-5' : 'hidden'}>
|
||||
{titled && area && (
|
||||
<header>
|
||||
<h3 className="m-0 font-semibold text-content" style={fs(17, 'subtitle')}>{t(area.labelKey)}</h3>
|
||||
<p className="m-0 mt-1 max-w-[560px] leading-normal text-content-muted" style={fs(12.5, 'body')}>{t(area.hintKey)}</p>
|
||||
</header>
|
||||
)}
|
||||
{children}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function InviteUser({ m }: { m: TripMembersState }) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
@@ -361,7 +291,7 @@ function LinkLine({ url, copied, onCopy }: { url: string; copied: boolean; onCop
|
||||
)
|
||||
}
|
||||
|
||||
export function LinkCard({ icon, title, hint, children }: { icon: ReactNode; title: string; hint: string; children: ReactNode }) {
|
||||
function LinkCard({ icon, title, hint, children }: { icon: ReactNode; title: string; hint: string; children: ReactNode }) {
|
||||
return (
|
||||
<section className={PANEL}>
|
||||
<div className="flex items-start gap-2.5">
|
||||
|
||||
@@ -1,290 +0,0 @@
|
||||
// FE-TJL-HOOK-001 to FE-TJL-HOOK-014
|
||||
|
||||
/**
|
||||
* The trip's linked journal as the planner and the Settings dialog read it.
|
||||
*
|
||||
* `useTripJournal` decides whether the trip has a Journal tab at all: only with
|
||||
* the Journey addon on, only once the server answered, and live on every
|
||||
* `trip:journal:changed`. `useTripJournalLink` is the Settings section: pick one
|
||||
* of your own journals, everybody of the trip ticked as a reader by default,
|
||||
* the people who would get in for the first time named before the save.
|
||||
*/
|
||||
import type { TripJournalCandidate, TripJournalLinkResponse } from '@trek/shared';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { buildUser } from '../../../tests/helpers/factories';
|
||||
import { server } from '../../../tests/helpers/msw/server';
|
||||
import { act, renderHook, waitFor } from '../../../tests/helpers/render';
|
||||
import { seedStore } from '../../../tests/helpers/store';
|
||||
import { useAddonStore } from '../../store/addonStore';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { useTripJournalLinkStore } from '../../store/tripJournalLinkStore';
|
||||
import { _resetNetworkMode, setForcedOffline } from '../../sync/networkMode';
|
||||
import { useTripJournal, useTripJournalLink, useTripJournalSectionVisible } from './useTripJournalLink';
|
||||
|
||||
const toast = { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() };
|
||||
vi.mock('../shared/Toast', () => ({ useToast: () => toast }));
|
||||
|
||||
const listeners = new Set<(e: Record<string, unknown>) => void>();
|
||||
vi.mock('../../api/websocket', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('../../api/websocket')>()),
|
||||
addListener: (fn: (e: Record<string, unknown>) => void) => {
|
||||
listeners.add(fn);
|
||||
},
|
||||
removeListener: (fn: (e: Record<string, unknown>) => void) => {
|
||||
listeners.delete(fn);
|
||||
},
|
||||
}));
|
||||
|
||||
const TRIP = 4;
|
||||
const ME = 1;
|
||||
|
||||
const EMPTY: TripJournalLinkResponse = { link: null, can_link: true, can_unlink: false, can_edit_members: false };
|
||||
const LINKED: TripJournalLinkResponse = {
|
||||
link: {
|
||||
journey_id: 9,
|
||||
title: 'Japan',
|
||||
subtitle: null,
|
||||
cover_image: null,
|
||||
owner_id: ME,
|
||||
linked_by: ME,
|
||||
created_at: 1,
|
||||
my_role: 'owner',
|
||||
members: [{ user_id: 2, role: 'editor', username: 'lena', avatar_url: null }],
|
||||
},
|
||||
can_link: true,
|
||||
can_unlink: true,
|
||||
can_edit_members: true,
|
||||
};
|
||||
const CANDIDATES: TripJournalCandidate[] = [
|
||||
{ user_id: 2, username: 'lena', avatar_url: null, is_trip_owner: false, access: 'editor' },
|
||||
{ user_id: 3, username: 'tom', avatar_url: null, is_trip_owner: false, access: null },
|
||||
];
|
||||
|
||||
let gets = 0;
|
||||
let answer: TripJournalLinkResponse = EMPTY;
|
||||
const writes: { method: string; body: unknown }[] = [];
|
||||
|
||||
function routes({ writeStatus = 200 } = {}) {
|
||||
server.use(
|
||||
http.get('/api/trips/:tripId/journal', () => {
|
||||
gets += 1;
|
||||
return HttpResponse.json(answer);
|
||||
}),
|
||||
http.get('/api/trips/:tripId/journal/candidates', () => HttpResponse.json({ candidates: CANDIDATES })),
|
||||
http.get('/api/journeys', () =>
|
||||
HttpResponse.json({
|
||||
journeys: [
|
||||
{ id: 9, user_id: ME, title: 'Japan' },
|
||||
{ id: 10, user_id: 99, title: 'Not mine' },
|
||||
],
|
||||
})
|
||||
),
|
||||
http.put('/api/trips/:tripId/journal', async ({ request }) => {
|
||||
writes.push({ method: 'PUT', body: await request.json() });
|
||||
return writeStatus === 200
|
||||
? HttpResponse.json(LINKED)
|
||||
: HttpResponse.json({ error: 'no' }, { status: writeStatus });
|
||||
}),
|
||||
http.patch('/api/trips/:tripId/journal/members', async ({ request }) => {
|
||||
writes.push({ method: 'PATCH', body: await request.json() });
|
||||
return HttpResponse.json(LINKED);
|
||||
}),
|
||||
http.delete('/api/trips/:tripId/journal', () => {
|
||||
writes.push({ method: 'DELETE', body: null });
|
||||
return HttpResponse.json(EMPTY);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function journeyAddon(enabled: boolean): void {
|
||||
useAddonStore.setState({
|
||||
loaded: true,
|
||||
addons: enabled ? [{ id: 'journey', name: 'Journey', type: 'global', icon: 'BookOpen', enabled: true }] : [],
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
gets = 0;
|
||||
answer = EMPTY;
|
||||
writes.length = 0;
|
||||
listeners.clear();
|
||||
vi.clearAllMocks();
|
||||
useTripJournalLinkStore.setState({ byTrip: {} });
|
||||
seedStore(useAuthStore, { user: buildUser({ id: ME }) });
|
||||
journeyAddon(true);
|
||||
routes();
|
||||
});
|
||||
|
||||
afterEach(() => _resetNetworkMode());
|
||||
|
||||
describe('useTripJournal', () => {
|
||||
it('FE-TJL-HOOK-001: with the Journey addon off it asks nothing and there is no journal', async () => {
|
||||
journeyAddon(false);
|
||||
const { result } = renderHook(() => useTripJournal(TRIP));
|
||||
expect(result.current.openJournalId).toBeNull();
|
||||
expect(result.current.settled).toBe(true);
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
expect(gets).toBe(0);
|
||||
});
|
||||
|
||||
it('FE-TJL-HOOK-002: a linked journal open to this person is the one the tab opens', async () => {
|
||||
answer = LINKED;
|
||||
const { result } = renderHook(() => useTripJournal(TRIP));
|
||||
expect(result.current.settled).toBe(false);
|
||||
await waitFor(() => expect(result.current.openJournalId).toBe(9));
|
||||
expect(result.current.settled).toBe(true);
|
||||
});
|
||||
|
||||
it('FE-TJL-HOOK-003: a link that leaves this person out gives no tab', async () => {
|
||||
answer = { ...LINKED, link: LINKED.link && { ...LINKED.link, my_role: null } };
|
||||
const { result } = renderHook(() => useTripJournal(TRIP));
|
||||
await waitFor(() => expect(result.current.link).not.toBeNull());
|
||||
expect(result.current.openJournalId).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-TJL-HOOK-004: trip:journal:changed asks again', async () => {
|
||||
const { result } = renderHook(() => useTripJournal(TRIP));
|
||||
await waitFor(() => expect(gets).toBe(1));
|
||||
answer = LINKED;
|
||||
act(() => {
|
||||
for (const fn of [...listeners]) fn({ type: 'trip:journal:changed' });
|
||||
});
|
||||
await waitFor(() => expect(result.current.openJournalId).toBe(9));
|
||||
});
|
||||
|
||||
it('FE-TJL-HOOK-005: offline it asks nothing and is settled', () => {
|
||||
setForcedOffline(true);
|
||||
const { result } = renderHook(() => useTripJournal(TRIP));
|
||||
expect(result.current.settled).toBe(true);
|
||||
expect(gets).toBe(0);
|
||||
});
|
||||
|
||||
it('FE-TJL-HOOK-006: without a trip id it asks nothing', async () => {
|
||||
renderHook(() => useTripJournal(null));
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
expect(gets).toBe(0);
|
||||
});
|
||||
|
||||
it('FE-TJL-HOOK-007: the Settings section shows for someone who may link, and not for someone who may not', async () => {
|
||||
const { result, rerender } = renderHook(() => useTripJournalSectionVisible(TRIP));
|
||||
await waitFor(() => expect(result.current).toBe(true));
|
||||
act(() => useTripJournalLinkStore.setState({ byTrip: { [TRIP]: { ...EMPTY, can_link: false } } }));
|
||||
rerender();
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useTripJournalLink', () => {
|
||||
it('FE-TJL-HOOK-008: picking offers only my journals and ticks everybody as a reader', async () => {
|
||||
const { result } = renderHook(() => useTripJournalLink(TRIP));
|
||||
await waitFor(() => expect(result.current.visible).toBe(true));
|
||||
|
||||
act(() => result.current.startPick());
|
||||
await waitFor(() => expect(result.current.journals).toEqual([{ id: 9, title: 'Japan', subtitle: null }]));
|
||||
act(() => result.current.pickJournal(9));
|
||||
await waitFor(() => expect(result.current.candidates).toHaveLength(2));
|
||||
expect(result.current.choice).toEqual({ 2: 'viewer', 3: 'viewer' });
|
||||
// lena is in already; tom would be let in for the first time.
|
||||
expect(result.current.newlyGranted.map((c) => c.username)).toEqual(['tom']);
|
||||
expect(result.current.canSave).toBe(true);
|
||||
});
|
||||
|
||||
it('FE-TJL-HOOK-009: saving a pick links with the chosen roles and goes back to the view', async () => {
|
||||
const { result } = renderHook(() => useTripJournalLink(TRIP));
|
||||
await waitFor(() => expect(result.current.visible).toBe(true));
|
||||
act(() => result.current.startPick());
|
||||
act(() => result.current.pickJournal(9));
|
||||
await waitFor(() => expect(result.current.candidates).toHaveLength(2));
|
||||
act(() => {
|
||||
result.current.setRole(2, 'editor');
|
||||
result.current.setRole(3, null);
|
||||
});
|
||||
expect(result.current.newlyGranted).toEqual([]);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.save();
|
||||
});
|
||||
expect(writes).toEqual([{ method: 'PUT', body: { journey_id: 9, members: [{ user_id: 2, role: 'editor' }] } }]);
|
||||
expect(toast.success).toHaveBeenCalledWith('trip.journal.linked');
|
||||
expect(result.current.mode).toBe('view');
|
||||
expect(result.current.link?.title).toBe('Japan');
|
||||
});
|
||||
|
||||
it('FE-TJL-HOOK-010: editing members starts from who the link holds and saves them', async () => {
|
||||
answer = LINKED;
|
||||
const { result } = renderHook(() => useTripJournalLink(TRIP));
|
||||
await waitFor(() => expect(result.current.link).not.toBeNull());
|
||||
act(() => result.current.editMembers());
|
||||
await waitFor(() => expect(result.current.candidates).toHaveLength(2));
|
||||
expect(result.current.choice).toEqual({ 2: 'editor', 3: null });
|
||||
|
||||
act(() => result.current.setAll('viewer'));
|
||||
await act(async () => {
|
||||
await result.current.save();
|
||||
});
|
||||
expect(writes).toEqual([
|
||||
{
|
||||
method: 'PATCH',
|
||||
body: {
|
||||
members: [
|
||||
{ user_id: 2, role: 'viewer' },
|
||||
{ user_id: 3, role: 'viewer' },
|
||||
],
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(toast.success).toHaveBeenCalledWith('trip.journal.membersSaved');
|
||||
});
|
||||
|
||||
it('FE-TJL-HOOK-011: unlinking takes the journal off the trip', async () => {
|
||||
answer = LINKED;
|
||||
const { result } = renderHook(() => useTripJournalLink(TRIP));
|
||||
await waitFor(() => expect(result.current.canUnlink).toBe(true));
|
||||
await act(async () => {
|
||||
await result.current.unlink();
|
||||
});
|
||||
expect(writes).toEqual([{ method: 'DELETE', body: null }]);
|
||||
expect(result.current.link).toBeNull();
|
||||
expect(toast.success).toHaveBeenCalledWith('trip.journal.unlinked');
|
||||
});
|
||||
|
||||
it('FE-TJL-HOOK-012: a refused save says it was not allowed and keeps the form open', async () => {
|
||||
routes({ writeStatus: 403 });
|
||||
const { result } = renderHook(() => useTripJournalLink(TRIP));
|
||||
await waitFor(() => expect(result.current.visible).toBe(true));
|
||||
act(() => result.current.startPick());
|
||||
act(() => result.current.pickJournal(9));
|
||||
await waitFor(() => expect(result.current.candidates).toHaveLength(2));
|
||||
await act(async () => {
|
||||
await result.current.save();
|
||||
});
|
||||
expect(toast.error).toHaveBeenCalledWith('trip.journal.errorForbidden');
|
||||
expect(result.current.mode).toBe('pick');
|
||||
expect(result.current.busy).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-TJL-HOOK-013: offline, picking says a connection is needed', async () => {
|
||||
const { result } = renderHook(() => useTripJournalLink(TRIP));
|
||||
await waitFor(() => expect(result.current.visible).toBe(true));
|
||||
setForcedOffline(true);
|
||||
act(() => result.current.startPick());
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('trip.journal.errorOffline'));
|
||||
expect(result.current.journals).toEqual([]);
|
||||
});
|
||||
|
||||
it('FE-TJL-HOOK-014: a failing candidate list is reported and cancel goes back to the view', async () => {
|
||||
server.use(
|
||||
http.get('/api/trips/:tripId/journal/candidates', () => HttpResponse.json({ error: 'x' }, { status: 500 }))
|
||||
);
|
||||
const { result } = renderHook(() => useTripJournalLink(TRIP));
|
||||
await waitFor(() => expect(result.current.visible).toBe(true));
|
||||
act(() => result.current.startPick());
|
||||
act(() => result.current.pickJournal(9));
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('trip.journal.errorGeneric'));
|
||||
expect(result.current.candidates).toEqual([]);
|
||||
act(() => result.current.cancel());
|
||||
expect(result.current.mode).toBe('view');
|
||||
expect(result.current.canSave).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,243 +0,0 @@
|
||||
import type { TripJournalCandidate, TripJournalLinkMember, TripJournalLinkRole } from '@trek/shared';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useNetworkMode } from '../../hooks/useNetworkMode';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { tripJournalLinkRepo, TripJournalOfflineError, type OwnJournal } from '../../repo/tripJournalLinkRepo';
|
||||
import { useAddonStore } from '../../store/addonStore';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { useTripJournalLinkStore } from '../../store/tripJournalLinkStore';
|
||||
import { useServerPing } from '../../sync/useServerPing';
|
||||
import { useToast } from '../shared/Toast';
|
||||
|
||||
/** Who a link lets in, as the member list edits it: a role, or left out. */
|
||||
export type JournalMemberChoice = Record<number, TripJournalLinkRole | null>;
|
||||
|
||||
/** One empty list for "the link holds nobody", so the member effect does not rerun on every render. */
|
||||
const NOBODY: TripJournalLinkMember[] = [];
|
||||
|
||||
/** The status of a failed request, when the server gave one. */
|
||||
function statusOf(err: unknown): number | undefined {
|
||||
return (err as { response?: { status?: number } })?.response?.status;
|
||||
}
|
||||
|
||||
/**
|
||||
* The trip's linked journal as everybody on the trip reads it: answered once per
|
||||
* trip from the store, asked again on mount, when the connection returns and on
|
||||
* every `trip:journal:changed`. With the Journey addon off there is no journal.
|
||||
*/
|
||||
export function useTripJournal(tripId: number | null | undefined) {
|
||||
const addonsLoaded = useAddonStore((s) => s.loaded);
|
||||
const journeyOn = useAddonStore((s) => s.isEnabled('journey'));
|
||||
const answer = useTripJournalLinkStore((s) => (tripId != null ? s.byTrip[String(tripId)] : undefined));
|
||||
const refresh = useTripJournalLinkStore((s) => s.refresh);
|
||||
const { offline } = useNetworkMode();
|
||||
const active = journeyOn && tripId != null && !offline;
|
||||
const reload = useCallback(async () => {
|
||||
if (tripId != null) await refresh(tripId);
|
||||
}, [tripId, refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
if (active) void reload();
|
||||
}, [active, reload]);
|
||||
useServerPing('trip:journal:changed', active, reload);
|
||||
|
||||
const link = journeyOn ? (answer?.link ?? null) : null;
|
||||
return {
|
||||
journeyOn,
|
||||
answer: journeyOn ? answer : undefined,
|
||||
link,
|
||||
/** The journal the trip's Journal tab opens: linked, and open to this person. */
|
||||
openJournalId: link?.my_role ? link.journey_id : null,
|
||||
/** Whether the answer is in, or none is coming (addon off, offline): the planner may drop the tab then. */
|
||||
settled: addonsLoaded && (!journeyOn || offline || answer !== undefined),
|
||||
};
|
||||
}
|
||||
|
||||
/** Whether the trip's Settings carry a Journal section for this person, for laying the dialog out. */
|
||||
export function useTripJournalSectionVisible(tripId: number): boolean {
|
||||
const { journeyOn, answer } = useTripJournal(tripId);
|
||||
return journeyOn && !!answer && (answer.can_link || !!answer.link);
|
||||
}
|
||||
|
||||
/** The toast for a failed journal request: offline, refused, or anything else. */
|
||||
function useJournalFailure() {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
return useCallback(
|
||||
(err: unknown) => {
|
||||
if (err instanceof TripJournalOfflineError) toast.error(t('trip.journal.errorOffline'));
|
||||
else if (statusOf(err) === 403) toast.error(t('trip.journal.errorForbidden'));
|
||||
else toast.error(t('trip.journal.errorGeneric'));
|
||||
},
|
||||
[t, toast]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The people of the trip for one journal, each with the role the save would
|
||||
* give them. `current` is who the link holds now, to start from; without it
|
||||
* everybody starts as a reader, the default the section promises.
|
||||
*
|
||||
* What loaded is kept with the journal it belongs to, so picking another one
|
||||
* shows nothing until its own people are in, never the last journal's.
|
||||
*/
|
||||
function useJournalMemberChoice(
|
||||
tripId: number,
|
||||
journeyId: number | null,
|
||||
current: TripJournalLinkMember[] | null,
|
||||
fail: (err: unknown) => void
|
||||
) {
|
||||
const [loaded, setLoaded] = useState<{ journeyId: number; list: TripJournalCandidate[] } | null>(null);
|
||||
const [choice, setChoice] = useState<JournalMemberChoice>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (journeyId == null) return;
|
||||
let cancelled = false;
|
||||
tripJournalLinkRepo
|
||||
.candidates(tripId, journeyId)
|
||||
.then((list) => {
|
||||
if (cancelled) return;
|
||||
const held = new Map((current ?? []).map((m) => [m.user_id, m.role]));
|
||||
setLoaded({ journeyId, list });
|
||||
setChoice(Object.fromEntries(list.map((c) => [c.user_id, current ? (held.get(c.user_id) ?? null) : 'viewer'])));
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (cancelled) return;
|
||||
fail(err);
|
||||
setLoaded({ journeyId, list: [] });
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [tripId, journeyId, current, fail]);
|
||||
|
||||
const candidates = journeyId != null && loaded?.journeyId === journeyId ? loaded.list : null;
|
||||
|
||||
const setRole = useCallback((memberId: number, role: TripJournalLinkRole | null) => {
|
||||
setChoice((prev) => ({ ...prev, [memberId]: role }));
|
||||
}, []);
|
||||
|
||||
const setAll = useCallback(
|
||||
(role: TripJournalLinkRole | null) =>
|
||||
setChoice(Object.fromEntries((candidates ?? []).map((c) => [c.user_id, role]))),
|
||||
[candidates]
|
||||
);
|
||||
|
||||
const chosenMembers = useMemo(
|
||||
() => Object.entries(choice).flatMap(([id, role]) => (role ? [{ user_id: Number(id), role }] : [])),
|
||||
[choice]
|
||||
);
|
||||
|
||||
/** The people the save would let into the journal for the first time; the warning names them. */
|
||||
const newlyGranted = useMemo(
|
||||
() => (candidates ?? []).filter((c) => c.access === null && choice[c.user_id]),
|
||||
[candidates, choice]
|
||||
);
|
||||
|
||||
return { candidates, choice, setRole, setAll, chosenMembers, newlyGranted };
|
||||
}
|
||||
|
||||
/**
|
||||
* The Journal section of the trip's Settings: link one of your journals, choose
|
||||
* who it is open to and as what, change it, or take it off. One hook for the
|
||||
* desktop dialog and the phone sheet, so both offer and warn about the same thing.
|
||||
*
|
||||
* Picking a journal asks the server who of the trip already has access to it;
|
||||
* whoever would be let in for the first time is named before anything is saved,
|
||||
* because they will see the journal in their own Journey list from then on.
|
||||
*/
|
||||
export function useTripJournalLink(tripId: number) {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const fail = useJournalFailure();
|
||||
const userId = useAuthStore((s) => s.user?.id ?? null);
|
||||
const { journeyOn, answer, link } = useTripJournal(tripId);
|
||||
const store = useTripJournalLinkStore.getState;
|
||||
|
||||
/** 'view' shows the link; 'pick' chooses a journal; 'members' changes who it is open to. */
|
||||
const [mode, setMode] = useState<'view' | 'pick' | 'members'>('view');
|
||||
const [journals, setJournals] = useState<OwnJournal[] | null>(null);
|
||||
const [pickedId, setPickedId] = useState<number | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const journeyId = mode === 'members' ? (link?.journey_id ?? null) : mode === 'pick' ? pickedId : null;
|
||||
// Who the link holds, to start from: when editing it, or when re-picking the
|
||||
// journal it already shows with people on it.
|
||||
const held = link?.members ?? NOBODY;
|
||||
const current = mode === 'members' || (link?.journey_id === pickedId && held.length > 0) ? held : null;
|
||||
const members = useJournalMemberChoice(tripId, journeyId, current, fail);
|
||||
const { chosenMembers } = members;
|
||||
|
||||
const startPick = useCallback(() => {
|
||||
setMode('pick');
|
||||
setPickedId(null);
|
||||
if (userId == null) return;
|
||||
tripJournalLinkRepo
|
||||
.ownJournals(userId)
|
||||
.then(setJournals)
|
||||
.catch((err: unknown) => {
|
||||
fail(err);
|
||||
setJournals([]);
|
||||
});
|
||||
}, [userId, fail]);
|
||||
|
||||
const editMembers = useCallback(() => setMode('members'), []);
|
||||
|
||||
const cancel = useCallback(() => {
|
||||
setMode('view');
|
||||
setPickedId(null);
|
||||
}, []);
|
||||
|
||||
const run = useCallback(
|
||||
async (write: () => Promise<void>, done: string) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await write();
|
||||
toast.success(done);
|
||||
cancel();
|
||||
} catch (err: unknown) {
|
||||
fail(err);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[cancel, fail, toast]
|
||||
);
|
||||
|
||||
const save = useCallback(async () => {
|
||||
if (mode === 'pick' && pickedId != null) {
|
||||
await run(() => store().link(tripId, pickedId, chosenMembers), t('trip.journal.linked'));
|
||||
} else if (mode === 'members') {
|
||||
await run(() => store().setMembers(tripId, chosenMembers), t('trip.journal.membersSaved'));
|
||||
}
|
||||
}, [mode, pickedId, run, store, tripId, chosenMembers, t]);
|
||||
|
||||
const unlink = useCallback(async () => {
|
||||
await run(() => store().unlink(tripId), t('trip.journal.unlinked'));
|
||||
}, [run, store, tripId, t]);
|
||||
|
||||
return {
|
||||
/** The section shows at all: the addon is on and this person may link, or the trip has a journal they see. */
|
||||
visible: journeyOn && !!answer && (answer.can_link || !!answer.link),
|
||||
link,
|
||||
canLink: !!answer?.can_link,
|
||||
canUnlink: !!answer?.can_unlink,
|
||||
canEditMembers: !!answer?.can_edit_members,
|
||||
mode,
|
||||
startPick,
|
||||
cancel,
|
||||
editMembers,
|
||||
journals,
|
||||
pickedId,
|
||||
pickJournal: setPickedId,
|
||||
candidates: members.candidates,
|
||||
choice: members.choice,
|
||||
setRole: members.setRole,
|
||||
setAll: members.setAll,
|
||||
newlyGranted: members.newlyGranted,
|
||||
canSave: !busy && (mode === 'members' || (mode === 'pick' && pickedId != null)),
|
||||
busy,
|
||||
save,
|
||||
unlink,
|
||||
};
|
||||
}
|
||||
@@ -20,7 +20,6 @@ export const TRIP_TAB_IDS = [
|
||||
'finanzplan',
|
||||
'dateien',
|
||||
'collab',
|
||||
'journal',
|
||||
] as const
|
||||
|
||||
export type TripTabId = (typeof TRIP_TAB_IDS)[number]
|
||||
@@ -41,7 +40,6 @@ export const TRIP_TAB_LABEL_KEYS: Record<TripTabId, string> = {
|
||||
finanzplan: 'trip.tabs.budget',
|
||||
dateien: 'trip.tabs.files',
|
||||
collab: 'admin.addons.catalog.collab.name',
|
||||
journal: 'trip.tabs.journal',
|
||||
}
|
||||
|
||||
export const isTripTabId = (value: unknown): value is TripTabId =>
|
||||
|
||||
@@ -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.
@@ -7,7 +7,7 @@ import ContributorInviteDialog from '../../../components/Journey/ContributorInvi
|
||||
import ConfirmDialog from '../../../components/shared/ConfirmDialog'
|
||||
import { ProviderPicker } from '../../../components/Journey/JourneyDetailPageProviderPicker'
|
||||
import { photoUrl, posterlessVideo } from '../../../pages/journeyDetail/JourneyDetailPage.helpers'
|
||||
import { useJourneyDetail, type JourneyDetailOptions } from '../../../pages/journeyDetail/useJourneyDetail'
|
||||
import { useJourneyDetail } from '../../../pages/journeyDetail/useJourneyDetail'
|
||||
import { useJourneyStore } from '../../../store/journeyStore'
|
||||
import { useAddonStore } from '../../../store/addonStore'
|
||||
import DawarichIcon from '../../../components/shared/DawarichIcon'
|
||||
@@ -32,12 +32,8 @@ import MJourneySettingsSheet from './MJourneySettingsSheet'
|
||||
* Journey detail — integrated map with the horizontal 280px card timeline
|
||||
* (Journey tab) and the 2-column photo gallery (Gallery tab). Cards and map
|
||||
* markers stay in sync; tapping the centered card opens the entry sheet.
|
||||
*
|
||||
* A trip that links the journal shows this same screen in its own tab
|
||||
* (`embedded`): the trip's back button and controls sit on top, so the screen
|
||||
* drops its own back button and leaves the trip's URL alone.
|
||||
*/
|
||||
export default function MJourneyDetail({ journeyId, embedded = false }: JourneyDetailOptions = {}) {
|
||||
export default function MJourneyDetail() {
|
||||
const {
|
||||
id, navigate, toast, t,
|
||||
current, loading,
|
||||
@@ -52,7 +48,7 @@ export default function MJourneyDetail({ journeyId, embedded = false }: JourneyD
|
||||
dismissSuggestion, restoreSuggestions, openAtEntryId,
|
||||
loadJourney, updateEntry, deleteEntry, reorderEntries, uploadPhotos,
|
||||
addPickedProviderPhotos, addEntryProviderPhotos,
|
||||
} = useJourneyDetail({ journeyId, embedded })
|
||||
} = useJourneyDetail()
|
||||
|
||||
// The dock's FAB is a sibling of this screen: on the Gallery it becomes the
|
||||
// upload button, so it has to know which tab is open — and it stops knowing
|
||||
@@ -179,7 +175,7 @@ export default function MJourneyDetail({ journeyId, embedded = false }: JourneyD
|
||||
const [params, setParams] = useSearchParams()
|
||||
const [pendingUpload, setPendingUpload] = useState(false)
|
||||
useEffect(() => {
|
||||
if (embedded || params.get('create') !== 'photo') return
|
||||
if (params.get('create') !== 'photo') return
|
||||
setParams(prev => {
|
||||
const next = new URLSearchParams(prev)
|
||||
next.delete('create')
|
||||
@@ -188,7 +184,7 @@ export default function MJourneyDetail({ journeyId, embedded = false }: JourneyD
|
||||
if (!canEditEntries) return
|
||||
setView('gallery')
|
||||
setPendingUpload(true)
|
||||
}, [params, setParams, canEditEntries, setView, embedded])
|
||||
}, [params, setParams, canEditEntries, setView])
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingUpload || !providersReady) return
|
||||
@@ -335,18 +331,14 @@ export default function MJourneyDetail({ journeyId, embedded = false }: JourneyD
|
||||
|
||||
{/* Header: back — segment — upload / overflow menu */}
|
||||
<div className="absolute inset-x-4 top-[var(--m-safe-top,12px)] z-10 flex items-center justify-between gap-2">
|
||||
{embedded ? (
|
||||
<span className="w-[38px] flex-none" aria-hidden />
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/journey')}
|
||||
aria-label={t('journey.detail.backToJourney')}
|
||||
className="flex h-[38px] w-[38px] flex-none items-center justify-center rounded-full bg-[color:var(--m-sheet)] text-m-ink shadow-[0_5px_14px_-6px_rgba(0,0,0,.3)]"
|
||||
>
|
||||
<ChevronLeft size={18} strokeWidth={2.2} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/journey')}
|
||||
aria-label={t('journey.detail.backToJourney')}
|
||||
className="flex h-[38px] w-[38px] flex-none items-center justify-center rounded-full bg-[color:var(--m-sheet)] text-m-ink shadow-[0_5px_14px_-6px_rgba(0,0,0,.3)]"
|
||||
>
|
||||
<ChevronLeft size={18} strokeWidth={2.2} />
|
||||
</button>
|
||||
<div className="absolute left-1/2 top-0 flex -translate-x-1/2 rounded-full bg-[color:var(--m-sheet)] p-[3px] shadow-[0_5px_14px_-6px_rgba(0,0,0,.3)]">
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { useRef, useState } from 'react'
|
||||
import { X, Image, Plus, Trash2, UserPlus, Link as LinkIcon, List, Grid3x3, MapPin, Route, Archive, ArchiveRestore, Undo2, Link2 } from 'lucide-react'
|
||||
import {
|
||||
X, Image, Plus, Trash2, UserPlus, Link as LinkIcon,
|
||||
List, Grid3x3, MapPin, Route, Archive, ArchiveRestore, Undo2,
|
||||
} from 'lucide-react'
|
||||
import MSheet from '../../components/MSheet'
|
||||
import MIconBtn from '../../components/MIconBtn'
|
||||
import MToggle from '../../components/MToggle'
|
||||
import ConfirmDialog from '../../../components/shared/ConfirmDialog'
|
||||
import { useTranslation } from '../../../i18n'
|
||||
import type { JourneyDetail, JourneyTripAccess } from '../../../store/journeyStore'
|
||||
import type { JourneyDetail } from '../../../store/journeyStore'
|
||||
import { useJourneySettings, useJourneyTripLinking } from '../../../components/Journey/useJourneySettings'
|
||||
import { useJourneyShareLink } from '../../../components/Journey/useJourneyShareLink'
|
||||
import { pickGradient } from '../../../pages/journeyDetail/JourneyDetailPage.helpers'
|
||||
@@ -234,8 +237,6 @@ export default function MJourneySettingsSheet({
|
||||
{t('journey.contributors.invite')}
|
||||
</button>
|
||||
|
||||
<MTripAccessList access={journey.trip_access} eyebrow={eyebrow} />
|
||||
|
||||
{/* Public share */}
|
||||
<div className={`${eyebrow} mb-[6px] mt-[14px]`}>{t('journey.share.publicShare')}</div>
|
||||
{!shareLink ? (
|
||||
@@ -332,27 +333,3 @@ export default function MJourneySettingsSheet({
|
||||
</MSheet>
|
||||
)
|
||||
}
|
||||
|
||||
/** Who a trip's linked journal lets in (#2683): shown under the contributors, but changed in that trip. */
|
||||
function MTripAccessList({ access, eyebrow }: { access: JourneyTripAccess[] | undefined; eyebrow: string }) {
|
||||
const { t } = useTranslation()
|
||||
if (!access?.length) return null
|
||||
return (
|
||||
<>
|
||||
<div className={`${eyebrow} mb-[6px] mt-[14px]`}>{t('journey.contributors.viaTrips')}</div>
|
||||
<p className="m-0 mb-[6px] text-caption text-m-faint">{t('journey.contributors.viaTripsHint')}</p>
|
||||
{access.map(a => (
|
||||
<div key={`${a.user_id}-${a.trip_id}`} className="flex items-center gap-[10px] px-[2px] py-1">
|
||||
<Link2 size={15} className="flex-none text-m-muted" />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-caption font-bold">{a.username}</span>
|
||||
<span className="block truncate text-caption text-m-faint">{t('journey.contributors.viaTrip', { trip: a.trip_title })}</span>
|
||||
</span>
|
||||
<span className="rounded-full bg-[color:var(--m-ic)] px-[10px] py-[3px] font-geist text-caption font-extrabold text-m-muted">
|
||||
{a.role}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ChevronRight, FileDown, Settings, Settings2 } from 'lucide-react'
|
||||
import { ChevronRight, FileDown, Settings, Share2 } from 'lucide-react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import MSheet from '../../../components/MSheet'
|
||||
import type { MTripSheetsProps } from '../MTripShell'
|
||||
@@ -14,7 +14,6 @@ const TILE_COLORS: Record<string, string> = {
|
||||
finanzplan: '#E8A33D',
|
||||
dateien: '#68686F',
|
||||
collab: '#EC4899',
|
||||
journal: '#C2410C',
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -49,7 +48,7 @@ export default function MMehrSheet({ planner, shell }: MTripSheetsProps) {
|
||||
}
|
||||
|
||||
const actions: { id: string; icon: LucideIcon; label: string; go: () => void }[] = [
|
||||
{ id: 'members', icon: Settings2, label: t('trip.settings.title'), go: () => shell.openSheet('members') },
|
||||
{ id: 'members', icon: Share2, label: t('members.shareTrip'), go: () => shell.openSheet('members') },
|
||||
{ id: 'export', icon: FileDown, label: t('mobileTrip.export'), go: () => shell.openSheet('export') },
|
||||
...(canEditTrip
|
||||
? [{ id: 'tripedit', icon: Settings, label: t('dashboard.editTrip'), go: () => shell.openSheet('tripedit') }]
|
||||
|
||||
@@ -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>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user