Compare commits

..

20 Commits

Author SHA1 Message Date
Maurice 17029d364a ci(lint): lintTextClient stays inside the client ratchet
Only check() uses it, as its default; exported it was an unused export to knip, which turned the server quality job on dev red.
2026-10-18 23:16:37 +02:00
Maurice f458278e88 ci(lint): a grown warning count names the file and line that grew it
lint:warnings failed with the total and the files holding the most of the rule, which are old ones the baseline allows; the file that tipped the count was never named, so finding it meant linting by hand. When a rule grows, the ratchet now lints the files that differ from the base twice, as they are and as they were there, and lists every one holding more of the rule, line and message. The base is TREK_LINT_BASE (the CI fetches the pushed-over commit on a push and the target branch on a pull request) or origin/dev; without it the report stays as it was.
2026-10-18 22:46:00 +02:00
Maurice fca42d46d9 refactor(journey): the phone's trip access list as its own component
The list of who a linked trip lets in took MJourneySettingsSheet past the 300-line limit for a component (max-lines-per-function), which turned the client lint on dev red. It is MTripAccessList beside it now; the markup is unchanged.
2026-10-18 22:36:57 +02:00
Maurice 532010aa79 feat(journey): the journal settings list who a linked trip lets in
Under the contributors, the journey settings on desktop and phone get a section of their own, Through trips, with everyone a trip's journal link lets in, their role and the trip they come through. It is read only there, since the trip's settings manage it, and it leaves out whoever is a contributor already. getJourneyFull answers it as trip_access.
2026-10-18 22:04:41 +02:00
Maurice 451167bde3 fix(trips): the settings button as a pill beside the trip's, and the dialog centred
The settings button was a bare round icon next to the trip's back pill; it is the same pill now, with the gear and the word Settings. The settings dialog opens centred again: its fixed height already keeps it from jumping between areas, so it does not need to be pinned to the top.
2026-10-18 22:04:40 +02:00
Maurice 92181cdefb test(trips): the suites follow the settings dialog's areas and the journal link
The dialog's nav buttons are named by their area alone (aria-label), so a hint line naming invites no longer matches the invite button. The sharing cases open the Sharing area first, the wiring and More sheet tests drive the settings button and row, the hook test reads the toasts as keys (renderHook loads no locale), and the lazy list and store registry know the journal panel and store.
2026-10-18 20:49:38 +02:00
Maurice ffa7333bb1 test(journey): the journal link repository on its own, without the two reads nothing called
linkRecipientUserIds and listTripIdsForJourney had no caller and kept src/db/repositories under its 99 % statement floor. TJLREPO-001..007 cover the link rows, the members list, the reach across trips ending with the membership, the hide-skeletons choice of a link member, the role precedence and leaveJournalLink.
2026-10-18 20:40:46 +02:00
Maurice 48d573c60d refactor(trips): leaving a trip tidies its journal link in the roster's own repository
The departure port needed a global binding that every suite building TripMembersModule without AppModule lacked. The roster's repository now deletes the leaver's row on the trip's journal link itself (leaveJournalLink), inside the transaction that removes the member: no edge into journey, no DI, no test stub, and the eight suites the stub touched are back as they were.

The schema inventory tests count the two new tables and their NOT NULL columns, the legacy step map lists the migration as post-legacy, and the TJL scenes give their users numbered names so cases sharing one database do not collide.
2026-10-18 20:02:09 +02:00
Maurice 33c5c7decc test(shared): the event inventory counts trip:journal:changed 2026-10-18 19:23:56 +02:00
Maurice 3db20dd71c chore(client): theme baseline down one for the share button the navbar lost 2026-10-18 19:13:36 +02:00
Maurice 0751b3e8c9 fix(trips): the journal link's toast and tab texts as literal keys
lint:i18n-keys only reaches a key through a literal t() or a *Key property: the three save toasts were passed to run() as bare keys and the tab label came out of TRIP_TAB_LABEL_KEYS, so four new en keys counted as unused. They are translated where they are named now.
2026-10-18 19:04:10 +02:00
Maurice 4a074ec93c refactor(journey): the link role from the generated entity, not a second union
TripJournalLinksRepository spelled 'viewer' | 'editor' by hand while the generated entity exports the same roles, which knip then counted as an unused export. The repository now derives its type and values from the entity.
2026-10-18 18:54:02 +02:00
Maurice a6da69ee36 refactor(trips): the member departure port in common/, where both sides may import it
Declared in trip-members, the port gave journey an edge into the roster, which reaches journey through other domains: the cycle again, the other way round. In the shared kernel neither domain owns it.
2026-10-18 18:44:02 +02:00
Maurice ce8d1e5a0a refactor(trips): leaving a trip reaches the journal link through a port, not an import
TripMembersService called TripJournalLinkService directly, which made trip-members and journey depend on each other (lint:boundaries). The roster now declares TRIP_MEMBER_DEPARTURE and calls memberLeft in the transaction that removes the member; the journey domain binds it globally (TripMemberDepartureModule), the way JourneyAccessModule binds the gateway's JOURNEY_ACCESS. Suites that build TripMembersModule alone import MemberDepartureStubModule.
2026-10-18 18:31:26 +02:00
Maurice 340d429415 test(trips): the journal link stub beside the other journey share helpers
budget.service.db and trips.service tests are held at their size baselines; the stub now comes from journey-share-repos, which both already import, so neither suite grows.
2026-10-18 18:17:04 +02:00
Maurice 89c96ed554 fix(trips): the embedded journal's offsets as classes on its wrapper, not in index.css
index.css is held at its line baseline. The two variables the embedded journal overrides (--nav-h on the desktop, --m-safe-top on the phone) now sit as arbitrary properties on the wrapper that needs them.
2026-10-18 18:04:34 +02:00
Maurice 889e88c362 refactor(trips): the journal member choice in a hook of its own, the test seed without an await loop
useTripJournalLink had grown past the 120-line limit for a function. The member list (candidates per journal, roles, who is new) is now useJournalMemberChoice. What loaded is kept with the journal it belongs to, so a new pick shows nothing until its own people are in, without resetting state by hand. The integration test adds its trip members with Promise.all.
2026-10-18 17:55:41 +02:00
Maurice fe05afec19 feat(trips): trip settings with a linked journal tab
The Share button becomes the trip's Settings, a round button beside the back
pill. The dialog is rebuilt around three areas (People, Sharing, Journal) with
a side navigation, a fixed height and 960 px width on wide screens.

Journal links one of your journals from the Journey addon to the trip. Every
trip member is ticked as a reader by default, each can be raised to editor or
left out, and the people a save would let in for the first time are named
before it saves. The link can be changed or removed. With a link the trip gets
a Journal tab on desktop and phone that shows the Journey addon's own view,
live through its sync and trip:journal:changed. Nothing shows with the addon
off.

members.shareTrip and nav.share are gone with the button that used them.
2026-10-18 17:41:25 +02:00
Maurice 89f3055a29 fix(journey): one journal link per trip as a unique trip id, factories within tsc's depth
The link table keyed by the trip id made both entities a one-to-one, and the
test typecheck failed with TS2589 in the itinerary factory. It now holds its
own id with trip_id UNIQUE, as trip_invite_tokens does, and its members hang
off link_id.

The factory error was not this table's alone: any further relation on trips,
journeys or users tipped the same check. insertRow and createRow now take a
caller's overrides as their own argument instead of a spread checked against
RequiredEntityData, so a new relation no longer breaks the itinerary and tour
factories.

The suites that hand-build TripMembersService get a journalLinksStub.
2026-10-18 17:41:18 +02:00
Maurice 80b6739362 feat(journey): a trip can link a journal and open it to the members it picks
A trip can now show one journal from the Journey addon as its own: any
journal its owner picks, and one journal on as many trips as they like.
The trip members chosen for the link get into the journal as viewers or
editors, see it in their own journal list and follow it live, for as long
as the link holds them and they stay on the trip.

That access is never copied into journey_contributors. It is read where
journal access is read: the contributor repository answers with the
stronger of a direct role and a link role, the journal list and the live
update recipients take the link members in, and the photo access check
goes the same way. Every one of those reads starts from linkReach, which
only counts members still on the linking trip, so unlinking, deselecting
someone and their leaving the trip all end exactly what the link gave.
A member who leaves is also taken off the link, so coming back later does
not quietly reopen the journal.

Linking or changing the members takes the journal's owner and the new
journal_link trip permission (trip owner by default); unlinking takes
either. /api/trips/:tripId/journal answers the link as each member may see
it, /candidates says who a journal would be opened to for the first time,
and four MCP tools do the same over the same service. A trip:journal:changed
event tells the trip to look again.
2026-10-18 15:59:33 +02:00
218 changed files with 4871 additions and 262 deletions
+20
View File
@@ -257,6 +257,15 @@ 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
@@ -264,6 +273,7 @@ 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
@@ -395,6 +405,15 @@ 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.
@@ -402,6 +421,7 @@ 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
+79 -4
View File
@@ -2,6 +2,7 @@
* 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 {
@@ -137,6 +138,78 @@ 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);
/**
@@ -148,6 +221,7 @@ 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,
}) {
@@ -160,13 +234,13 @@ export async function check({
}
for (const line of errors) error(`FAIL ${line}`);
let grown = 0;
const grown = [];
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++;
grown.push({ area, rule });
const top = Object.entries(files[area][rule])
.sort(([, a], [, b]) => b - a)
.slice(0, 5)
@@ -182,7 +256,8 @@ export async function check({
});
stale += fallen.length;
}
if (grown) {
await reportNew(grown, { root, files, lintText: lintText && lintText(root), error });
if (grown.length) {
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.'
@@ -191,5 +266,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 || stale ? 1 : 0;
return errors.length || grown.length || stale ? 1 : 0;
}
+1 -1
View File
@@ -55,7 +55,7 @@
"components/Layout/DemoBanner.tsx": 25,
"components/Layout/InAppNotificationBell.tsx": 10,
"components/Layout/MobileTopBar.tsx": 51,
"components/Layout/Navbar.tsx": 22,
"components/Layout/Navbar.tsx": 21,
"components/Layout/NewVersionNotice.tsx": 3,
"components/Layout/OfflineBanner.tsx": 2,
"components/Layout/PageSidebar.tsx": 3,
+31
View File
@@ -0,0 +1,31 @@
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),
};
+4
View File
@@ -93,6 +93,10 @@ 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'] },
{ id: 'extras', keys: ['budget_edit', 'packing_edit', 'collab_edit', 'share_manage', 'journal_link'] },
];
/**
@@ -98,6 +98,19 @@ 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 } from '../../store/journeyStore'
import type { JourneyContributor, JourneyDetail, JourneyTripAccess } from '../../store/journeyStore'
import { pickGradient } from '../../pages/journeyDetail/JourneyDetailPage.helpers'
import { AddTripDialog } from './JourneyDetailPageAddTripDialog'
import ToggleSwitch from '../Settings/ToggleSwitch'
@@ -277,6 +277,8 @@ 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>
@@ -324,3 +326,32 @@ 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>
)
}
+14 -18
View File
@@ -169,23 +169,24 @@ describe('Navbar', () => {
expect(adminLink).toHaveAttribute('href', '/admin');
});
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-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-020: share button click calls onShare', async () => {
it('FE-COMP-NAVBAR-020: the trip settings button calls onSettings', async () => {
const user = userEvent.setup();
const onShare = vi.fn();
render(<Navbar onShare={onShare} />);
const shareBtn = screen.getByRole('button', { name: /share/i });
await user.click(shareBtn);
expect(onShare).toHaveBeenCalled();
const onSettings = vi.fn();
render(<Navbar onSettings={onSettings} />);
await user.click(screen.getByRole('button', { name: 'Trip settings' }));
expect(onSettings).toHaveBeenCalled();
});
it('FE-COMP-NAVBAR-021: share button NOT rendered when onShare prop omitted', () => {
it('FE-COMP-NAVBAR-021: no trip settings button without onSettings, and no share button any more', () => {
render(<Navbar />);
expect(screen.queryByRole('button', { name: 'Trip settings' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: /share/i })).not.toBeInTheDocument();
});
@@ -453,8 +454,8 @@ describe('Navbar styling and menu details', () => {
expect(screen.queryByText('3.5.0-rc1')).not.toBeInTheDocument();
});
it('FE-W5NAV-009: the back, share and theme buttons reset their hover background', () => {
render(<Navbar showBack onBack={vi.fn()} onShare={vi.fn()} />);
it('FE-W5NAV-009: the back and theme buttons reset their hover background', () => {
render(<Navbar showBack onBack={vi.fn()} onSettings={vi.fn()} />);
const back = screen.getByRole('button', { name: /back/i });
fireEvent.mouseEnter(back);
@@ -462,11 +463,6 @@ 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);
+19 -14
View File
@@ -6,7 +6,7 @@ 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, Users, Moon, Sun, Monitor, CalendarDays, Briefcase, Globe, Compass, BookOpen, Bookmark } from 'lucide-react'
import { Plane, LogOut, Settings, ChevronDown, Shield, ArrowLeft, ChevronLeft, 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'
@@ -21,7 +21,8 @@ interface NavbarProps {
tripId?: number | string
onBack?: () => void
showBack?: boolean
onShare?: () => void
/** The trip's Settings: who is on it, its links and its journal. */
onSettings?: () => void
}
interface Addon {
@@ -32,7 +33,7 @@ interface Addon {
enabled: boolean
}
export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }: NavbarProps): React.ReactElement {
export default function Navbar({ tripTitle, tripId, onBack, showBack, onSettings }: NavbarProps): React.ReactElement {
const { user, logout, isPrerelease, appVersion } = useAuthStore()
const { settings, updateSetting } = useSettingsStore()
const { addons: allAddons, loadAddons } = useAddonStore()
@@ -173,6 +174,21 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
<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).
@@ -245,17 +261,6 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
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 />}
@@ -0,0 +1,37 @@
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>
);
}
@@ -0,0 +1,150 @@
// 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);
});
});
@@ -0,0 +1,324 @@
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 Share Trip title', () => {
it('FE-COMP-MEMBERS-002: shows the Trip settings title', () => {
render(<TripMembersModal {...defaultProps} />);
// members.shareTrip = "Share Trip"
expect(screen.getByText('Share Trip')).toBeInTheDocument();
// trip.settings.title = "Trip settings"
expect(screen.getByText('Trip settings')).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('Share Trip')).toBeInTheDocument();
expect(screen.getByText('Trip settings')).toBeInTheDocument();
});
// ── Share Link Section (016-021) ───────────────────────────────────────────
@@ -291,6 +291,7 @@ 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);
@@ -689,6 +690,7 @@ 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);
@@ -719,6 +721,7 @@ 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 }));
@@ -734,6 +737,7 @@ 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,3 +1,4 @@
import { TripJournalLinkSection } from './TripJournalLinkSection'
import Modal from '../shared/Modal'
import { Crown, UserMinus, UserPlus, Users, LogOut, Link2, Trash2, Copy, Check, UserRound, Pencil, Plus } from 'lucide-react'
import { useTranslation } from '../../i18n'
@@ -181,7 +182,7 @@ function TripMembersSheet({ isOpen, onClose, tripId, tripTitle, onMembersChanged
}
return (
<Modal isOpen={isOpen} onClose={onClose} title={t('members.shareTrip')} size="3xl">
<Modal isOpen={isOpen} onClose={onClose} title={t('trip.settings.title')} 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>
@@ -402,6 +403,8 @@ 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>
+81 -11
View File
@@ -1,12 +1,15 @@
import { useId, useState, type ReactNode } from 'react'
import { Check, Copy, Crown, Link2, LogOut, Pencil, Plus, Trash2, UserMinus, UserPlus, UserRound, Users } from 'lucide-react'
import { Check, Copy, Crown, Link2, LogOut, Pencil, Plus, Settings, 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 {
@@ -29,18 +32,38 @@ 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 desktop dialog behind Share: who is on the trip, its guests, and the two links that let others in. */
/**
* 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.
*/
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 (
@@ -48,15 +71,16 @@ function OpenDialog({ onClose, tripId, tripTitle, onMembersChanged }: TripShareD
<DialogShell
onClose={onClose}
labelledBy={labelId}
width={m.canManageShare ? 'wide' : 'detail'}
width={areas.length > 1 ? 'wide' : 'detail'}
blocked={!!ask}
bodyClassName={BODY}
header={(
<DialogHeader
tile={<DialogTile><Users size={20} strokeWidth={1.9} className="text-content-muted" /></DialogTile>}
tile={<DialogTile><Settings size={20} strokeWidth={1.9} className="text-content-muted" /></DialogTile>}
tint={NEUTRAL_TINT}
labelId={labelId}
onClose={onClose}
eyebrow={t('members.shareTrip')}
eyebrow={t('trip.settings.title')}
title={tripTitle}
pills={m.loading ? undefined : (
<>
@@ -67,17 +91,24 @@ function OpenDialog({ onClose, tripId, tripTitle, onMembersChanged }: TripShareD
/>
)}
>
<div className={m.canManageShare ? 'grid grid-cols-2 items-start gap-6 max-md:grid-cols-1' : ''}>
<div className="flex flex-col gap-5">
{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}>
{m.canManageMembers && <InviteUser m={m} />}
<MembersList m={m} onAsk={setAsk} />
{(m.isCurrentOwner || m.guests.length > 0) && <GuestsList m={m} onAsk={setAsk} />}
</div>
</AreaPanel>
{m.canManageShare && (
<div className="flex flex-col gap-5">
<AreaPanel area={panelOf('sharing')} shown={current.id === 'sharing'} titled>
<PublicLink tripId={tripId} />
<InviteLink tripId={tripId} />
</div>
</AreaPanel>
)}
{journalSection && (
<AreaPanel area={panelOf('journal')} shown={current.id === 'journal'} titled>
<TripJournalLinkSection tripId={tripId} framed={false} />
</AreaPanel>
)}
</div>
</DialogShell>
@@ -93,6 +124,45 @@ 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 (
@@ -291,7 +361,7 @@ function LinkLine({ url, copied, onCopy }: { url: string; copied: boolean; onCop
)
}
function LinkCard({ icon, title, hint, children }: { icon: ReactNode; title: string; hint: string; children: ReactNode }) {
export 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">
@@ -0,0 +1,290 @@
// 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);
});
});
@@ -0,0 +1,243 @@
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,
};
}
+2
View File
@@ -20,6 +20,7 @@ export const TRIP_TAB_IDS = [
'finanzplan',
'dateien',
'collab',
'journal',
] as const
export type TripTabId = (typeof TRIP_TAB_IDS)[number]
@@ -40,6 +41,7 @@ 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 =>
@@ -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 } from '../../../pages/journeyDetail/useJourneyDetail'
import { useJourneyDetail, type JourneyDetailOptions } from '../../../pages/journeyDetail/useJourneyDetail'
import { useJourneyStore } from '../../../store/journeyStore'
import { useAddonStore } from '../../../store/addonStore'
import DawarichIcon from '../../../components/shared/DawarichIcon'
@@ -32,8 +32,12 @@ 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() {
export default function MJourneyDetail({ journeyId, embedded = false }: JourneyDetailOptions = {}) {
const {
id, navigate, toast, t,
current, loading,
@@ -48,7 +52,7 @@ export default function MJourneyDetail() {
dismissSuggestion, restoreSuggestions, openAtEntryId,
loadJourney, updateEntry, deleteEntry, reorderEntries, uploadPhotos,
addPickedProviderPhotos, addEntryProviderPhotos,
} = useJourneyDetail()
} = useJourneyDetail({ journeyId, embedded })
// 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
@@ -175,7 +179,7 @@ export default function MJourneyDetail() {
const [params, setParams] = useSearchParams()
const [pendingUpload, setPendingUpload] = useState(false)
useEffect(() => {
if (params.get('create') !== 'photo') return
if (embedded || params.get('create') !== 'photo') return
setParams(prev => {
const next = new URLSearchParams(prev)
next.delete('create')
@@ -184,7 +188,7 @@ export default function MJourneyDetail() {
if (!canEditEntries) return
setView('gallery')
setPendingUpload(true)
}, [params, setParams, canEditEntries, setView])
}, [params, setParams, canEditEntries, setView, embedded])
useEffect(() => {
if (!pendingUpload || !providersReady) return
@@ -331,14 +335,18 @@ export default function MJourneyDetail() {
{/* 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">
<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>
{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>
)}
<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,14 +1,11 @@
import { useRef, useState } from 'react'
import {
X, Image, Plus, Trash2, UserPlus, Link as LinkIcon,
List, Grid3x3, MapPin, Route, Archive, ArchiveRestore, Undo2,
} from 'lucide-react'
import { X, Image, Plus, Trash2, UserPlus, Link as LinkIcon, List, Grid3x3, MapPin, Route, Archive, ArchiveRestore, Undo2, Link2 } 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 } from '../../../store/journeyStore'
import type { JourneyDetail, JourneyTripAccess } from '../../../store/journeyStore'
import { useJourneySettings, useJourneyTripLinking } from '../../../components/Journey/useJourneySettings'
import { useJourneyShareLink } from '../../../components/Journey/useJourneyShareLink'
import { pickGradient } from '../../../pages/journeyDetail/JourneyDetailPage.helpers'
@@ -237,6 +234,8 @@ 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 ? (
@@ -333,3 +332,27 @@ 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>
))}
</>
)
}
@@ -1,4 +1,4 @@
import { ChevronRight, FileDown, Settings, Share2 } from 'lucide-react'
import { ChevronRight, FileDown, Settings, Settings2 } from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import MSheet from '../../../components/MSheet'
import type { MTripSheetsProps } from '../MTripShell'
@@ -14,6 +14,7 @@ const TILE_COLORS: Record<string, string> = {
finanzplan: '#E8A33D',
dateien: '#68686F',
collab: '#EC4899',
journal: '#C2410C',
}
/**
@@ -48,7 +49,7 @@ export default function MMehrSheet({ planner, shell }: MTripSheetsProps) {
}
const actions: { id: string; icon: LucideIcon; label: string; go: () => void }[] = [
{ id: 'members', icon: Share2, label: t('members.shareTrip'), go: () => shell.openSheet('members') },
{ id: 'members', icon: Settings2, label: t('trip.settings.title'), 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') }]
@@ -7,6 +7,7 @@ import MFilesTab from './MFilesTab'
import MCollabTab from './MCollabTab'
import MListsTab from './MListsTab'
import MRoadtripTab from '../roadtrip/MRoadtripTab'
import MJourneyDetail from '../../journey/MJourneyDetail'
import PluginFrame from '../../../../components/Plugins/PluginFrame'
/**
@@ -61,6 +62,14 @@ export default function MTripTabPanel({ planner, shell, tab }: MTripTabPanelProp
// switch between them must not lose it.
case 'roadtrip':
return <MRoadtripTab planner={planner} shell={shell} tab={tab} />
// The journal the trip links: the Journey addon's own phone screen, moved
// below the trip's floating top controls: its bars measure from --m-safe-top.
case 'journal':
return planner.journalId == null ? null : (
<div className="absolute inset-0 overflow-hidden [--m-safe-top:calc(env(safe-area-inset-top,0px)+62px)]">
<MJourneyDetail journeyId={planner.journalId} embedded />
</div>
)
default:
return <TabScroller>{null}</TabScroller>
}
+37 -24
View File
@@ -18,7 +18,7 @@ import JourneyDayDawarich from '../components/Journey/JourneyDayDawarich'
import MobileEntryView from '../components/Journey/MobileEntryView'
import { useJourneyStore } from '../store/journeyStore'
import { computeJourneyLifecycle } from '../utils/journeyLifecycle'
import { useJourneyDetail } from './journeyDetail/useJourneyDetail'
import { useJourneyDetail, type JourneyDetailOptions } from './journeyDetail/useJourneyDetail'
import { createDraftJourneyEntry, pickGradient, groupByDate, formatDate, photoUrl, matchJourneyEntries } from './journeyDetail/JourneyDetailPage.helpers'
import { EntryCard, SkeletonCard, CheckinCard } from '../components/Journey/JourneyDetailPageEntryCard'
import { GalleryView } from '../components/Journey/JourneyDetailPageGalleryView'
@@ -39,7 +39,12 @@ export default function JourneyDetailPage() {
)
}
function JourneyDetailPageDesktop() {
/**
* The journal screen. The journal page renders it for the route's journal; a trip
* that links a journal renders the same screen inside its own tab (`embedded`),
* so the trip shows the journal with nothing of it built twice.
*/
export function JourneyDetailPageDesktop({ journeyId, embedded = false }: JourneyDetailOptions = {}) {
// Page = wiring container: load + live sync, view state, dialogs, the
// scroll-synced map and the map/trip-date derivations live in the hook.
const {
@@ -62,13 +67,17 @@ function JourneyDetailPageDesktop() {
feedEdge, scrollFeedTo,
loadJourney, updateEntry, deleteEntry, reorderEntries, uploadPhotos, deletePhoto,
addPickedProviderPhotos, addEntryProviderPhotos,
} = useJourneyDetail()
} = useJourneyDetail({ journeyId, embedded })
// Inside a trip the trip draws the bar and owns the height; the journal fills
// the box it is given and starts at its top.
const frame = embedded ? 'h-full bg-zinc-50 dark:bg-zinc-950' : 'min-h-screen bg-zinc-50 dark:bg-zinc-950'
const below = embedded ? undefined : { paddingTop: 'var(--nav-h, 0px)' }
if (loading || !current) {
return (
<div className="min-h-screen bg-zinc-50 dark:bg-zinc-950">
<Navbar />
<div style={{ paddingTop: 'var(--nav-h, 0px)' }} className="flex justify-center py-20">
<div className={frame}>
{!embedded && <Navbar />}
<div style={below} className="flex justify-center py-20">
<div className="w-6 h-6 border-2 border-zinc-300 border-t-zinc-900 rounded-full animate-spin" />
</div>
</div>
@@ -166,8 +175,8 @@ function JourneyDetailPageDesktop() {
) : null
return (
<div className="min-h-screen bg-zinc-50 dark:bg-zinc-950">
<Navbar />
<div className={frame}>
{!embedded && <Navbar />}
{/* Mobile combined map+timeline (Polarsteps-style) — renders as fullscreen overlay */}
{showMobileCombined && (
@@ -206,13 +215,15 @@ function JourneyDetailPageDesktop() {
className="fixed inset-x-0 z-30 flex items-start justify-between gap-2 px-4"
style={{ top: 'calc(var(--nav-h, 56px) + 12px)' }}
>
<button type="button"
onClick={() => navigate('/journey')}
aria-label={t('journey.detail.backToJourney')}
className={barButton}
>
<ArrowLeft size={16} />
</button>
{!embedded && (
<button type="button"
onClick={() => navigate('/journey')}
aria-label={t('journey.detail.backToJourney')}
className={barButton}
>
<ArrowLeft size={16} />
</button>
)}
<div className="flex-1 min-w-0 flex justify-center">
<div className="flex bg-surface-elevated backdrop-blur-lg border border-edge rounded-lg overflow-hidden shadow-lg">
@@ -270,7 +281,7 @@ function JourneyDetailPageDesktop() {
</div>
)}
<div style={{ paddingTop: 'var(--nav-h, 0px)' }} className={showMobileCombined ? 'hidden' : ''}>
<div style={below} className={showMobileCombined ? 'hidden' : embedded ? 'h-full' : ''}>
<div
className={
isMobile
@@ -310,13 +321,15 @@ function JourneyDetailPageDesktop() {
<div className="relative z-[3] flex items-center justify-between mb-5">
<div className="flex items-center gap-2">
<button type="button"
onClick={() => navigate('/journey')}
aria-label={t('journey.detail.backToJourney')}
className="w-[34px] h-[34px] rounded-full bg-white/15 backdrop-blur flex items-center justify-center hover:bg-white/25"
>
<ArrowLeft size={14} />
</button>
{!embedded && (
<button type="button"
onClick={() => navigate('/journey')}
aria-label={t('journey.detail.backToJourney')}
className="w-[34px] h-[34px] rounded-full bg-white/15 backdrop-blur flex items-center justify-center hover:bg-white/25"
>
<ArrowLeft size={14} />
</button>
)}
{/* Status badge — keep completed/upcoming/draft/archived, but drop live + synced-with-trips per UX trim */}
<div className="hidden md:flex items-center gap-2">
{lifecycle !== 'live' && lifecycle !== 'archived' && (
@@ -821,7 +834,7 @@ function JourneyDetailPageDesktop() {
{/* TREK Studio. It portals itself over the page, so this journey keeps
rendering underneath and shows through the panel's margin. */}
<Outlet />
{!embedded && <Outlet />}
</div>
)
}
+4 -4
View File
@@ -39,7 +39,7 @@ import { latestReservation } from './tripPlanner/transportEditorOpeners'
import {
ReservationsPanel, FileManager, CostsPanel, ExpenseModal, CollabPanel,
RoadtripSidebar, RoadtripCorridorPanel, RoadtripLimitsCard, RoadtripStopPopup, RoadtripStayModal, RoadtripTrackModal,
TourWorkspaceRail, TourPlannerToursRail, TransportModal,
TourWorkspaceRail, TourPlannerToursRail, TransportModal, TripJournalPanel,
} from './tripPlanner/plannerLazy'
import { LazyPanel } from './tripPlanner/LazyPanel'
import { ListsContainer } from './tripPlanner/ListsContainer'
@@ -70,7 +70,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
trip, days, places, assignments, packingItems, todoItems, categories, reservations, budgetItems, files,
selectedDayId, isLoading, tripActions, can, canUploadFiles,
pushUndo, undo, canUndo, lastActionLabel, handleUndo,
enabledAddons, collabFeatures, tripAccommodations, setTripAccommodations,
enabledAddons, collabFeatures, journalId, tripAccommodations, setTripAccommodations,
roadtripMode, toggleRoadtripMode, roadtripActive, roadtripRoutes, roadtripLineColors, roadtripMapLines, roadtripMapPlaces, collapsedRoadtripDays, toggleRoadtripDay, roadtripCorridor,
overviewActive, tripOverview, toggleOverview, overviewShown,
dawarichEnabled, dawarichTrailShown, toggleDawarichTrail, dawarichTrail, dawarichHiddenDates,
@@ -242,7 +242,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
return (
<div style={{ position: 'fixed', inset: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden', ...fontStyle }}>
<HelpAnchor id={helpId} />
<Navbar tripTitle={trip.title} tripId={tripId} showBack onBack={() => navigate('/dashboard')} onShare={() => setShowMembersModal(true)} />
<Navbar tripTitle={trip.title} tripId={tripId} showBack onBack={() => navigate('/dashboard')} onSettings={() => setShowMembersModal(true)} />
<div className="bg-surface-elevated border-b border-edge-faint" style={{
position: 'fixed', top: 'var(--nav-h)', insetInline: 0, zIndex: 40,
@@ -792,7 +792,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
</LazyPanel>
</div>
)}
{activeTab === 'journal' && <LazyPanel id="journal"><TripJournalPanel journeyId={journalId} /></LazyPanel>}
{activeTab.startsWith('plugin:') && (
<div style={{ position: 'absolute', top: 0, insetInline: 0, bottom: 'var(--bottom-nav-h)', overflow: 'hidden' }}>
@@ -657,14 +657,14 @@ describe('TripPlannerPage — shell', () => {
expect(container).toBeEmptyDOMElement()
})
it('FE-PAGE-TPW-005: the navbar gets the trip title and drives back / share', () => {
it('FE-PAGE-TPW-005: the navbar gets the trip title and drives back / settings', () => {
renderPage()
expect(props('navbar').tripTitle).toBe('Kyoto')
act(() => { props('navbar').onBack() })
expect(hookState.navigate).toHaveBeenCalledWith('/dashboard')
act(() => { props('navbar').onShare() })
act(() => { props('navbar').onSettings() })
expect(hookState.setShowMembersModal).toHaveBeenCalledWith(true)
})
@@ -29,9 +29,23 @@ const NO_TRACKS: JourneyTrack[] = []
* map/trip-date derivations. JourneyDetailPage stays a wiring container around
* its large two-pane JSX and many presentational sub-components.
* Behaviour is identical to the previous in-component logic.
*
* The journal can also show inside a trip that links it, as the same screen with
* the same dialogs. There the trip names the journal (`journeyId`) instead of the
* route, and `embedded` keeps the screen to its own box: no scroll lock on the
* page, no `?create=` handling on the trip's URL, and no trip out to the journal
* list when the journal goes away; the trip decides what shows then.
*/
export function useJourneyDetail() {
const { id } = useParams()
export interface JourneyDetailOptions {
/** The journal to show; the route's `:id` when left out. */
journeyId?: number
/** Shown inside another screen (a trip) rather than as the journal page. */
embedded?: boolean
}
export function useJourneyDetail({ journeyId, embedded = false }: JourneyDetailOptions = {}) {
const params = useParams()
const id = journeyId != null ? String(journeyId) : params.id
const navigate = useNavigate()
const toast = useToast()
const { t, locale } = useTranslation()
@@ -67,9 +81,10 @@ export function useJourneyDetail() {
const [showAddTrip, setShowAddTrip] = useState(false)
const [searchParams, setSearchParams] = useSearchParams()
// The bottom-nav "+" starts a new entry via ?create=entry.
// The bottom-nav "+" starts a new entry via ?create=entry. Not inside a trip:
// the trip's own `create` values live on that same URL.
useEffect(() => {
if (searchParams.get('create') === 'entry' && current && canEditEntries) {
if (!embedded && searchParams.get('create') === 'entry' && current && canEditEntries) {
setEditingEntry(createDraftJourneyEntry(current.id))
setSearchParams(p => { p.delete('create'); return p }, { replace: true })
}
@@ -119,11 +134,11 @@ export function useJourneyDetail() {
}, [current?.hide_skeletons])
useEffect(() => {
if (notFound) {
if (notFound && !embedded) {
toast.error(t('journey.notFound'))
navigate('/journey')
}
}, [notFound])
}, [notFound, embedded])
// WebSocket real-time updates
useEffect(() => {
@@ -374,9 +389,9 @@ export function useJourneyDetail() {
// the body must not scroll underneath it. Goes through the shared counter so a
// modal opening on top of it cannot hand the page back early (#1809).
useEffect(() => {
if (isMobile) return
if (isMobile || embedded) return
return lockBodyScroll()
}, [isMobile])
}, [isMobile, embedded])
// Map only shows real journal entries — skeletons are trip-derived
// suggestions, not something the user actually journaled at that spot.
@@ -549,7 +564,7 @@ export function useJourneyDetail() {
}, [])
return {
id, navigate, toast, t, locale,
id, navigate, toast, t, locale, embedded, notFound,
openStudio, prefetchStudio,
current, loading,
canEditEntries, canEditJourney, myRole,
@@ -0,0 +1,20 @@
import { JourneyDetailPageDesktop } from '../JourneyDetailPage';
/**
* The trip's Journal tab on the desktop: the Journey addon's own journal view,
* not a copy of it, so the journal looks and works the same in both places and
* stays live through the journey's own sync.
*
* The view's fixed bars measure themselves from --nav-h, so the wrapper sets it
* to the navbar plus the trip's 44 px tab bar, and they sit below both.
*/
const BELOW_TRIP_TABS = '[--nav-h:44px] md:[--nav-h:calc(64px+env(safe-area-inset-top,0px)+44px)]';
export default function TripJournalPanel({ journeyId }: { journeyId: number | null }) {
if (journeyId == null) return null;
return (
<div className={`${BELOW_TRIP_TABS} absolute inset-0 overflow-y-auto`}>
<JourneyDetailPageDesktop journeyId={journeyId} embedded />
</div>
);
}
@@ -24,6 +24,7 @@ vi.mock('../../components/Tours/planner/TourPlannerPanels', () => ({
TourPlannerToursRail: label('tour planner tours rail'),
}))
vi.mock('../../components/Planner/TransportModal', () => ({ TransportModal: label('transport modal') }))
vi.mock('./TripJournalPanel', () => ({ default: label('trip journal panel') }))
const cases: Array<[keyof typeof lazy, string]> = [
['ReservationsPanel', 'reservations panel'],
@@ -43,6 +44,7 @@ const cases: Array<[keyof typeof lazy, string]> = [
['TourWorkspaceRail', 'tour workspace rail'],
['TourPlannerToursRail', 'tour planner tours rail'],
['TransportModal', 'transport modal'],
['TripJournalPanel', 'trip journal panel'],
]
function renderLazy(name: keyof typeof lazy) {
@@ -17,6 +17,8 @@ export const ExpenseModal = lazyWithRetry(() =>
import('../../components/Budget/CostsPanel').then(m => ({ default: m.ExpenseModal }))
)
export const CollabPanel = lazyWithRetry(() => import('../../components/Collab/CollabPanel'))
// The Journey addon's journal view, which only a trip with a linked journal opens.
export const TripJournalPanel = lazyWithRetry(() => import('./TripJournalPanel'))
export const RoadtripSidebar = lazyWithRetry(() => import('../../components/Roadtrip/RoadtripSidebar'))
export const RoadtripCorridorPanel = lazyWithRetry(() => import('../../components/Roadtrip/RoadtripCorridorPanel'))
export const RoadtripLimitsCard = lazyWithRetry(() => import('../../components/Roadtrip/RoadtripLimitsCard'))
@@ -24,6 +24,8 @@ import { isServiceStopType } from '../../components/Roadtrip/roadtripModel'
import { inspectorStay } from '../../components/Roadtrip/stayReading'
import { usePlaceSelection } from '../../hooks/usePlaceSelection'
import { useTourPlaceIds } from '../../hooks/useTourPlaceIds'
import { LinkedJournalIcon } from '../../components/Trips/LinkedJournalIcon'
import { useTripJournal } from '../../components/Trips/useTripJournalLink'
import { useAddonStore } from '../../store/addonStore'
import { usePlannerHistory } from '../../hooks/usePlannerHistory'
import { useIsTouch } from '../../hooks/useIsTouch'
@@ -198,6 +200,7 @@ export function useTripPlanner() {
// A trip-page plugin may replace core tabs while it's active (its manifest names
// them; 'plan' is never replaceable) and may pick where its own tab sits.
const journal = useTripJournal(Number.isFinite(tripId) ? tripId : null)
const replacedTabs = new Set(tripPagePlugins.flatMap(p => p.tripPage?.replaces ?? []))
const TRIP_TABS = [
{ id: 'plan', label: t(TRIP_TAB_LABEL_KEYS.plan), icon: Map },
@@ -211,6 +214,8 @@ export function useTripPlanner() {
...(enabledAddons.budget ? [{ id: 'finanzplan', label: t(TRIP_TAB_LABEL_KEYS.finanzplan), icon: Wallet }] : []),
...(enabledAddons.documents ? [{ id: 'dateien', label: t(TRIP_TAB_LABEL_KEYS.dateien), icon: FolderOpen }] : []),
...(enabledAddons.collab ? [{ id: 'collab', label: t(TRIP_TAB_LABEL_KEYS.collab), icon: Users }] : []),
// A journal from the Journey addon tied to this trip, for whoever the link lets in.
...(journal.openJournalId != null ? [{ id: 'journal', label: t('trip.tabs.journal'), icon: LinkedJournalIcon }] : []),
].filter(tab => tab.id === 'plan' || !replacedTabs.has(tab.id))
// Positioned plugin tabs splice in ascending order so two positions stay stable;
// the rest append, exactly as before this capability existed.
@@ -237,12 +242,14 @@ export function useTripPlanner() {
// Same for the addon-owned tabs: until the feed answers, enabledAddons is a
// guess, and evicting on a guess would drop a legitimately requested tab.
if (!addonsLoaded) return
// And for the Journal tab, until the link has been asked for.
if (activeTab === 'journal' && !journal.settled) return
const validTabIds = TRIP_TABS.map(t => t.id)
if (!validTabIds.includes(activeTab)) {
setActiveTab('plan')
sessionStorage.setItem(`trip-tab-${tripId}`, 'plan')
}
}, [activeTab, enabledAddons, addonsLoaded, tripPluginIds, pluginsLoaded, isMobile])
}, [activeTab, enabledAddons, addonsLoaded, tripPluginIds, pluginsLoaded, isMobile, journal.settled, journal.openJournalId])
const handleTabChange = (rawTabId: string): void => {
// A core tab a plugin replaced is gone from the bar, but a programmatic jump
@@ -653,7 +660,7 @@ export function useTripPlanner() {
trip, days, places, assignments, storedAssignments, packingItems, todoItems, categories, reservations, budgetItems, files,
selectedDayId, isLoading, tripActions, can, canUploadFiles,
pushUndo, undo, canUndo, lastActionLabel, handleUndo,
enabledAddons, collabFeatures, tripAccommodations, setTripAccommodations,
enabledAddons, collabFeatures, journalId: journal.openJournalId, tripAccommodations, setTripAccommodations,
roadtripMode, toggleRoadtripMode, roadtripActive, roadtripFeedActive, roadtripRoutes, roadtripLineColors, roadtripMapLines, roadtripMapPlaces, collapsedRoadtripDays, toggleRoadtripDay, roadtripCorridor,
overviewShown, toggleOverview, overviewActive, tripOverview,
dawarichTrailShown, toggleDawarichTrail, dawarichTrail, dawarichHiddenDates, dawarichEnabled: !!enabledAddons.dawarich,
+106
View File
@@ -0,0 +1,106 @@
/**
* FE-TJL-REPO-001 to FE-TJL-REPO-006
*
* `tripJournalLinkRepo` is online-only on purpose (who a journal is open to is
* the server's answer, never a cached guess), so what is pinned is that it
* refuses before a request while offline, that every call reaches its route
* with the body the contract names, and that the picker only offers journals
* the person owns.
*/
import type { TripJournalLinkResponse } from '@trek/shared';
import { http, HttpResponse } from 'msw';
import { afterEach, describe, expect, it } from 'vitest';
import { server } from '../../tests/helpers/msw/server';
import { _resetNetworkMode, setForcedOffline } from '../sync/networkMode';
import { tripJournalLinkRepo, TripJournalOfflineError } from './tripJournalLinkRepo';
const NONE: TripJournalLinkResponse = { link: null, can_link: true, can_unlink: false, can_edit_members: false };
afterEach(() => _resetNetworkMode());
describe('tripJournalLinkRepo', () => {
it('FE-TJL-REPO-001: refuses every call while offline, before a request leaves', async () => {
let calls = 0;
server.use(
http.get('/api/trips/:tripId/journal', () => {
calls += 1;
return HttpResponse.json(NONE);
})
);
setForcedOffline(true);
await expect(tripJournalLinkRepo.get(4)).rejects.toBeInstanceOf(TripJournalOfflineError);
await expect(tripJournalLinkRepo.candidates(4, 1)).rejects.toBeInstanceOf(TripJournalOfflineError);
await expect(tripJournalLinkRepo.link(4, 1, [])).rejects.toBeInstanceOf(TripJournalOfflineError);
await expect(tripJournalLinkRepo.setMembers(4, [])).rejects.toBeInstanceOf(TripJournalOfflineError);
await expect(tripJournalLinkRepo.unlink(4)).rejects.toBeInstanceOf(TripJournalOfflineError);
await expect(tripJournalLinkRepo.ownJournals(1)).rejects.toBeInstanceOf(TripJournalOfflineError);
expect(calls).toBe(0);
});
it('FE-TJL-REPO-002: reads the trip link', async () => {
server.use(http.get('/api/trips/4/journal', () => HttpResponse.json(NONE)));
expect(await tripJournalLinkRepo.get(4)).toEqual(NONE);
});
it('FE-TJL-REPO-003: asks for the candidates of one journal and hands back the list', async () => {
let asked: string | null = null;
server.use(
http.get('/api/trips/4/journal/candidates', ({ request }) => {
asked = new URL(request.url).searchParams.get('journey_id');
return HttpResponse.json({
candidates: [{ user_id: 2, username: 'lena', avatar_url: null, is_trip_owner: false, access: null }],
});
})
);
const list = await tripJournalLinkRepo.candidates(4, 9);
expect(asked).toBe('9');
expect(list.map((c) => c.username)).toEqual(['lena']);
});
it('FE-TJL-REPO-004: links, sets members and unlinks with the bodies the contract names', async () => {
const bodies: unknown[] = [];
server.use(
http.put('/api/trips/4/journal', async ({ request }) => {
bodies.push(await request.json());
return HttpResponse.json(NONE);
}),
http.patch('/api/trips/4/journal/members', async ({ request }) => {
bodies.push(await request.json());
return HttpResponse.json(NONE);
}),
http.delete('/api/trips/4/journal', () => HttpResponse.json(NONE))
);
await tripJournalLinkRepo.link(4, 9, [{ user_id: 2, role: 'viewer' }]);
await tripJournalLinkRepo.setMembers(4, [{ user_id: 2, role: 'editor' }]);
expect(await tripJournalLinkRepo.unlink(4)).toEqual(NONE);
expect(bodies).toEqual([
{ journey_id: 9, members: [{ user_id: 2, role: 'viewer' }] },
{ members: [{ user_id: 2, role: 'editor' }] },
]);
});
it('FE-TJL-REPO-005: offers only the journals the person owns', async () => {
server.use(
http.get('/api/journeys', () =>
HttpResponse.json({
journeys: [
{ id: 1, user_id: 7, title: 'Mine', subtitle: 'Japan' },
{ id: 2, user_id: 8, title: 'Theirs' },
{ id: 3, user_id: 7, title: 'Also mine' },
],
})
)
);
expect(await tripJournalLinkRepo.ownJournals(7)).toEqual([
{ id: 1, title: 'Mine', subtitle: 'Japan' },
{ id: 3, title: 'Also mine', subtitle: null },
]);
});
it('FE-TJL-REPO-006: the offline error says what it needs', () => {
const err = new TripJournalOfflineError();
expect(err.name).toBe('TripJournalOfflineError');
expect(err.message).toMatch(/connection/);
});
});
+70
View File
@@ -0,0 +1,70 @@
import type { TripJournalCandidate, TripJournalLinkMemberRequest, TripJournalLinkResponse } from '@trek/shared';
import { tripJournalApi } from '../api/tripJournal';
import { isEffectivelyOffline } from '../sync/networkMode';
/** A journal the person owns, as the link picker lists it. */
export interface OwnJournal {
id: number;
title: string;
subtitle: string | null;
}
/**
* The journal a trip links, and who it is open to.
*
* Online-only with no Dexie table, for the reason docSyncRepo gives: every
* answer is the server's (who may see the link, who would be let in), and the
* journal it opens is online-only too, as the Journey addon is. Writing it
* offline would mean granting access nobody could check. The store in front of
* this keeps the last answer for the session.
*/
export class TripJournalOfflineError extends Error {
constructor() {
super('Linking a journal needs a connection');
this.name = 'TripJournalOfflineError';
}
}
function requireOnline(): void {
if (isEffectivelyOffline()) throw new TripJournalOfflineError();
}
export const tripJournalLinkRepo = {
async get(tripId: number): Promise<TripJournalLinkResponse> {
requireOnline();
return tripJournalApi.get(tripId);
},
async candidates(tripId: number, journeyId: number): Promise<TripJournalCandidate[]> {
requireOnline();
return (await tripJournalApi.candidates(tripId, journeyId)).candidates;
},
async link(
tripId: number,
journeyId: number,
members: TripJournalLinkMemberRequest[]
): Promise<TripJournalLinkResponse> {
requireOnline();
return tripJournalApi.link(tripId, journeyId, members);
},
async setMembers(tripId: number, members: TripJournalLinkMemberRequest[]): Promise<TripJournalLinkResponse> {
requireOnline();
return tripJournalApi.setMembers(tripId, members);
},
async unlink(tripId: number): Promise<TripJournalLinkResponse> {
requireOnline();
return tripJournalApi.unlink(tripId);
},
/** The journals `userId` owns, newest first, for the picker. Those they only contribute to are not theirs to link. */
async ownJournals(userId: number): Promise<OwnJournal[]> {
requireOnline();
const { journeys } = await tripJournalApi.journals();
return journeys
.filter((j) => j.user_id === userId)
.map((j) => ({ id: j.id, title: j.title, subtitle: j.subtitle ?? null }));
},
};
+12
View File
@@ -141,11 +141,23 @@ export interface JourneyContributor {
avatar?: string | null
}
/** Someone a trip's linked journal lets in, with the trip they come through (#2683). */
export interface JourneyTripAccess {
user_id: number
role: 'editor' | 'viewer'
username: string
avatar?: string | null
trip_id: number
trip_title: string
}
export interface JourneyDetail extends Journey {
entries: JourneyEntry[]
gallery: GalleryPhoto[]
trips: JourneyTrip[]
contributors: JourneyContributor[]
/** Who the trips' journal links let in besides the contributors, managed in those trips. */
trip_access?: JourneyTripAccess[]
stats: { entries: number; photos: number; places: number }
hide_skeletons?: boolean
/** How many suggestions were waved away one at a time, so the settings sheet can offer them back. */
@@ -0,0 +1,81 @@
/**
* FE-TJL-STORE-001 to FE-TJL-STORE-005
*
* The store keeps the server's answer per trip so the Journal tab and the
* Settings dialog read the same one: a write replaces it with what the server
* answered, a 404/403 means there is no journal for this person, and any other
* failure leaves the last answer standing rather than flashing the tab away.
*/
import type { TripJournalLinkResponse } from '@trek/shared';
import { http, HttpResponse } from 'msw';
import { beforeEach, describe, expect, it } from 'vitest';
import { server } from '../../tests/helpers/msw/server';
import { useTripJournalLinkStore } from './tripJournalLinkStore';
const LINKED: TripJournalLinkResponse = {
link: {
journey_id: 9,
title: 'Japan',
subtitle: null,
cover_image: null,
owner_id: 1,
linked_by: 1,
created_at: 1,
my_role: 'owner',
members: [],
},
can_link: true,
can_unlink: true,
can_edit_members: true,
};
const EMPTY: TripJournalLinkResponse = { link: null, can_link: true, can_unlink: false, can_edit_members: false };
beforeEach(() => useTripJournalLinkStore.setState({ byTrip: {} }));
describe('tripJournalLinkStore', () => {
it('FE-TJL-STORE-001: refresh keeps the answer under the trip', async () => {
server.use(http.get('/api/trips/4/journal', () => HttpResponse.json(LINKED)));
await useTripJournalLinkStore.getState().refresh(4);
expect(useTripJournalLinkStore.getState().byTrip['4']).toEqual(LINKED);
});
it('FE-TJL-STORE-002: a 404 (addon off, trip out of reach) is no journal at all', async () => {
server.use(http.get('/api/trips/4/journal', () => HttpResponse.json({ error: 'gone' }, { status: 404 })));
await useTripJournalLinkStore.getState().refresh(4);
expect(useTripJournalLinkStore.getState().byTrip['4']).toEqual({
link: null,
can_link: false,
can_unlink: false,
can_edit_members: false,
});
});
it('FE-TJL-STORE-003: any other failure leaves the last answer standing', async () => {
useTripJournalLinkStore.setState({ byTrip: { 4: LINKED } });
server.use(http.get('/api/trips/4/journal', () => HttpResponse.json({ error: 'boom' }, { status: 500 })));
await useTripJournalLinkStore.getState().refresh(4);
expect(useTripJournalLinkStore.getState().byTrip['4']).toEqual(LINKED);
});
it('FE-TJL-STORE-004: link, setMembers and unlink each keep what the server answered', async () => {
server.use(
http.put('/api/trips/4/journal', () => HttpResponse.json(LINKED)),
http.patch('/api/trips/4/journal/members', () => HttpResponse.json({ ...LINKED, can_unlink: false })),
http.delete('/api/trips/4/journal', () => HttpResponse.json(EMPTY))
);
const s = useTripJournalLinkStore.getState();
await s.link(4, 9, []);
expect(useTripJournalLinkStore.getState().byTrip['4']).toEqual(LINKED);
await s.setMembers(4, []);
expect(useTripJournalLinkStore.getState().byTrip['4']?.can_unlink).toBe(false);
await s.unlink(4);
expect(useTripJournalLinkStore.getState().byTrip['4']).toEqual(EMPTY);
});
it('FE-TJL-STORE-005: a failed write rejects for its caller and keeps the last answer', async () => {
useTripJournalLinkStore.setState({ byTrip: { 4: EMPTY } });
server.use(http.put('/api/trips/4/journal', () => HttpResponse.json({ error: 'no' }, { status: 403 })));
await expect(useTripJournalLinkStore.getState().link(4, 9, [])).rejects.toBeTruthy();
expect(useTripJournalLinkStore.getState().byTrip['4']).toEqual(EMPTY);
});
});
+49
View File
@@ -0,0 +1,49 @@
import type { TripJournalLinkMemberRequest, TripJournalLinkResponse } from '@trek/shared';
import { create } from 'zustand';
import { tripJournalLinkRepo } from '../repo/tripJournalLinkRepo';
/**
* The journal each trip links, as this person may see it, remembered for the
* session so the trip's Journal tab and its Settings dialog render the last
* answer at once. Both read it from here, so the tab appears and goes the moment
* the dialog links or unlinks, without a second request.
*/
interface TripJournalLinkState {
/** Per trip id, the server's answer. Missing until asked. */
byTrip: Record<string, TripJournalLinkResponse>;
refresh: (tripId: number) => Promise<void>;
link: (tripId: number, journeyId: number, members: TripJournalLinkMemberRequest[]) => Promise<void>;
setMembers: (tripId: number, members: TripJournalLinkMemberRequest[]) => Promise<void>;
unlink: (tripId: number) => Promise<void>;
}
/** The answer for a trip someone cannot reach, or with the Journey addon off: no journal. */
const NONE: TripJournalLinkResponse = { link: null, can_link: false, can_unlink: false, can_edit_members: false };
export const useTripJournalLinkStore = create<TripJournalLinkState>((set) => {
const keep = (tripId: number, answer: TripJournalLinkResponse) =>
set((s) => ({ byTrip: { ...s.byTrip, [String(tripId)]: answer } }));
return {
byTrip: {},
refresh: async (tripId) => {
try {
keep(tripId, await tripJournalLinkRepo.get(tripId));
} catch (err) {
// 404 is the Journey addon switched off or a trip this person no longer
// reaches: both a real "no journal". Anything else, offline included, is
// no answer at all, and the last one stands.
const status = (err as { response?: { status?: number } })?.response?.status;
if (status === 404 || status === 403) keep(tripId, NONE);
}
},
// The writes answer with the link as it now stands, so the tab and the
// dialog move together; a failure is the caller's to report.
link: async (tripId, journeyId, members) =>
keep(tripId, await tripJournalLinkRepo.link(tripId, journeyId, members)),
setMembers: async (tripId, members) => keep(tripId, await tripJournalLinkRepo.setMembers(tripId, members)),
unlink: async (tripId) => keep(tripId, await tripJournalLinkRepo.unlink(tripId)),
};
});
+1
View File
@@ -59,4 +59,5 @@ export const UNRESET_STORES: Record<string, string> = {
useSystemNoticeStore: NOT_YET,
useTourFerrataCrossingStore: 'set by the tour map stage; the tests that read it reset it in their own beforeEach',
useTourPlaceToolStore: 'reset in the tour tests themselves to initialTourPlaceToolState()',
useTripJournalLinkStore: 'the journal link per trip; the tests that read it reset it in their own beforeEach',
};
@@ -89,6 +89,17 @@ describe('MJourneySettingsSheet', () => {
expect(screen.getByText('8 places')).toBeInTheDocument();
});
it('FE-MOB-JSET-2683: lists the people a linked trip lets in, and nothing without', async () => {
const { unmount } = setup();
expect(screen.queryByText('Through trips')).not.toBeInTheDocument();
unmount();
setup({ trip_access: [{ user_id: 7, role: 'editor', username: 'lena', avatar: null, trip_id: 3, trip_title: 'Tokyo trip' }] });
expect(await screen.findByText('Through trips')).toBeInTheDocument();
expect(screen.getByText('lena')).toBeInTheDocument();
expect(screen.getByText('via Tokyo trip')).toBeInTheDocument();
});
it('FE-MOB-JSET-002: shows the empty hint when no trip is linked', async () => {
setup({ trips: [] });
expect(await screen.findByText('No trips linked')).toBeInTheDocument();
@@ -104,7 +104,7 @@ describe('MMehrSheet', () => {
it('FE-MOB-MEHR-009: the action rows open the share, export and edit sheets', () => {
const { shell } = renderSheet()
fireEvent.click(screen.getByRole('button', { name: 'Share Trip' }))
fireEvent.click(screen.getByRole('button', { name: 'Trip settings' }))
fireEvent.click(screen.getByRole('button', { name: 'Export' }))
fireEvent.click(screen.getByRole('button', { name: 'Edit Trip' }))
expect(shell.openSheet).toHaveBeenNthCalledWith(1, 'members')
@@ -116,7 +116,7 @@ describe('MMehrSheet', () => {
const { planner } = renderSheet({ can: vi.fn(() => false) })
expect(planner.can).toHaveBeenCalledWith('trip_edit', planner.trip)
expect(screen.queryByRole('button', { name: 'Edit Trip' })).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Share Trip' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Trip settings' })).toBeInTheDocument()
})
it('FE-MOB-MEHR-011: renders only the action rows when every section sits in the dock', () => {
@@ -124,13 +124,13 @@ describe('MMehrSheet', () => {
TRIP_TABS: TABS.filter(tab => tab.id !== 'dateien' && tab.id !== 'collab') as TripPlanner['TRIP_TABS'],
})
expect(screen.queryByText(/files$/)).not.toBeInTheDocument()
const rows = screen.getByRole('button', { name: 'Share Trip' }).parentElement
const rows = screen.getByRole('button', { name: 'Trip settings' }).parentElement
expect(rows).not.toHaveClass('mt-2')
})
it('FE-MOB-MEHR-012: separates the action rows from the tile grid when both are present', () => {
renderSheet()
const rows = screen.getByRole('button', { name: 'Share Trip' }).parentElement
const rows = screen.getByRole('button', { name: 'Trip settings' }).parentElement
expect(rows).toHaveClass('mt-2')
})
@@ -172,7 +172,7 @@ describe('MMehrSheet', () => {
// Five sections still fit beside the More button, so nothing overflows and the
// sheet is the action rows alone.
expect(screen.queryByRole('button', { name: 'Lists' })).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Share Trip' }).parentElement).not.toHaveClass('mt-2')
expect(screen.getByRole('button', { name: 'Trip settings' }).parentElement).not.toHaveClass('mt-2')
})
})
})
@@ -1,7 +1,15 @@
import { readFileSync } from 'node:fs';
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import { areaCounts, check, inlineConfigsOf, lintClient, tally } from '../../../scripts/lib/eslint-warnings.mjs';
import {
areaCounts,
check,
inlineConfigsOf,
lintClient,
newWarningsOf,
tally,
} from '../../../scripts/lib/eslint-warnings.mjs';
import { RatchetError } from '../../../scripts/lib/ratchet.mjs';
import { ratchetTree, type RatchetTree } from '../../helpers/ratchetFixture';
@@ -183,4 +191,33 @@ describe('lint:warnings', () => {
expect(byKey['b.js'].messages.map((m) => m.ruleId)).toEqual(['no-empty']);
expect(byKey['b.js'].inlineConfigs).toEqual([]);
});
it('names the file and line behind a grown count, measured against the base, and nothing without one', async () => {
const root = mkdtempSync(join(tmpdir(), 'lint-new-'));
try {
mkdirSync(join(root, 'src'));
writeFileSync(join(root, 'src/new.ts'), 'two');
writeFileSync(join(root, 'src/old.ts'), 'one');
// new.ts held none of the rule on the base, old.ts already held its one
const git = (_root: string, args: string[]) =>
args[0] === 'diff' ? 'src/new.ts\nsrc/old.ts\n' : args[1]?.endsWith('old.ts') ? 'one' : 'none';
const counts: Record<string, number> = { two: 2, one: 1, none: 0 };
const lintText = async (text: string) => [
{
messages: Array.from({ length: counts[text] ?? 0 }, (_, i) => ({
ruleId: 'no-empty',
severity: 1,
message: `w${i}`,
line: i + 1,
})),
},
];
const keys = ['src/new.ts', 'src/old.ts', 'src/untouched.ts'];
const lines = await newWarningsOf({ root, rule: 'no-empty', area: 'src', keys, lintText, ref: 'base', git });
expect(lines).toEqual(['src/new.ts:1 w0', 'src/new.ts:2 w1']);
expect(await newWarningsOf({ root, rule: 'no-empty', area: 'src', keys, lintText, git: () => null })).toBeNull();
} finally {
rmSync(root, { recursive: true, force: true });
}
});
});
+79 -4
View File
@@ -6,6 +6,7 @@
* own major and has to lint with the one its eslint.config.mjs was written
* for. This file never imports eslint, so it resolves no version of its own.
*/
import { execFileSync } from 'node:child_process';
import { join } from 'node:path';
import ts from 'typescript';
import {
@@ -145,6 +146,77 @@ export function lintWith(ESLint) {
};
}
/** 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}`);
});
}
/** A one-file lint for newWarningsOf, with the ESLint the workspace imports (see lintWith). */
export function lintTextWith(ESLint) {
return (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);
/**
@@ -156,6 +228,8 @@ export async function check({
baselinePath = join(root, 'scripts/eslint-baseline.json'),
update = false,
lint,
/** lintTextWith(ESLint): names the files behind a grown count; left out, only the counts are told. */
lintText = null,
log = console.log,
error = console.error,
}) {
@@ -169,13 +243,13 @@ export async function check({
}
for (const line of errors) error(`FAIL ${line}`);
let grown = 0;
const grown = [];
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++;
grown.push({ area, rule });
const top = Object.entries(files[area][rule])
.sort(([, a], [, b]) => b - a)
.slice(0, 5)
@@ -191,7 +265,8 @@ export async function check({
});
stale += fallen.length;
}
if (grown) {
await reportNew(grown, { root, files, lintText: lintText && lintText(root), error });
if (grown.length) {
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.'
@@ -200,5 +275,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 || stale ? 1 : 0;
return errors.length || grown.length || stale ? 1 : 0;
}
+23 -2
View File
@@ -1,9 +1,10 @@
import { ESLint } from 'eslint';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, it } from 'node:test';
import { areaCounts, check, inlineConfigsOf, lintWith, tally } from './eslint-warnings.mjs';
import { areaCounts, check, inlineConfigsOf, lintWith, newWarningsOf, tally } from './eslint-warnings.mjs';
import { RatchetError } from './ratchet.mjs';
import { ratchetTree } from './tree-fixture.mjs';
@@ -92,6 +93,26 @@ describe('scripts/lib/eslint-warnings', () => {
assert.match(out, /Fix the new warning rather than silencing it/);
});
it('names the file and line behind a grown count, measured against the base, and nothing without one', async () => {
const root = mkdtempSync(join(tmpdir(), 'lint-new-'));
try {
mkdirSync(join(root, 'src'));
writeFileSync(join(root, 'src/new.ts'), 'two');
writeFileSync(join(root, 'src/old.ts'), 'one');
// new.ts held none of the rule on the base, old.ts already held its one
const git = (_root, args) => (args[0] === 'diff' ? 'src/new.ts\nsrc/old.ts\n' : args[1].endsWith('old.ts') ? 'one' : 'none');
const lintText = async (text) => [
{ messages: Array.from({ length: { two: 2, one: 1, none: 0 }[text] }, (_, i) => ({ ...warn('no-empty', `w${i}`), line: i + 1 })) },
];
const keys = ['src/new.ts', 'src/old.ts', 'src/untouched.ts'];
const lines = await newWarningsOf({ root, rule: 'no-empty', area: 'src', keys, lintText, ref: 'base', git });
assert.deepEqual(lines, ['src/new.ts:1 w0', 'src/new.ts:2 w1']);
assert.equal(await newWarningsOf({ root, rule: 'no-empty', area: 'src', keys, lintText, git: () => null }), null);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
it('counts a message an eslint-disable comment silenced', async () => {
assert.equal(await run(empty, (root) => [result(root, 'src/a.ts', [], [warn('no-console')])]), 1);
assert.match(tree.error.join('\n'), /suppressed: 1 no-console warning/);
+4 -2
View File
@@ -23,11 +23,13 @@
* ESLint comes from this workspace, so the server lints with the major its
* config was written for.
*/
import { check, lintWith } from '../../scripts/lib/eslint-warnings.mjs';
import { check, lintTextWith, lintWith } from '../../scripts/lib/eslint-warnings.mjs';
import { runCli } from '../../scripts/lib/ratchet.mjs';
import { ESLint } from 'eslint';
import { fileURLToPath } from 'node:url';
const root = fileURLToPath(new URL('..', import.meta.url));
await runCli('lint:warnings', (args) => check({ root, update: args.includes('--update'), lint: lintWith(ESLint) }));
await runCli('lint:warnings', (args) =>
check({ root, update: args.includes('--update'), lint: lintWith(ESLint), lintText: lintTextWith(ESLint) }),
);
-1
View File
@@ -15,7 +15,6 @@
"src/db/repositories/Days.repository.ts": 7,
"src/db/repositories/InviteTokens.repository.ts": 3,
"src/db/repositories/JourneyBooks.repository.ts": 3,
"src/db/repositories/JourneyContributors.repository.ts": 3,
"src/db/repositories/JourneyEntries.repository.ts": 9,
"src/db/repositories/JourneyPhotos.repository.ts": 6,
"src/db/repositories/Journeys.repository.ts": 4,
+2
View File
@@ -117,6 +117,8 @@
"TripDocumentLinks": "doc-sync",
"TripFiles": "files",
"TripInviteTokens": "trip-invite",
"TripJournalLinkMembers": "journey",
"TripJournalLinks": "journey",
"TripMembers": "trip-membership",
"TripPhotos": "photos",
"Trips": "trips",
@@ -6,6 +6,7 @@ import { JourneyEntries } from './JourneyEntries.entity';
import { JourneyPhotos } from './JourneyPhotos.entity';
import { JourneyShareTokens } from './JourneyShareTokens.entity';
import { JourneyTrips } from './JourneyTrips.entity';
import { TripJournalLinks } from './TripJournalLinks.entity';
import { Users } from './Users.entity';
export class Journeys {
@@ -32,6 +33,7 @@ export class Journeys {
journey_photos_collection = new Collection<JourneyPhotos>(this);
journey_share_tokens: Ref<JourneyShareTokens> | null = null;
journey_trips_collection = new Collection<JourneyTrips>(this);
trip_journal_links_collection = new Collection<TripJournalLinks>(this);
}
export const JourneysSchema = defineEntity({
@@ -60,5 +62,6 @@ export const JourneysSchema = defineEntity({
journey_photos_collection: () => p.oneToMany(JourneyPhotos).mappedBy('journey').hidden(),
journey_share_tokens: () => p.oneToOne(JourneyShareTokens).ref().mappedBy('journey').hidden(),
journey_trips_collection: () => p.oneToMany(JourneyTrips).mappedBy('journey').hidden(),
trip_journal_links_collection: () => p.oneToMany(TripJournalLinks).mappedBy('journey').hidden(),
},
});
@@ -0,0 +1,38 @@
import { EntityRepositoryType, type Opt, PrimaryKeyProp, type Ref, defineEntity, p } from '@mikro-orm/core';
import { TripJournalLinkMembersRepository } from '../repositories/TripJournalLinkMembers.repository';
import { TripJournalLinks } from './TripJournalLinks.entity';
import { Users } from './Users.entity';
export class TripJournalLinkMembers {
[EntityRepositoryType]?: TripJournalLinkMembersRepository;
[PrimaryKeyProp]?: ['link', 'user'];
link!: Ref<TripJournalLinks>;
link_id!: number;
user!: Ref<Users>;
user_id!: number;
role: TTripJournalLinkMembersRole & Opt = TripJournalLinkMembersRole.VIEWER;
hide_skeletons: number & Opt = 0;
added_at!: number;
}
export const TripJournalLinkMembersRole = {
VIEWER: 'viewer',
EDITOR: 'editor',
} as const;
export type TTripJournalLinkMembersRole = (typeof TripJournalLinkMembersRole)[keyof typeof TripJournalLinkMembersRole];
export const TripJournalLinkMembersSchema = defineEntity({
class: TripJournalLinkMembers,
repository: () => TripJournalLinkMembersRepository,
uniques: [{ properties: ['link', 'user'] }],
properties: {
link: () => p.manyToOne(TripJournalLinks).primary().ref().hidden(),
link_id: p.integer().persist(false),
user: () => p.manyToOne(Users).primary().ref().hidden().index('idx_trip_journal_link_members_user'),
user_id: p.integer().persist(false).index('idx_trip_journal_link_members_user'),
role: p.enum(() => TripJournalLinkMembersRole).default('viewer'),
hide_skeletons: p.integer().default(0),
added_at: p.integer(),
},
});
@@ -0,0 +1,36 @@
import { Collection, EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core';
import { TripJournalLinksRepository } from '../repositories/TripJournalLinks.repository';
import { Journeys } from './Journeys.entity';
import { TripJournalLinkMembers } from './TripJournalLinkMembers.entity';
import { Trips } from './Trips.entity';
import { Users } from './Users.entity';
export class TripJournalLinks {
[EntityRepositoryType]?: TripJournalLinksRepository;
id!: number & Opt;
trip!: Ref<Trips>;
trip_id!: number;
journey!: Ref<Journeys>;
journey_id!: number;
linked_by?: number | null;
created_at!: number;
linkedByRef?: Ref<Users> | null;
trip_journal_link_members_collection = new Collection<TripJournalLinkMembers>(this);
}
export const TripJournalLinksSchema = defineEntity({
class: TripJournalLinks,
repository: () => TripJournalLinksRepository,
uniques: [{ properties: ['trip'] }],
properties: {
id: p.integer().primary(),
trip: () => p.manyToOne(Trips).ref().deleteRule('cascade').hidden(),
trip_id: p.integer().persist(false),
journey: () => p.manyToOne(Journeys).ref().deleteRule('cascade').hidden().index('idx_trip_journal_links_journey'),
journey_id: p.integer().persist(false).index('idx_trip_journal_links_journey'),
linked_by: p.integer().nullable().persist(false).index('idx_trip_journal_links_linked_by'),
created_at: p.integer(),
linkedByRef: () => p.manyToOne(Users).ref().joinColumn('linked_by').nullable().hidden().index('idx_trip_journal_links_linked_by'),
trip_journal_link_members_collection: () => p.oneToMany(TripJournalLinkMembers).mappedBy('link').hidden(),
},
});
+3
View File
@@ -32,6 +32,7 @@ import { TripAlbumLinks } from './TripAlbumLinks.entity';
import { TripDocumentLinks } from './TripDocumentLinks.entity';
import { TripFiles } from './TripFiles.entity';
import { TripInviteTokens } from './TripInviteTokens.entity';
import { TripJournalLinks } from './TripJournalLinks.entity';
import { TripMembers } from './TripMembers.entity';
import { TripPhotos } from './TripPhotos.entity';
import { Users } from './Users.entity';
@@ -84,6 +85,7 @@ export class Trips {
trip_document_links_collection = new Collection<TripDocumentLinks>(this);
trip_files_collection = new Collection<TripFiles>(this);
trip_invite_tokens_collection = new Collection<TripInviteTokens>(this);
trip_journal_links_collection = new Collection<TripJournalLinks>(this);
trip_members_collection = new Collection<TripMembers>(this);
trip_photos_collection = new Collection<TripPhotos>(this);
}
@@ -145,6 +147,7 @@ export const TripsSchema = defineEntity({
trip_document_links_collection: () => p.oneToMany(TripDocumentLinks).mappedBy('trip').hidden(),
trip_files_collection: () => p.oneToMany(TripFiles).mappedBy('trip').hidden(),
trip_invite_tokens_collection: () => p.oneToMany(TripInviteTokens).mappedBy('trip').hidden(),
trip_journal_links_collection: () => p.oneToMany(TripJournalLinks).mappedBy('trip').hidden(),
trip_members_collection: () => p.oneToMany(TripMembers).mappedBy('trip').hidden(),
trip_photos_collection: () => p.oneToMany(TripPhotos).mappedBy('trip').hidden(),
},
+6
View File
@@ -57,6 +57,8 @@ import { TripAlbumLinks } from './TripAlbumLinks.entity';
import { TripDocumentLinks } from './TripDocumentLinks.entity';
import { TripFiles } from './TripFiles.entity';
import { TripInviteTokens } from './TripInviteTokens.entity';
import { TripJournalLinkMembers } from './TripJournalLinkMembers.entity';
import { TripJournalLinks } from './TripJournalLinks.entity';
import { TripMembers } from './TripMembers.entity';
import { TripPhotos } from './TripPhotos.entity';
import { Trips } from './Trips.entity';
@@ -182,6 +184,8 @@ export class Users {
trip_document_links_collection = new Collection<TripDocumentLinks>(this);
trip_files_collection = new Collection<TripFiles>(this);
trip_invite_tokens_collection = new Collection<TripInviteTokens>(this);
trip_journal_link_members_collection = new Collection<TripJournalLinkMembers>(this);
trip_journal_links_collection = new Collection<TripJournalLinks>(this);
trip_members_collection = new Collection<TripMembers>(this);
trip_members_collection1 = new Collection<TripMembers>(this);
trip_photos_collection = new Collection<TripPhotos>(this);
@@ -325,6 +329,8 @@ export const UsersSchema = defineEntity({
trip_document_links_collection: () => p.oneToMany(TripDocumentLinks).mappedBy('createdByRef').hidden(),
trip_files_collection: () => p.oneToMany(TripFiles).mappedBy('uploadedByRef').hidden(),
trip_invite_tokens_collection: () => p.oneToMany(TripInviteTokens).mappedBy('createdByRef').hidden(),
trip_journal_link_members_collection: () => p.oneToMany(TripJournalLinkMembers).mappedBy('user').hidden(),
trip_journal_links_collection: () => p.oneToMany(TripJournalLinks).mappedBy('linkedByRef').hidden(),
trip_members_collection: () => p.oneToMany(TripMembers).mappedBy('user').hidden(),
trip_members_collection1: () => p.oneToMany(TripMembers).mappedBy('invitedByRef').hidden(),
trip_photos_collection: () => p.oneToMany(TripPhotos).mappedBy('user').hidden(),
+4
View File
@@ -116,6 +116,8 @@ import { TripAlbumLinksSchema } from './TripAlbumLinks.entity';
import { TripDocumentLinksSchema } from './TripDocumentLinks.entity';
import { TripFilesSchema } from './TripFiles.entity';
import { TripInviteTokensSchema } from './TripInviteTokens.entity';
import { TripJournalLinkMembersSchema } from './TripJournalLinkMembers.entity';
import { TripJournalLinksSchema } from './TripJournalLinks.entity';
import { TripMembersSchema } from './TripMembers.entity';
import { TripPhotosSchema } from './TripPhotos.entity';
import { TripsSchema } from './Trips.entity';
@@ -256,6 +258,8 @@ export const ALL_ENTITIES: readonly EntitySchema[] = [
TripDocumentLinksSchema,
TripFilesSchema,
TripInviteTokensSchema,
TripJournalLinkMembersSchema,
TripJournalLinksSchema,
TripMembersSchema,
TripPhotosSchema,
TripsSchema,
@@ -0,0 +1,11 @@
// Generated by scripts/generate-db-types.ts from the migrated schema. Do not edit by hand:
// change the migration, then run `npm run gen:db-types`.
import type { InsertOptional } from './columns';
export interface TripJournalLinkMembersTable {
link_id: number;
user_id: number;
role: InsertOptional<string>;
hide_skeletons: InsertOptional<number>;
added_at: number;
}
@@ -0,0 +1,11 @@
// Generated by scripts/generate-db-types.ts from the migrated schema. Do not edit by hand:
// change the migration, then run `npm run gen:db-types`.
import type { InsertOptional } from './columns';
export interface TripJournalLinksTable {
id: InsertOptional<number>;
trip_id: number;
journey_id: number;
linked_by: InsertOptional<number | null>;
created_at: number;
}
+4
View File
@@ -122,6 +122,8 @@ import type { TripAlbumLinksTable } from './TripAlbumLinks.table';
import type { TripDocumentLinksTable } from './TripDocumentLinks.table';
import type { TripFilesTable } from './TripFiles.table';
import type { TripInviteTokensTable } from './TripInviteTokens.table';
import type { TripJournalLinkMembersTable } from './TripJournalLinkMembers.table';
import type { TripJournalLinksTable } from './TripJournalLinks.table';
import type { TripMembersTable } from './TripMembers.table';
import type { TripPhotosTable } from './TripPhotos.table';
import type { TripsTable } from './Trips.table';
@@ -268,6 +270,8 @@ export interface DB {
trip_document_links: TripDocumentLinksTable;
trip_files: TripFilesTable;
trip_invite_tokens: TripInviteTokensTable;
trip_journal_link_members: TripJournalLinkMembersTable;
trip_journal_links: TripJournalLinksTable;
trip_members: TripMembersTable;
trip_photos: TripPhotosTable;
trips: TripsTable;
@@ -0,0 +1,49 @@
import { Migration } from '@mikro-orm/migrations';
/**
* A trip can show one journal from the Journey addon as its own. The link
* belongs to the trip, not to the journal's own trips (`journey_trips`): any
* journal its owner chooses, and one journal may hang off several trips.
* `linked_by` names who linked it, kept when that account goes as unknown.
*
* `trip_journal_link_members` lists the trip members the link lets into the
* journal, as viewer or editor, apart from `journey_contributors` so that
* unlinking, deselecting someone or their leaving the trip takes away exactly
* the access the link gave and nothing the journal's owner granted directly.
* `hide_skeletons` is that member's own journal preference, which for a direct
* contributor sits on the contributor row. A member's rows go with the link,
* with the trip and with the account.
*
* One link per trip is `trip_id UNIQUE` beside its own id rather than the trip
* id as key, as `trip_invite_tokens` holds one per trip: a key that is also a
* foreign key maps to a one-to-one on both entities, and a second one of those
* next to `tours` made the ORM's entity types recurse past what tsc expands.
*/
export class Migration20200101044300_a_trip_links_a_journal extends Migration {
override name = 'Migration20200101044300_a_trip_links_a_journal';
override up(): void {
this.addSql(`
CREATE TABLE IF NOT EXISTS trip_journal_links (
id INTEGER PRIMARY KEY,
trip_id INTEGER NOT NULL UNIQUE REFERENCES trips(id) ON DELETE CASCADE,
journey_id INTEGER NOT NULL REFERENCES journeys(id) ON DELETE CASCADE,
linked_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
created_at INTEGER NOT NULL
)
`);
this.addSql('CREATE INDEX IF NOT EXISTS idx_trip_journal_links_journey ON trip_journal_links(journey_id)');
this.addSql('CREATE INDEX IF NOT EXISTS idx_trip_journal_links_linked_by ON trip_journal_links(linked_by)');
this.addSql(`
CREATE TABLE IF NOT EXISTS trip_journal_link_members (
link_id INTEGER NOT NULL REFERENCES trip_journal_links(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role TEXT NOT NULL DEFAULT 'viewer' CHECK (role IN ('viewer', 'editor')),
hide_skeletons INTEGER NOT NULL DEFAULT 0,
added_at INTEGER NOT NULL,
PRIMARY KEY (link_id, user_id)
)
`);
this.addSql('CREATE INDEX IF NOT EXISTS idx_trip_journal_link_members_user ON trip_journal_link_members(user_id)');
}
}
@@ -1,5 +1,12 @@
import type { JourneyContributors } from '../entities/JourneyContributors.entity';
import type { DB } from '../kysely/db';
import {
linkReach,
readLinkHideSkeletons,
readLinkRole,
type TripJournalLinkKyselyDB,
type TripJournalLinkRole,
} from './TripJournalLinks.repository';
import { TrekRepository } from './_shared/trek-repository';
/** JG18's contributor-list row (`getJourneyFull`) — the junction plus the joined username/avatar. */
@@ -15,6 +22,19 @@ export interface JourneyContributorListRow {
/** The `journey_contributors`/`users` tables {@link JourneyContributorsRepository.listForJourney} reads. */
type JourneyContributorListKyselyDB = Pick<DB, 'journey_contributors' | 'users'>;
/** Someone a trip's journal link lets in, with the trip they come through (#2683). */
export interface JourneyTripAccessRow {
user_id: number;
role: TripJournalLinkRole;
username: string;
avatar: string | null;
trip_id: number;
trip_title: string;
}
/** The table the access reads below take a person's own contributor row from. */
type JourneyContributorRowKyselyDB = Pick<DB, 'journey_contributors'>;
/**
* `journey_contributors` — a genuine TWO-column composite primary key
* (`journey` + `user`, both `.primary()`, R3 — pinned against
@@ -26,36 +46,71 @@ type JourneyContributorListKyselyDB = Pick<DB, 'journey_contributors' | 'users'>
* pinned in `task-1-report.md`.
*/
export class JourneyContributorsRepository extends TrekRepository<JourneyContributors> {
/** JG4 — `canAccessJourney`'s contributor-match primitive: `SELECT 1 FROM journey_contributors WHERE journey_id = ? AND user_id = ?`. */
async existsForUser(journeyId: number, userId: number): Promise<boolean> {
const row = await this.qb('jc')
.select(['jc.journey'])
.where({ journey: journeyId, user: userId })
.execute<unknown>('get', false);
return !!row;
/** The person's own contributor row on the journal, if they have one. */
private async directRow(journeyId: number, userId: number) {
return await this.kysely<JourneyContributorRowKyselyDB>()
.selectFrom('journey_contributors')
.select(['role', 'hide_skeletons'])
.where('journey_id', '=', journeyId)
.where('user_id', '=', userId)
.executeTakeFirst();
}
/** JG7/JG21 — `canEdit`'s edit-access primitive and `getJourneyFull`'s `myRole` resolution: `SELECT role FROM journey_contributors WHERE journey_id = ? AND user_id = ?`. */
async findRole(journeyId: number, userId: number): Promise<string | undefined> {
const row = await this.qb('jc')
.select(['jc.role'])
.where({ journey: journeyId, user: userId })
.execute<{ role: string } | undefined>('get', false);
return row?.role;
/** JG4 — whether a person may open the journal: as a contributor, or through a trip's link. */
async hasAccess(journeyId: number, userId: number): Promise<boolean> {
return (await this.findAccessRole(journeyId, userId)) !== undefined;
}
/** JG20/JG27 — `getJourneyFull`'s `hide_skeletons` preference read and `updateJourneyPreferences`'s re-read, one statement text. */
/**
* JG7/JG21 — the role a person acts with on the journal, for `canEdit` and
* `my_role`: their contributor role, or the role a trip's link gives them
* when that is the stronger one. Undefined with neither.
*/
async findAccessRole(journeyId: number, userId: number): Promise<string | undefined> {
const direct = await this.directRow(journeyId, userId);
if (direct?.role === 'owner' || direct?.role === 'editor') return direct.role;
const linked = await readLinkRole(this.kysely<TripJournalLinkKyselyDB>(), journeyId, userId);
return linked ?? direct?.role;
}
/** JG20/JG27 — the person's "hide skeletons" choice, from their contributor row or else from their link. */
async getHideSkeletons(journeyId: number, userId: number): Promise<boolean> {
const row = await this.qb('jc')
.select(['jc.hide_skeletons'])
.where({ journey: journeyId, user: userId })
.execute<{ hide_skeletons: number } | undefined>('get', false);
return !!row?.hide_skeletons;
const direct = await this.directRow(journeyId, userId);
if (direct) return !!direct.hide_skeletons;
return !!(await readLinkHideSkeletons(this.kysely<TripJournalLinkKyselyDB>(), journeyId, userId));
}
/** JG26 — `updateJourneyPreferences`'s write: `UPDATE journey_contributors SET hide_skeletons = ? WHERE journey_id = ? AND user_id = ?`. */
/** JG26 — stores that choice where `getHideSkeletons` reads it: the contributor row, or else the person's links. */
async setHideSkeletons(journeyId: number, userId: number, value: number): Promise<void> {
await this.nativeUpdate({ journey: journeyId, user: userId }, { hide_skeletons: value });
if (await this.directRow(journeyId, userId)) {
await this.nativeUpdate({ journey: journeyId, user: userId }, { hide_skeletons: value });
return;
}
const db = this.kysely<TripJournalLinkKyselyDB>();
await db
.updateTable('trip_journal_link_members')
.set({ hide_skeletons: value })
.where('user_id', '=', userId)
.where('link_id', 'in', db.selectFrom('trip_journal_links').select('id').where('journey_id', '=', journeyId))
.execute();
}
/**
* #2683 — the people the trips' journal links let into this journal, one row
* per trip they come through, leaving out whoever is a contributor already:
* the settings show them apart, since they are managed in the trip.
*/
async listTripAccessForJourney(journeyId: number): Promise<JourneyTripAccessRow[]> {
const direct = new Set((await this.listForJourney(journeyId)).map((c) => c.user_id));
const rows = await linkReach(this.kysely<TripJournalLinkKyselyDB>())
.innerJoin('users as u', 'u.id', 'm.user_id')
.select(['m.user_id', 'm.role', 'u.username', 'u.avatar', 't.id as trip_id', 't.title as trip_title'])
.where('l.journey_id', '=', journeyId)
.orderBy('u.username', 'asc')
.execute();
return rows
.filter((r) => !direct.has(r.user_id))
.map((r) => ({ ...r, role: r.role === 'editor' ? 'editor' : 'viewer' }));
}
/** JG18 — `getJourneyFull`'s contributor list, joined to `users` for `username`/`avatar` (`avatar_url` is computed in the SERVICE, unchanged). */
@@ -1,5 +1,6 @@
import type { Journeys } from '../entities/Journeys.entity';
import type { DB } from '../kysely/db';
import { linkReach, type TripJournalLinkKyselyDB } from './TripJournalLinks.repository';
import { TrekRepository } from './_shared/trek-repository';
/**
@@ -114,6 +115,12 @@ export class JourneysRepository extends TrekRepository<Journeys> {
.where('journey_id', '=', id)
.execute();
const ids = new Set(contributors.map((c) => c.user_id));
// The trip members a trip's link lets in follow the journal live as well.
const linked = await linkReach(this.kysely<TripJournalLinkKyselyDB>())
.select('m.user_id')
.where('l.journey_id', '=', id)
.execute();
for (const l of linked) ids.add(l.user_id);
const ownerId = await this.findOwnerId(id);
if (ownerId !== undefined) ids.add(ownerId);
return [...ids];
@@ -169,7 +176,18 @@ export class JourneysRepository extends TrekRepository<Journeys> {
.whereRef('jt2.journey_id', '=', 'j.id')
.as('trip_date_max'),
])
.where((eb) => eb.or([eb('j.user_id', '=', user_id), eb('jc.user_id', '=', user_id)]))
// A journal a trip's link lets this person into is theirs to open here too.
.where((eb) =>
eb.or([
eb('j.user_id', '=', user_id),
eb('jc.user_id', '=', user_id),
eb(
'j.id',
'in',
linkReach(this.kysely<TripJournalLinkKyselyDB>()).select('l.journey_id').where('m.user_id', '=', user_id),
),
]),
)
.orderBy('j.updated_at', 'desc')
.execute();
return rows as JourneyListRow[];
@@ -0,0 +1,4 @@
import type { TripJournalLinkMembers } from '../entities/TripJournalLinkMembers.entity';
import { TrekRepository } from './_shared/trek-repository';
export class TripJournalLinkMembersRepository extends TrekRepository<TripJournalLinkMembers> {}
@@ -0,0 +1,216 @@
import {
TripJournalLinkMembersRole,
type TTripJournalLinkMembersRole,
} from '../entities/TripJournalLinkMembers.entity';
import type { TripJournalLinks } from '../entities/TripJournalLinks.entity';
import type { DB } from '../kysely/db';
import { TrekRepository } from './_shared/trek-repository';
import type { Kysely } from 'kysely';
/** The tables a link's reach is read from. */
export type TripJournalLinkKyselyDB = Pick<
DB,
'trip_journal_links' | 'trip_journal_link_members' | 'trips' | 'trip_members' | 'journeys' | 'users'
>;
/** The role a link gives, as the migration's CHECK and the generated entity spell it. */
export type TripJournalLinkRole = TTripJournalLinkMembersRole;
const { EDITOR, VIEWER } = TripJournalLinkMembersRole;
/** A trip's link with the journal it shows, as the settings dialog reads it. */
export interface TripJournalLinkRow {
trip_id: number;
journey_id: number;
linked_by: number | null;
created_at: number;
journey_title: string;
journey_subtitle: string | null;
journey_cover_image: string | null;
journey_owner_id: number;
}
/** Someone on the trip, as the member picker lists them. */
export interface TripPersonRow {
user_id: number;
username: string;
avatar: string | null;
is_guest: boolean;
is_trip_owner: boolean;
}
/** One person the link lets into its journal. */
export interface TripJournalLinkMemberRow {
user_id: number;
role: TripJournalLinkRole;
username: string;
avatar: string | null;
}
/**
* The link members who still belong to the linking trip, as its owner or on its
* roster, with the link they hold. Every read of link access starts here, so
* leaving a trip ends what its link gave in the same instant, whether or not
* anything cleaned up after it. A caller narrows it by journal or by person and
* picks its own columns.
*/
export function linkReach(db: Kysely<TripJournalLinkKyselyDB>) {
return db
.selectFrom('trip_journal_links as l')
.innerJoin('trip_journal_link_members as m', 'm.link_id', 'l.id')
.innerJoin('trips as t', 't.id', 'l.trip_id')
.where((eb) =>
eb.or([
eb('t.user_id', '=', eb.ref('m.user_id')),
eb.exists(
eb
.selectFrom('trip_members as tm')
.select('tm.id')
.whereRef('tm.trip_id', '=', 'l.trip_id')
.whereRef('tm.user_id', '=', 'm.user_id'),
),
]),
);
}
/** The stronger of two link roles; a person linked through two trips gets the better one. */
function strongest(roles: string[]): TripJournalLinkRole | null {
if (roles.includes(EDITOR)) return EDITOR;
return roles.length > 0 ? VIEWER : null;
}
/** The role a person holds on a journal through any trip's link, or null when none lets them in. */
export async function readLinkRole(
db: Kysely<TripJournalLinkKyselyDB>,
journeyId: number,
userId: number,
): Promise<TripJournalLinkRole | null> {
const rows = await linkReach(db)
.select('m.role')
.where('l.journey_id', '=', journeyId)
.where('m.user_id', '=', userId)
.execute();
return strongest(rows.map((r) => r.role));
}
/** A link member's own "hide skeletons" choice on the journal, or null when no link lets them in. */
export async function readLinkHideSkeletons(
db: Kysely<TripJournalLinkKyselyDB>,
journeyId: number,
userId: number,
): Promise<number | null> {
const row = await linkReach(db)
.select('m.hide_skeletons')
.where('l.journey_id', '=', journeyId)
.where('m.user_id', '=', userId)
.executeTakeFirst();
return row?.hide_skeletons ?? null;
}
/**
* `trip_journal_links` and its members: which journal a trip shows, and who
* of the trip may read or write it through that link. The access this table
* gives is read through {@link linkReach} everywhere, never copied into
* `journey_contributors`, so a direct contributor's role is never overwritten
* and undoing a link takes away exactly what it gave.
*/
export class TripJournalLinksRepository extends TrekRepository<TripJournalLinks> {
/** The trip's link with its journal's title and cover, or null without one. */
async findForTrip(tripId: number): Promise<TripJournalLinkRow | null> {
const row = await this.kysely<TripJournalLinkKyselyDB>()
.selectFrom('trip_journal_links as l')
.innerJoin('journeys as j', 'j.id', 'l.journey_id')
.select([
'l.trip_id',
'l.journey_id',
'l.linked_by',
'l.created_at',
'j.title as journey_title',
'j.subtitle as journey_subtitle',
'j.cover_image as journey_cover_image',
'j.user_id as journey_owner_id',
])
.where('l.trip_id', '=', tripId)
.executeTakeFirst();
return row ?? null;
}
/** The people the trip's link lets in, still on the trip or not, with their name for the list. */
async listMembers(tripId: number): Promise<TripJournalLinkMemberRow[]> {
const rows = await this.kysely<TripJournalLinkKyselyDB>()
.selectFrom('trip_journal_link_members as m')
.innerJoin('trip_journal_links as l', 'l.id', 'm.link_id')
.innerJoin('users as u', 'u.id', 'm.user_id')
.select(['m.user_id', 'm.role', 'u.username', 'u.avatar'])
.where('l.trip_id', '=', tripId)
.orderBy('u.username', 'asc')
.execute();
return rows.map((r) => ({ ...r, role: r.role === EDITOR ? EDITOR : VIEWER }));
}
/** Everyone on the trip, its owner first, with what the member picker shows of them. */
async listTripPeople(tripId: number): Promise<TripPersonRow[]> {
const db = this.kysely<TripJournalLinkKyselyDB>();
const owner = await db
.selectFrom('trips as t')
.innerJoin('users as u', 'u.id', 't.user_id')
.select(['u.id as user_id', 'u.username', 'u.avatar', 'u.is_guest'])
.where('t.id', '=', tripId)
.executeTakeFirst();
const members = await db
.selectFrom('trip_members as tm')
.innerJoin('users as u', 'u.id', 'tm.user_id')
.select(['u.id as user_id', 'u.username', 'u.avatar', 'u.is_guest'])
.where('tm.trip_id', '=', tripId)
.orderBy('u.username', 'asc')
.execute();
const people = owner ? [{ ...owner, is_trip_owner: true }] : [];
for (const m of members) if (m.user_id !== owner?.user_id) people.push({ ...m, is_trip_owner: false });
return people.map((p) => ({ ...p, is_guest: p.is_guest === 1 }));
}
/** Points the trip at a journal, replacing the one it showed; the link keeps its id. */
async upsertLink(tripId: number, journeyId: number, linkedBy: number, now: number): Promise<void> {
await this.kysely<TripJournalLinkKyselyDB>()
.insertInto('trip_journal_links')
.values({ trip_id: tripId, journey_id: journeyId, linked_by: linkedBy, created_at: now })
.onConflict((oc) =>
oc.column('trip_id').doUpdateSet({ journey_id: journeyId, linked_by: linkedBy, created_at: now }),
)
.execute();
}
/** The id of the trip's link, as a subquery the member writes narrow by. */
private linkIdOf(tripId: number) {
return this.kysely<TripJournalLinkKyselyDB>()
.selectFrom('trip_journal_links')
.select('id')
.where('trip_id', '=', tripId);
}
/** Sets who the trip's link lets in, replacing the list it had. */
async replaceMembers(
tripId: number,
members: { user_id: number; role: TripJournalLinkRole }[],
now: number,
): Promise<void> {
const db = this.kysely<TripJournalLinkKyselyDB>();
const link = await this.linkIdOf(tripId).executeTakeFirst();
if (!link) return;
await db.deleteFrom('trip_journal_link_members').where('link_id', '=', link.id).execute();
if (members.length === 0) return;
await db
.insertInto('trip_journal_link_members')
.values(members.map((m) => ({ link_id: link.id, user_id: m.user_id, role: m.role, added_at: now })))
.execute();
}
/** Removes the trip's link; its members go with it. */
async deleteForTrip(tripId: number): Promise<void> {
await this.kysely<TripJournalLinkKyselyDB>()
.deleteFrom('trip_journal_links')
.where('trip_id', '=', tripId)
.execute();
}
}
@@ -1,6 +1,7 @@
import { caseWhenEquals, coalesce } from '../dialect/sql-functions';
import type { TripMembers } from '../entities/TripMembers.entity';
import { Trips } from '../entities/Trips.entity';
import type { DB } from '../kysely/db';
import { TrekRepository } from './_shared/trek-repository';
/**
@@ -266,6 +267,22 @@ export class TripMembersRepository extends TrekRepository<TripMembers> {
await this.qb('m').delete().where({ trip: trip_id }).andWhere('m.user_id = ?', [user_id]).execute('run');
}
/**
* Takes someone who left the trip off its journal link, so coming back later
* does not quietly reopen the journal. Their access already ended with the
* membership (the link's reach requires it); this tidies the row. Here rather
* than in the journey domain because leaving is the roster's to finish, and
* reaching into journey from the roster closed a cycle between the two.
*/
async leaveJournalLink(tripId: number, userId: number): Promise<void> {
const db = this.kysely<Pick<DB, 'trip_journal_links' | 'trip_journal_link_members'>>();
await db
.deleteFrom('trip_journal_link_members')
.where('user_id', '=', userId)
.where('link_id', 'in', db.selectFrom('trip_journal_links').select('id').where('trip_id', '=', tripId))
.execute();
}
// ---------------------------------------------------------------------------
// Plan 3c Task 7 (`trips.rpc.ts::members`) — additive: no other repository
// is rooted at `trip_members` joined to `users`, and RP3's shape is
+2
View File
@@ -52,6 +52,7 @@ import { AirtrailModule } from './integrations/airtrail.module';
import { DawarichModule } from './integrations/dawarich.module';
import { JourneyAccessModule } from './journey/journey-access.module';
import { JourneyModule } from './journey/journey.module';
import { TripJournalLinkModule } from './journey/trip-journal-link.module';
import { LlmParseModule } from './llm-parse/llm-parse.module';
import { ManagedExtModule } from './managed/managed-ext.module';
import { MapsModule } from './maps/maps.module';
@@ -166,6 +167,7 @@ import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR, APP_PIPE } from '@nestjs/core';
AirtrailModule,
DawarichModule,
JourneyModule,
TripJournalLinkModule,
CollectionsModule,
ShareModule,
TripInviteModule,
@@ -178,7 +178,7 @@ export class JourneyDomainService {
// `SELECT *` read this repository now serves.
const own = await this.journeysRepo.findOwnedByUser(journeyId, userId);
if (own) return own as Journey;
const isContributor = await this.contributorsRepo.existsForUser(journeyId, userId);
const isContributor = await this.contributorsRepo.hasAccess(journeyId, userId);
if (isContributor) return ((await this.journeysRepo.findById(journeyId)) as Journey) || null;
return null;
}
@@ -189,7 +189,7 @@ export class JourneyDomainService {
async canEdit(journeyId: number, userId: number): Promise<boolean> {
if (await this.isOwner(journeyId, userId)) return true;
const role = await this.contributorsRepo.findRole(journeyId, userId);
const role = await this.contributorsRepo.findAccessRole(journeyId, userId);
return role === 'editor' || role === 'owner';
}
@@ -270,12 +270,10 @@ export class JourneyDomainService {
// linked trips (JG17)
const trips = await this.journeyTripsRepo.listForJourney(journeyId);
// contributors (JG18)
const contributorsRaw = await this.contributorsRepo.listForJourney(journeyId);
const contributors = contributorsRaw.map((c) => ({
...c,
avatar_url: avatarUrl(c),
}));
const withAvatar = <R extends { avatar: string | null }>(rows: R[]) => rows.map((r) => ({ ...r, avatar_url: avatarUrl(r) }));
const contributors = withAvatar(await this.contributorsRepo.listForJourney(journeyId)); // JG18
// Who a trip's journal link lets in besides them (#2683), shown apart in the settings.
const tripAccess = withAvatar(await this.contributorsRepo.listTripAccessForJourney(journeyId));
// stats
const entryCount = entries.filter((e) => e.type === 'entry').length;
@@ -293,7 +291,7 @@ export class JourneyDomainService {
myRole = 'owner';
} else {
// JG21 — same text as JG7 (`findRole`).
const role = await this.contributorsRepo.findRole(journeyId, userId);
const role = await this.contributorsRepo.findAccessRole(journeyId, userId);
myRole = (role as 'editor' | 'viewer' | undefined) ?? null;
}
@@ -306,6 +304,7 @@ export class JourneyDomainService {
gallery,
trips,
contributors,
trip_access: tripAccess,
stats: { entries: entryCount, photos: photoCount, places: places.length },
hide_skeletons: hideSkeletons,
// What the eye toggle cannot say: how much was waved away one at a time, and
@@ -0,0 +1,70 @@
import { ADDON_IDS } from '../../addons';
import type { User } from '../../types';
import { AddonGuard } from '../addons/addon.guard';
import { RequireAddon } from '../addons/require-addon.decorator';
import { CurrentUser } from '../auth-core/current-user.decorator';
import { JwtAuthGuard } from '../auth-core/jwt-auth.guard';
import { ResponseContract } from '../common/response-contract';
import { TripAccessGuard } from '../permissions/trip-access.guard';
import { TripJournalCandidatesQueryDto, TripJournalLinkDto, TripJournalLinkMembersDto } from './trip-journal-link.dto';
import { TripJournalLinkService } from './trip-journal-link.service';
import { Body, Controller, Delete, Get, Headers, HttpCode, Param, Patch, Put, Query, UseGuards } from '@nestjs/common';
import { tripJournalCandidatesResponseSchema, tripJournalLinkResponseSchema } from '@trek/shared';
/**
* The journal a trip shows, at `/api/trips/:tripId/journal`. Every member of
* the trip may read the link as it concerns them; changing it is decided in the
* service, because it turns on who owns the journal as well as on the trip
* permission. Behind the Journey addon: with it off the routes answer 404.
*/
@Controller('api/trips/:tripId/journal')
@UseGuards(AddonGuard, JwtAuthGuard, TripAccessGuard)
@RequireAddon(ADDON_IDS.JOURNEY, 'Journey')
export class TripJournalLinkController {
constructor(private readonly links: TripJournalLinkService) {}
@Get()
@ResponseContract(tripJournalLinkResponseSchema)
async get(@Param('tripId') tripId: string, @CurrentUser() user: User) {
return this.links.get(Number(tripId), user);
}
@Get('candidates')
@ResponseContract(tripJournalCandidatesResponseSchema)
async candidates(
@Param('tripId') tripId: string,
@CurrentUser() user: User,
@Query() query: TripJournalCandidatesQueryDto,
) {
return { candidates: await this.links.candidates(Number(tripId), user, query.journey_id) };
}
@Put()
@ResponseContract(tripJournalLinkResponseSchema)
async link(
@Param('tripId') tripId: string,
@CurrentUser() user: User,
@Body() body: TripJournalLinkDto,
@Headers('x-socket-id') socketId?: string,
) {
return this.links.link(Number(tripId), user, body.journey_id, body.members, socketId);
}
@Patch('members')
@ResponseContract(tripJournalLinkResponseSchema)
async setMembers(
@Param('tripId') tripId: string,
@CurrentUser() user: User,
@Body() body: TripJournalLinkMembersDto,
@Headers('x-socket-id') socketId?: string,
) {
return this.links.setMembers(Number(tripId), user, body.members, socketId);
}
@Delete()
@HttpCode(200)
@ResponseContract(tripJournalLinkResponseSchema)
async unlink(@Param('tripId') tripId: string, @CurrentUser() user: User, @Headers('x-socket-id') socketId?: string) {
return this.links.unlink(Number(tripId), user, socketId);
}
}
@@ -0,0 +1,11 @@
import {
tripJournalCandidatesQuerySchema,
tripJournalLinkMembersRequestSchema,
tripJournalLinkRequestSchema,
} from '@trek/shared';
import { createZodDto } from 'nestjs-zod';
export class TripJournalLinkDto extends createZodDto(tripJournalLinkRequestSchema) {}
export class TripJournalLinkMembersDto extends createZodDto(tripJournalLinkMembersRequestSchema) {}
export class TripJournalCandidatesQueryDto extends createZodDto(tripJournalCandidatesQuerySchema) {}
@@ -0,0 +1,97 @@
import { ADDON_IDS } from '../../addons';
import {
McpController,
TOOL_ANNOTATIONS_DELETE,
TOOL_ANNOTATIONS_READONLY,
TOOL_ANNOTATIONS_WRITE,
Tool,
ok,
type McpContext,
} from '../../nest-mcp';
import { addonGate } from '../addons/addon-gate';
import { AddonsService } from '../addons/addons.service';
import { McpToolGuardsService } from '../mcp-shared/mcp-tool-guards.service';
import { TripJournalLinkService, type TripJournalActor } from './trip-journal-link.service';
import { idSchema, tripJournalLinkRequestSchema } from '@trek/shared';
const journeyAddonOn = addonGate(ADDON_IDS.JOURNEY);
/**
* The trip's linked journal over MCP: the same service, the same permission and
* journal-owner rules as `/api/trips/:tripId/journal`, behind the Journey addon.
*/
@McpController()
export class TripJournalLinkMcp {
constructor(
private readonly links: TripJournalLinkService,
private readonly guards: McpToolGuardsService,
readonly addons: AddonsService,
) {}
private async actor(tripId: number, userId: number): Promise<TripJournalActor> {
const { role } = await this.guards.tripWriter(tripId, userId);
return { id: userId, role };
}
@Tool({
name: 'get_trip_journal',
description:
'The journal from the Journey addon a trip shows as its own, if any: its id and title, your access to it, and whether you may link, unlink or change who it is open to. Open the journal itself with get_journey.',
inputSchema: { tripId: idSchema },
annotations: TOOL_ANNOTATIONS_READONLY,
when: journeyAddonOn,
access: { group: 'journey', mode: 'read' },
})
async getTripJournal({ tripId }: { tripId: number }, ctx: McpContext) {
return ok(await this.links.get(tripId, await this.actor(tripId, ctx.userId)));
}
@Tool({
name: 'link_trip_journal',
description:
'Show one of your own journals in a trip, replacing the journal it showed. The trip members listed get into the journal as viewers or editors and see it in their own journal list; members left out do not. Needs the journal to be yours and the trip permission to link journals.',
inputSchema: { tripId: idSchema, journey_id: idSchema, members: tripJournalLinkRequestSchema.shape.members },
annotations: TOOL_ANNOTATIONS_WRITE,
when: journeyAddonOn,
access: { group: 'journey', mode: 'write' },
})
async linkTripJournal(
{
tripId,
journey_id,
members,
}: { tripId: number; journey_id: number; members: { user_id: number; role: 'viewer' | 'editor' }[] },
ctx: McpContext,
) {
return ok(await this.links.link(tripId, await this.actor(tripId, ctx.userId), journey_id, members));
}
@Tool({
name: 'set_trip_journal_members',
description:
"Change which trip members the trip's linked journal is open to, and as viewer or editor. The list replaces the one before; anyone left out loses the access the link gave them. Only the journal's owner can do this.",
inputSchema: { tripId: idSchema, members: tripJournalLinkRequestSchema.shape.members },
annotations: TOOL_ANNOTATIONS_WRITE,
when: journeyAddonOn,
access: { group: 'journey', mode: 'write' },
})
async setTripJournalMembers(
{ tripId, members }: { tripId: number; members: { user_id: number; role: 'viewer' | 'editor' }[] },
ctx: McpContext,
) {
return ok(await this.links.setMembers(tripId, await this.actor(tripId, ctx.userId), members));
}
@Tool({
name: 'unlink_trip_journal',
description:
'Take the linked journal off a trip. The journal itself stays; the members the link let in lose that access, while its owner and direct contributors keep theirs.',
inputSchema: { tripId: idSchema },
annotations: TOOL_ANNOTATIONS_DELETE,
when: journeyAddonOn,
access: { group: 'journey', mode: 'write' },
})
async unlinkTripJournal({ tripId }: { tripId: number }, ctx: McpContext) {
return ok(await this.links.unlink(tripId, await this.actor(tripId, ctx.userId)));
}
}
@@ -0,0 +1,33 @@
import { JourneyContributors } from '../../db/entities/JourneyContributors.entity';
import { TripJournalLinks } from '../../db/entities/TripJournalLinks.entity';
import { AddonsModule } from '../addons/addons.module';
import { McpSharedModule } from '../mcp-shared/mcp-shared.module';
import { PermissionsModule } from '../permissions/permissions.module';
import { RealtimeModule } from '../realtime/realtime.module';
import { TripMembershipModule } from '../trip-membership/trip-membership.module';
import { JourneyDomainModule } from './journey-domain.module';
import { TripJournalLinkController } from './trip-journal-link.controller';
import { TripJournalLinkMcp } from './trip-journal-link.mcp';
import { TripJournalLinkService } from './trip-journal-link.service';
import { MikroOrmModule } from '@mikro-orm/nestjs';
import { Module } from '@nestjs/common';
/**
* A trip showing one journal from the Journey addon: the link, its routes and
* its MCP tools.
*/
@Module({
imports: [
JourneyDomainModule,
TripMembershipModule,
PermissionsModule,
RealtimeModule,
AddonsModule,
McpSharedModule,
MikroOrmModule.forFeature([TripJournalLinks, JourneyContributors]),
],
controllers: [TripJournalLinkController],
providers: [TripJournalLinkService, TripJournalLinkMcp],
exports: [TripJournalLinkService],
})
export class TripJournalLinkModule {}
@@ -0,0 +1,209 @@
import { JourneyContributors } from '../../db/entities/JourneyContributors.entity';
import { TripJournalLinks } from '../../db/entities/TripJournalLinks.entity';
import type { JourneyContributorsRepository } from '../../db/repositories/JourneyContributors.repository';
import type { TripJournalLinksRepository, TripPersonRow } from '../../db/repositories/TripJournalLinks.repository';
import { avatarUrl } from '../common/avatarUrl';
import { forbidden, notFound } from '../common/domain-error';
import { UnitOfWork } from '../database/unit-of-work';
import { PermissionsService } from '../permissions/permissions.service';
import { RealtimeService } from '../realtime/realtime.service';
import { TripAccessService } from '../trip-membership/trip-access.service';
import { JourneyDomainService } from './journey-domain.service';
import { InjectRepository } from '@mikro-orm/nestjs';
import { Injectable } from '@nestjs/common';
import type {
TripJournalAccessRole,
TripJournalCandidate,
TripJournalLinkMemberRequest,
TripJournalLinkResponse,
} from '@trek/shared';
/** Who is acting: their id, and their account role for the admin bypass of trip permissions. */
export interface TripJournalActor {
id: number;
role: string;
}
const NOT_LINKED = 'No journal is linked to this trip';
/**
* A trip that shows one journal from the Journey addon as its own.
*
* Linking opens the journal to the trip members chosen for it, as viewers or
* editors, for as long as the link holds them and they stay on the trip. That
* access is read wherever journal access is read (`JourneyContributorsRepository`
* folds it in), so the journal's own screens, its live updates and its MCP tools
* serve linked members without knowing about trips.
*
* Linking a journal, or changing who it is open to, hands someone else's access
* to it, so it takes the journal's owner, and on the trip the `journal_link`
* permission. Taking the link down only takes access away, so the permission
* alone or the journal's owner alone is enough.
*/
@Injectable()
export class TripJournalLinkService {
constructor(
@InjectRepository(TripJournalLinks) private readonly links: TripJournalLinksRepository,
@InjectRepository(JourneyContributors) private readonly contributors: JourneyContributorsRepository,
private readonly journeys: JourneyDomainService,
private readonly tripAccess: TripAccessService,
private readonly permissions: PermissionsService,
private readonly realtime: RealtimeService,
private readonly uow: UnitOfWork,
) {}
/** The trip, or a 404 for someone who is not on it. */
private async requireTrip(tripId: number, actor: TripJournalActor) {
const trip = await this.tripAccess.findAccessible(tripId, actor.id);
if (!trip) throw notFound('Trip not found');
return trip;
}
private async mayLink(trip: { user_id: number }, actor: TripJournalActor): Promise<boolean> {
return this.permissions.checkPermission(
'journal_link',
actor.role,
trip.user_id,
actor.id,
trip.user_id !== actor.id,
);
}
/** The access someone holds on a journal today, as owner, contributor or through a link. */
private async accessOf(journeyId: number, ownerId: number, userId: number): Promise<TripJournalAccessRole | null> {
if (userId === ownerId) return 'owner';
const role = await this.contributors.findAccessRole(journeyId, userId);
return role === 'owner' || role === 'editor' || role === 'viewer' ? role : null;
}
/** The trip's journal as this person sees it, and what they may do with it. */
async get(tripId: number, actor: TripJournalActor): Promise<TripJournalLinkResponse> {
const trip = await this.requireTrip(tripId, actor);
const canLink = await this.mayLink(trip, actor);
const row = await this.links.findForTrip(tripId);
if (!row) return { link: null, can_link: canLink, can_unlink: false, can_edit_members: false };
const isJournalOwner = row.journey_owner_id === actor.id;
const myRole = await this.accessOf(row.journey_id, row.journey_owner_id, actor.id);
const canEditMembers = canLink && isJournalOwner;
const canUnlink = canLink || isJournalOwner;
// Someone the journal is closed to, and who can do nothing about the link,
// does not see that the trip has one.
if (!myRole && !canLink) return { link: null, can_link: false, can_unlink: false, can_edit_members: false };
const members = canLink || isJournalOwner ? await this.links.listMembers(tripId) : null;
return {
link: {
journey_id: row.journey_id,
title: row.journey_title,
subtitle: row.journey_subtitle,
cover_image: row.journey_cover_image,
owner_id: row.journey_owner_id,
linked_by: row.linked_by,
created_at: row.created_at,
my_role: myRole,
members:
members?.map((m) => ({ user_id: m.user_id, username: m.username, avatar_url: avatarUrl(m), role: m.role })) ??
null,
},
can_link: canLink,
can_unlink: canUnlink,
can_edit_members: canEditMembers,
};
}
/**
* The people of the trip who could be let into a journal, with the access they
* hold there today. A null `access` is someone the link would let in for the
* first time, the person the dialog warns about. Only for the journal's owner.
*/
async candidates(tripId: number, actor: TripJournalActor, journeyId: number): Promise<TripJournalCandidate[]> {
await this.requireTrip(tripId, actor);
if (!(await this.journeys.isOwner(journeyId, actor.id))) throw forbidden('Only the journal owner can link it');
const people = (await this.links.listTripPeople(tripId)).filter((p) => !p.is_guest && p.user_id !== actor.id);
return await Promise.all(
people.map(async (p) => ({
user_id: p.user_id,
username: p.username,
avatar_url: avatarUrl(p),
is_trip_owner: p.is_trip_owner,
access: await this.accessOf(journeyId, actor.id, p.user_id),
})),
);
}
/**
* The members a link may hold: people on the trip, never a guest (who cannot
* sign in) and never the journal's owner (who has it anyway). A repeated id
* keeps its last role.
*/
private allowedMembers(
people: TripPersonRow[],
ownerId: number,
members: TripJournalLinkMemberRequest[],
): TripJournalLinkMemberRequest[] {
const onTrip = new Set(people.filter((p) => !p.is_guest).map((p) => p.user_id));
const byUser = new Map<number, TripJournalLinkMemberRequest>();
for (const m of members) {
if (m.user_id === ownerId) continue;
if (!onTrip.has(m.user_id)) throw forbidden('Only members of this trip can be given the journal');
byUser.set(m.user_id, m);
}
return [...byUser.values()];
}
/** Shows a journal in the trip, to these members, replacing the one it showed. */
async link(
tripId: number,
actor: TripJournalActor,
journeyId: number,
members: TripJournalLinkMemberRequest[],
socketId?: string,
): Promise<TripJournalLinkResponse> {
const trip = await this.requireTrip(tripId, actor);
if (!(await this.mayLink(trip, actor))) throw forbidden('No permission to link a journal');
if (!(await this.journeys.isOwner(journeyId, actor.id))) throw forbidden('Only the journal owner can link it');
const allowed = this.allowedMembers(await this.links.listTripPeople(tripId), actor.id, members);
const now = Date.now();
await this.uow.transactional(async () => {
await this.links.upsertLink(tripId, journeyId, actor.id, now);
await this.links.replaceMembers(tripId, allowed, now);
});
this.realtime.broadcast(tripId, 'trip:journal:changed', {}, socketId);
return this.get(tripId, actor);
}
/** Changes who the linked journal is open to. */
async setMembers(
tripId: number,
actor: TripJournalActor,
members: TripJournalLinkMemberRequest[],
socketId?: string,
): Promise<TripJournalLinkResponse> {
const trip = await this.requireTrip(tripId, actor);
const row = await this.links.findForTrip(tripId);
if (!row) throw notFound(NOT_LINKED);
if (!(await this.mayLink(trip, actor)) || row.journey_owner_id !== actor.id) {
throw forbidden('Only the journal owner can change who it is open to');
}
const allowed = this.allowedMembers(await this.links.listTripPeople(tripId), row.journey_owner_id, members);
await this.uow.transactional(async () => {
await this.links.replaceMembers(tripId, allowed, Date.now());
});
this.realtime.broadcast(tripId, 'trip:journal:changed', {}, socketId);
return this.get(tripId, actor);
}
/** Takes the journal off the trip; everyone it let in loses that access. */
async unlink(tripId: number, actor: TripJournalActor, socketId?: string): Promise<TripJournalLinkResponse> {
const trip = await this.requireTrip(tripId, actor);
const row = await this.links.findForTrip(tripId);
if (!row) throw notFound(NOT_LINKED);
if (!(await this.mayLink(trip, actor)) && row.journey_owner_id !== actor.id) {
throw forbidden('No permission to unlink the journal');
}
await this.links.deleteForTrip(tripId);
this.realtime.broadcast(tripId, 'trip:journal:changed', {}, socketId);
return this.get(tripId, actor);
}
}
@@ -57,7 +57,7 @@ export class MemoriesAccessService {
/** MA2's owner-OR-contributor check, shared by MA2 and MA6's per-journey loop — the same two-branch `journeys`/`journey_contributors` logic `canAccessJourney` uses, re-implemented here (see the class docstring on why it isn't a call to `JourneyDomainService`). */
private async ownerOrContributor(journeyId: number, userId: number): Promise<boolean> {
if (await this.journeys.isOwnedByUser(journeyId, userId)) return true;
return await this.journeyContributors.existsForUser(journeyId, userId);
return await this.journeyContributors.hasAccess(journeyId, userId);
}
async canAccessUserPhoto(
@@ -197,7 +197,12 @@ export class TripMembersService {
* the route's one remaining raw-string caller).
*/
async removeMember(tripId: number, targetUserId: number): Promise<void> {
await this.tripMembersRepo.remove(tripId, targetUserId);
// A linked journal stops being open to someone who left; coming back later
// must not quietly reopen it.
await this.uow.transactional(async () => {
await this.tripMembersRepo.remove(tripId, targetUserId);
await this.tripMembersRepo.leaveJournalLink(tripId, targetUserId);
});
}
/**
+33 -26
View File
@@ -29,13 +29,12 @@ export async function makeDayAssignment(
overrides: EntityData<DayAssignments> = {},
): Promise<DayAssignmentRow> {
const orderIndex = overrides.order_index ?? (await nextOrderIndex(orm, dayId));
return createRow(orm, DayAssignments, {
day: dayId,
place: placeId,
notes: null,
...overrides,
order_index: orderIndex,
});
return createRow(
orm,
DayAssignments,
{ day: dayId, place: placeId, notes: null },
{ ...overrides, order_index: orderIndex },
);
}
/** A note on the day, sorted last unless `sort_order` says otherwise. */
@@ -45,15 +44,19 @@ export function makeDayNote(
tripId: number,
overrides: EntityData<DayNotes> = {},
): Promise<DayNoteRow> {
return createRow(orm, DayNotes, {
day: dayId,
trip: tripId,
text: 'Test note',
time: null,
icon: '📝',
sort_order: 9999,
...overrides,
});
return createRow(
orm,
DayNotes,
{
day: dayId,
trip: tripId,
text: 'Test note',
time: null,
icon: '📝',
sort_order: 9999,
},
overrides,
);
}
/** A stay at `placeId` from the start day to the end day. */
@@ -65,14 +68,18 @@ export function makeDayAccommodation(
endDayId: number,
overrides: EntityData<DayAccommodations> = {},
): Promise<DayAccommodationRow> {
return createRow(orm, DayAccommodations, {
trip: tripId,
place: placeId,
startDay: startDayId,
endDay: endDayId,
check_in: null,
check_out: null,
confirmation: null,
...overrides,
});
return createRow(
orm,
DayAccommodations,
{
trip: tripId,
place: placeId,
startDay: startDayId,
endDay: endDayId,
check_in: null,
check_out: null,
confirmation: null,
},
overrides,
);
}
+15 -5
View File
@@ -28,17 +28,26 @@ import type {
* `SELECT *` returned.
*/
/** Inserts one row and returns its primary key (the autoincrement id for most tables). */
/**
* Inserts one row and returns its primary key (the autoincrement id for most tables).
*
* A factory hands its caller's `overrides` in as their own argument rather than
* spread into `data`: checking a spread `EntityData` against `RequiredEntityData`
* expands every relation the entity reaches, and for the itinerary and tour rows
* (day to trip, place to tour) that took tsc past its instantiation depth (TS2589).
*/
export function insertRow<T extends object>(
orm: FactoryOrm,
entity: EntityClass<T>,
data: RequiredEntityData<T>,
overrides?: EntityData<T>,
): Promise<Primary<T>> {
const row = (overrides ? { ...data, ...overrides } : data) as RequiredEntityData<T>;
// async, so a refused column rejects the returned promise rather than
// throwing before the caller has one to await.
return inContext(orm, async (em) => {
assertWritable(em, entity, data);
return em.insert(entity, data);
assertWritable(em, entity, row);
return em.insert(entity, row);
});
}
@@ -65,14 +74,15 @@ export function insertRows<T extends object>(
/**
* Inserts one row into a table keyed by a single autoincrement `id` and reads
* it back, defaults and all. For a table with a composite or natural key use
* `insertRow` and then `findRow` with the key columns.
* `insertRow` and then `findRow` with the key columns. `overrides` as for `insertRow`.
*/
export async function createRow<T extends { id: number }>(
orm: FactoryOrm,
entity: EntityClass<T>,
data: RequiredEntityData<T>,
overrides?: EntityData<T>,
): Promise<EntityDTO<T>> {
const id = await insertRow(orm, entity, data);
const id = await insertRow(orm, entity, data, overrides);
const row = await findRow(orm, entity, { id } as FilterQuery<T>);
if (!row) throw new Error(`createRow: ${entity.name} ${String(id)} is not there after its insert`);
return row;
+1 -1
View File
@@ -11,7 +11,7 @@ export type TourRow = EntityDTO<Tours>;
* of its own; its type defaults to 'hike', the one the tours migration seeds.
*/
export async function makeTour(orm: FactoryOrm, placeId: number, overrides: EntityData<Tours> = {}): Promise<TourRow> {
await insertRow(orm, Tours, { place: placeId, tourTypeRef: 'hike', ...overrides });
await insertRow(orm, Tours, { place: placeId, tourTypeRef: 'hike' }, overrides);
const tour = await findRow(orm, Tours, { place: placeId });
if (!tour) throw new Error(`makeTour: no tour for place ${placeId} after its insert`);
return tour;
@@ -0,0 +1,348 @@
import { buildApp } from '../../src/bootstrap';
import { withRequestContext } from '../../src/nest/database/request-context';
import { TripJournalLinkMcp } from '../../src/nest/journey/trip-journal-link.mcp';
import { RealtimeService } from '../../src/nest/realtime/realtime.service';
import { authCookie } from '../helpers/auth';
import type { FactoryOrm } from '../helpers/factories/context';
import { addJourneyContributor, makeJourney } from '../helpers/factories/journeys';
import { setAddonEnabled } from '../helpers/factories/settings';
import { addTripMember, makeTrip } from '../helpers/factories/trips';
import { makeUser } from '../helpers/factories/users';
import { MikroORM } from '@mikro-orm/core';
import type { INestApplication } from '@nestjs/common';
import request from 'supertest';
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
/**
* A trip that shows one journal from the Journey addon (TJL-*).
*
* The link opens the journal to the trip members chosen for it, as viewers or
* editors, and that access is read wherever journal access is read. So these
* cases go through the real routes end to end: the link's own routes, and the
* journal's existing ones a linked member then reaches.
*/
let app: INestApplication;
let orm: FactoryOrm;
let broadcast: ReturnType<typeof vi.spyOn>;
beforeAll(async () => {
app = await buildApp();
await app.init();
orm = app.get(MikroORM);
broadcast = vi.spyOn(app.get(RealtimeService), 'broadcast');
});
afterAll(async () => {
await app?.close();
});
beforeEach(async () => {
await setAddonEnabled(orm, 'journey', true);
broadcast.mockClear();
});
const http = () => request(app.getHttpServer());
let scenes = 0;
/**
* A trip owned by `owner` with three members, and a journal of the owner's.
* The names carry the scene's number, since usernames are unique and every
* case builds its own scene on the one database.
*/
async function scene() {
const n = ++scenes;
const { user: owner } = await makeUser(orm, { username: `Owner ${n}` });
const { user: lena } = await makeUser(orm, { username: `Lena ${n}` });
const { user: tom } = await makeUser(orm, { username: `Tom ${n}` });
const { user: mia } = await makeUser(orm, { username: `Mia ${n}` });
const { user: stranger } = await makeUser(orm, { username: `Stranger ${n}` });
const trip = await makeTrip(orm, owner.id);
await Promise.all([lena, tom, mia].map((m) => addTripMember(orm, trip.id, m.id)));
const journal = await makeJourney(orm, owner.id, { title: 'New York, the long weekend' });
return { owner, lena, tom, mia, stranger, trip, journal };
}
async function link(s: Awaited<ReturnType<typeof scene>>, members: { user_id: number; role: 'viewer' | 'editor' }[]) {
return http()
.put(`/api/trips/${s.trip.id}/journal`)
.set('Cookie', authCookie(s.owner.id))
.send({ journey_id: s.journal.id, members });
}
describe('linking a journal to a trip', () => {
it('TJL-001: a trip without a link says so, and its owner may link one', async () => {
const s = await scene();
const res = await http().get(`/api/trips/${s.trip.id}/journal`).set('Cookie', authCookie(s.owner.id));
expect(res.status).toBe(200);
expect(res.body).toEqual({ link: null, can_link: true, can_unlink: false, can_edit_members: false });
});
it('TJL-002: links the journal, opens it to the chosen members and tells the trip', async () => {
const s = await scene();
const res = await link(s, [
{ user_id: s.lena.id, role: 'viewer' },
{ user_id: s.tom.id, role: 'editor' },
]);
expect(res.status).toBe(200);
expect(res.body.link).toMatchObject({
journey_id: s.journal.id,
title: 'New York, the long weekend',
my_role: 'owner',
});
expect(res.body.link.members.map((m: { username: string; role: string }) => [m.username, m.role])).toEqual([
[s.lena.username, 'viewer'],
[s.tom.username, 'editor'],
]);
expect(res.body).toMatchObject({ can_link: true, can_unlink: true, can_edit_members: true });
expect(broadcast).toHaveBeenCalledWith(s.trip.id, 'trip:journal:changed', {}, undefined);
});
it('TJL-003: a viewer reads the journal, finds it in their list, and cannot write to it', async () => {
const s = await scene();
await link(s, [{ user_id: s.lena.id, role: 'viewer' }]);
const cookie = authCookie(s.lena.id);
const seen = await http().get(`/api/trips/${s.trip.id}/journal`).set('Cookie', cookie);
expect(seen.body.link).toMatchObject({ journey_id: s.journal.id, my_role: 'viewer', members: null });
expect(seen.body).toMatchObject({ can_link: false, can_unlink: false, can_edit_members: false });
const journal = await http().get(`/api/journeys/${s.journal.id}`).set('Cookie', cookie);
expect(journal.status).toBe(200);
expect(journal.body.my_role).toBe('viewer');
const list = await http().get('/api/journeys').set('Cookie', cookie);
expect(list.body.journeys.map((j: { id: number }) => j.id)).toContain(s.journal.id);
const write = await http()
.post(`/api/journeys/${s.journal.id}/entries`)
.set('Cookie', cookie)
.send({ title: 'not mine to write', entry_date: '2026-05-01' });
expect(write.status).toBe(404);
});
it('TJL-004: an editor writes to the journal', async () => {
const s = await scene();
await link(s, [{ user_id: s.tom.id, role: 'editor' }]);
const res = await http()
.post(`/api/journeys/${s.journal.id}/entries`)
.set('Cookie', authCookie(s.tom.id))
.send({ title: 'Brooklyn Bridge at dawn', entry_date: '2026-05-01' });
expect(res.status).toBe(201);
});
it('TJL-005: a member left out sees no journal in the trip and cannot open it', async () => {
const s = await scene();
await link(s, [{ user_id: s.lena.id, role: 'viewer' }]);
const cookie = authCookie(s.mia.id);
const seen = await http().get(`/api/trips/${s.trip.id}/journal`).set('Cookie', cookie);
expect(seen.body).toEqual({ link: null, can_link: false, can_unlink: false, can_edit_members: false });
expect((await http().get(`/api/journeys/${s.journal.id}`).set('Cookie', cookie)).status).toBe(404);
});
it('TJL-006: someone outside the trip gets a 404 for its journal', async () => {
const s = await scene();
const res = await http().get(`/api/trips/${s.trip.id}/journal`).set('Cookie', authCookie(s.stranger.id));
expect(res.status).toBe(404);
});
it('TJL-007: refuses a member without the permission, a journal not theirs, and people off the trip', async () => {
const s = await scene();
const asLena = await http()
.put(`/api/trips/${s.trip.id}/journal`)
.set('Cookie', authCookie(s.lena.id))
.send({ journey_id: s.journal.id, members: [] });
expect(asLena.status).toBe(403);
const other = await makeJourney(orm, s.lena.id);
const notHers = await http()
.put(`/api/trips/${s.trip.id}/journal`)
.set('Cookie', authCookie(s.owner.id))
.send({ journey_id: other.id, members: [] });
expect(notHers.status).toBe(403);
expect(notHers.body.error).toBe('Only the journal owner can link it');
const offTrip = await link(s, [{ user_id: s.stranger.id, role: 'viewer' }]);
expect(offTrip.status).toBe(403);
expect(offTrip.body.error).toBe('Only members of this trip can be given the journal');
});
it('TJL-008: never lets a guest in, and leaves the journal owner off the list', async () => {
const s = await scene();
const { user: guest } = await makeUser(orm, { is_guest: 1 });
await addTripMember(orm, s.trip.id, guest.id);
const withGuest = await link(s, [{ user_id: guest.id, role: 'viewer' }]);
expect(withGuest.status).toBe(403);
const withOwner = await link(s, [
{ user_id: s.owner.id, role: 'viewer' },
{ user_id: s.lena.id, role: 'viewer' },
{ user_id: s.lena.id, role: 'editor' },
]);
expect(withOwner.body.link.members.map((m: { user_id: number; role: string }) => [m.user_id, m.role])).toEqual([
[s.lena.id, 'editor'],
]);
});
it('TJL-009: the owner may only link a journal they own, even with the admin permission set wide', async () => {
const s = await scene();
const lenaJournal = await makeJourney(orm, s.lena.id);
const res = await http()
.get(`/api/trips/${s.trip.id}/journal/candidates?journey_id=${lenaJournal.id}`)
.set('Cookie', authCookie(s.owner.id));
expect(res.status).toBe(403);
});
});
describe('who a journal would be opened to', () => {
it('TJL-010: lists the trip people but the caller and guests, with the access they hold today', async () => {
const s = await scene();
await addJourneyContributor(orm, s.journal.id, s.tom.id, 'editor');
const res = await http()
.get(`/api/trips/${s.trip.id}/journal/candidates?journey_id=${s.journal.id}`)
.set('Cookie', authCookie(s.owner.id));
expect(res.status).toBe(200);
const byName = Object.fromEntries(
res.body.candidates.map((c: { username: string; access: string | null }) => [c.username, c.access]),
);
expect(byName).toEqual({ [s.lena.username]: null, [s.mia.username]: null, [s.tom.username]: 'editor' });
});
it('TJL-011: refuses a journey id that is not a number', async () => {
const s = await scene();
const res = await http()
.get(`/api/trips/${s.trip.id}/journal/candidates?journey_id=abc`)
.set('Cookie', authCookie(s.owner.id));
expect(res.status).toBe(400);
});
});
describe('changing and ending a link', () => {
it('TJL-012: changing the members takes the access of whoever was left out', async () => {
const s = await scene();
await link(s, [
{ user_id: s.lena.id, role: 'viewer' },
{ user_id: s.tom.id, role: 'editor' },
]);
const res = await http()
.patch(`/api/trips/${s.trip.id}/journal/members`)
.set('Cookie', authCookie(s.owner.id))
.send({ members: [{ user_id: s.tom.id, role: 'viewer' }] });
expect(res.status).toBe(200);
expect((await http().get(`/api/journeys/${s.journal.id}`).set('Cookie', authCookie(s.lena.id))).status).toBe(404);
const tom = await http().get(`/api/journeys/${s.journal.id}`).set('Cookie', authCookie(s.tom.id));
expect(tom.body.my_role).toBe('viewer');
});
it('TJL-013: only the journal owner may change who it is open to', async () => {
const s = await scene();
await link(s, []);
const res = await http()
.patch(`/api/trips/${s.trip.id}/journal/members`)
.set('Cookie', authCookie(s.lena.id))
.send({ members: [{ user_id: s.mia.id, role: 'editor' }] });
expect(res.status).toBe(403);
});
it('TJL-014: changing members or unlinking a trip without a link answers 404', async () => {
const s = await scene();
const cookie = authCookie(s.owner.id);
expect(
(await http().patch(`/api/trips/${s.trip.id}/journal/members`).set('Cookie', cookie).send({ members: [] }))
.status,
).toBe(404);
expect((await http().delete(`/api/trips/${s.trip.id}/journal`).set('Cookie', cookie)).status).toBe(404);
});
it('TJL-015: unlinking takes what the link gave and keeps what was given directly', async () => {
const s = await scene();
await addJourneyContributor(orm, s.journal.id, s.mia.id, 'viewer');
await link(s, [
{ user_id: s.lena.id, role: 'viewer' },
{ user_id: s.mia.id, role: 'editor' },
]);
const mia = await http().get(`/api/journeys/${s.journal.id}`).set('Cookie', authCookie(s.mia.id));
expect(mia.body.my_role).toBe('editor');
const res = await http().delete(`/api/trips/${s.trip.id}/journal`).set('Cookie', authCookie(s.owner.id));
expect(res.status).toBe(200);
expect(res.body.link).toBeNull();
expect((await http().get(`/api/journeys/${s.journal.id}`).set('Cookie', authCookie(s.lena.id))).status).toBe(404);
const after = await http().get(`/api/journeys/${s.journal.id}`).set('Cookie', authCookie(s.mia.id));
expect(after.body.my_role).toBe('viewer');
});
it('TJL-016: a member who leaves the trip loses the journal, and coming back does not reopen it', async () => {
const s = await scene();
await link(s, [{ user_id: s.lena.id, role: 'viewer' }]);
const left = await http()
.delete(`/api/trips/${s.trip.id}/members/${s.lena.id}`)
.set('Cookie', authCookie(s.lena.id));
expect(left.status).toBe(200);
expect((await http().get(`/api/journeys/${s.journal.id}`).set('Cookie', authCookie(s.lena.id))).status).toBe(404);
await addTripMember(orm, s.trip.id, s.lena.id);
expect((await http().get(`/api/journeys/${s.journal.id}`).set('Cookie', authCookie(s.lena.id))).status).toBe(404);
});
it('TJL-017: a link member keeps a "hide skeletons" choice of their own', async () => {
const s = await scene();
await link(s, [{ user_id: s.lena.id, role: 'viewer' }]);
const cookie = authCookie(s.lena.id);
const set = await http()
.patch(`/api/journeys/${s.journal.id}/preferences`)
.set('Cookie', cookie)
.send({ hide_skeletons: true });
expect(set.status).toBe(200);
const journal = await http().get(`/api/journeys/${s.journal.id}`).set('Cookie', cookie);
expect(journal.body.hide_skeletons).toBe(true);
const owner = await http().get(`/api/journeys/${s.journal.id}`).set('Cookie', authCookie(s.owner.id));
expect(owner.body.hide_skeletons).toBe(false);
});
it('TJL-018: with the Journey addon off the routes answer 404', async () => {
const s = await scene();
await setAddonEnabled(orm, 'journey', false);
const res = await http().get(`/api/trips/${s.trip.id}/journal`).set('Cookie', authCookie(s.owner.id));
expect(res.status).toBe(404);
});
});
describe('the same over MCP', () => {
it('TJL-019: links, reads, changes and unlinks with the rules of the routes', async () => {
const s = await scene();
const mcp = app.get(TripJournalLinkMcp);
const ctx = (userId: number) => ({ userId }) as Parameters<TripJournalLinkMcp['getTripJournal']>[1];
const json = (r: { content: { text: string }[] }) => JSON.parse(r.content[0].text);
const run = <T>(fn: () => Promise<T>) => withRequestContext(orm as MikroORM, fn);
const linked = json(
await run(() =>
mcp.linkTripJournal(
{ tripId: s.trip.id, journey_id: s.journal.id, members: [{ user_id: s.lena.id, role: 'viewer' }] },
ctx(s.owner.id),
),
),
);
expect(linked.link.members).toHaveLength(1);
expect(json(await run(() => mcp.getTripJournal({ tripId: s.trip.id }, ctx(s.lena.id)))).link.my_role).toBe(
'viewer',
);
await expect(
run(() => mcp.setTripJournalMembers({ tripId: s.trip.id, members: [] }, ctx(s.lena.id))),
).rejects.toMatchObject({ status: 403 });
const changed = json(
await run(() =>
mcp.setTripJournalMembers(
{ tripId: s.trip.id, members: [{ user_id: s.tom.id, role: 'editor' }] },
ctx(s.owner.id),
),
),
);
expect(changed.link.members.map((m: { user_id: number }) => m.user_id)).toEqual([s.tom.id]);
expect(json(await run(() => mcp.unlinkTripJournal({ tripId: s.trip.id }, ctx(s.owner.id)))).link).toBeNull();
});
});
+4 -3
View File
@@ -37,14 +37,15 @@ describe('ALL_ENTITIES', () => {
* + scheduler_leases from the cron lease migration
* + user_sessions, the session rows behind revocable session tokens
* + ferrata_tiles, the stored via ferrata grid cells
* + paddle_regions, paddle_features and tour_obstacle_reviews, the paddle obstacle data and decisions)
* + paddle_regions, paddle_features and tour_obstacle_reviews, the paddle obstacle data and decisions
* + trip_journal_links and trip_journal_link_members, the journal a trip shows and who it is open to)
* is registered, and no two share a class name (a silent duplicate would
* shadow one of them in `orm.getMetadata()`, exactly the kind of drift
* `entity-schema-parity.test.ts` can't see — it iterates the metadata map,
* which de-duplicates by class name).
*/
it('ENT-003: exactly 138 entities, every class name unique', () => {
expect(ALL_ENTITIES.length).toBe(138);
it('ENT-003: exactly 140 entities, every class name unique', () => {
expect(ALL_ENTITIES.length).toBe(140);
const names = ALL_ENTITIES.map((s) => s.meta.className);
expect(new Set(names).size).toBe(names.length);
});
@@ -29,7 +29,9 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
// + paddle_regions: id, name; paddle_features: id, region_id, osm_ref, kind, geometry, tags, tag_hash and
// its bounding box (a feature is only ever written whole, from one read of its region).
// + tour_obstacle_reviews: place_id, obstacle_key, decision, both fingerprints, obstacle_snapshot, decided_at.
const KNOWN_UNINITIALISED_NOT_NULL_SCALARS = 475;
// + trip_journal_links: id, trip_id, journey_id, created_at; trip_journal_link_members: link_id, user_id,
// added_at (a link and its members are always written whole).
const KNOWN_UNINITIALISED_NOT_NULL_SCALARS = 482;
describe('entity class fields never carry a NaN initialiser (task-4-review-shape.md, Important 1)', () => {
it('CLASSFIELD-001: new X() has no own property whose value is NaN, across all 133 entities', () => {
@@ -54,6 +54,7 @@ const UNNUMBERED = [
'Migration20200101044000_a_paddle_tour_plans_its_portages_and_locks',
'Migration20200101044100_paddle_obstacles_from_openstreetmap',
'Migration20200101044200_a_paddle_tour_records_its_obstacle_decisions',
'Migration20200101044300_a_trip_links_a_journal',
];
// The last step the positional runner ever had; planLegacyBaseline refuses a
// schema_version past it.
@@ -0,0 +1,191 @@
/**
* `TripJournalLinksRepository` and the link's reach (TJLREPO-*): one journal
* per trip, the members it lets in, and the rule every read of link access
* starts from: a member counts only while they are still on the linking trip.
* Also the roster's side, `TripMembersRepository.leaveJournalLink`.
*/
import { JourneyContributors } from '../../../../src/db/entities/JourneyContributors.entity';
import { TripJournalLinks } from '../../../../src/db/entities/TripJournalLinks.entity';
import { TripMembers } from '../../../../src/db/entities/TripMembers.entity';
import type { JourneyContributorsRepository } from '../../../../src/db/repositories/JourneyContributors.repository';
import type { TripJournalLinksRepository } from '../../../../src/db/repositories/TripJournalLinks.repository';
import type { TripMembersRepository } from '../../../../src/db/repositories/TripMembers.repository';
import { createSnapshotTestDb } from '../../../helpers/db-mock';
import { addJourneyContributor, makeJourney } from '../../../helpers/factories/journeys';
import { countRows } from '../../../helpers/factories/rows';
import { addTripMember, makeTrip } from '../../../helpers/factories/trips';
import { makeUser } from '../../../helpers/factories/users';
import { resetTestDb } from '../../../helpers/test-db';
import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
const testDb = createSnapshotTestDb();
let t: TestOrm;
let links: TripJournalLinksRepository;
let contributors: JourneyContributorsRepository;
let roster: TripMembersRepository;
beforeAll(async () => {
t = await createTestOrm(testDb);
links = t.repo(TripJournalLinks);
contributors = t.repo(JourneyContributors);
roster = t.repo(TripMembers);
});
beforeEach(() => {
resetTestDb(testDb);
t.clear();
});
afterAll(async () => {
await t.close();
testDb.close();
});
/** A trip owned by `owner` with Lena and Tom on it, a guest, and a journal of the owner's. */
async function scene() {
const { user: owner } = await makeUser(t, { username: 'Owner' });
const { user: lena } = await makeUser(t, { username: 'Lena' });
const { user: tom } = await makeUser(t, { username: 'Tom' });
const { user: guest } = await makeUser(t, { username: 'Guest', is_guest: 1 });
const trip = await makeTrip(t, owner.id);
await Promise.all([lena, tom, guest].map((m) => addTripMember(t, trip.id, m.id)));
const journal = await makeJourney(t, owner.id, { title: 'Japan' });
return { owner, lena, tom, guest, trip, journal };
}
describe('TripJournalLinksRepository', () => {
it('TJLREPO-001: a trip without a link reads as none, and its people with the owner first', async () => {
const s = await scene();
expect(await links.findForTrip(s.trip.id)).toBeNull();
expect(await links.listMembers(s.trip.id)).toEqual([]);
const people = await links.listTripPeople(s.trip.id);
expect(people.map((p) => [p.username, p.is_trip_owner, p.is_guest])).toEqual([
['Owner', true, false],
['Guest', false, true],
['Lena', false, false],
['Tom', false, false],
]);
});
it('TJLREPO-002: linking reads back with the journal, and relinking keeps one row per trip', async () => {
const s = await scene();
const other = await makeJourney(t, s.owner.id, { title: 'Iceland' });
await links.upsertLink(s.trip.id, s.journal.id, s.owner.id, 1);
await links.upsertLink(s.trip.id, other.id, s.owner.id, 2);
const row = await links.findForTrip(s.trip.id);
expect(row).toMatchObject({ trip_id: s.trip.id, journey_id: other.id, journey_title: 'Iceland', created_at: 2 });
expect(await countRows(t, TripJournalLinks, {})).toBe(1);
});
it('TJLREPO-003: members are replaced as a list, and a trip without a link takes none', async () => {
const s = await scene();
await links.replaceMembers(s.trip.id, [{ user_id: s.lena.id, role: 'viewer' }], 1);
expect(await links.listMembers(s.trip.id)).toEqual([]);
await links.upsertLink(s.trip.id, s.journal.id, s.owner.id, 1);
await links.replaceMembers(
s.trip.id,
[
{ user_id: s.tom.id, role: 'editor' },
{ user_id: s.lena.id, role: 'viewer' },
],
1,
);
expect((await links.listMembers(s.trip.id)).map((m) => [m.username, m.role])).toEqual([
['Lena', 'viewer'],
['Tom', 'editor'],
]);
await links.replaceMembers(s.trip.id, [], 2);
expect(await links.listMembers(s.trip.id)).toEqual([]);
});
it('TJLREPO-004: the reach gives the stronger role across trips and ends with the membership', async () => {
const s = await scene();
const second = await makeTrip(t, s.owner.id);
await addTripMember(t, second.id, s.lena.id);
await links.upsertLink(s.trip.id, s.journal.id, s.owner.id, 1);
await links.upsertLink(second.id, s.journal.id, s.owner.id, 1);
await links.replaceMembers(s.trip.id, [{ user_id: s.lena.id, role: 'viewer' }], 1);
await links.replaceMembers(second.id, [{ user_id: s.lena.id, role: 'editor' }], 1);
expect(await contributors.findAccessRole(s.journal.id, s.lena.id)).toBe('editor');
expect(await contributors.findAccessRole(s.journal.id, s.tom.id)).toBeUndefined();
await roster.remove(second.id, s.lena.id);
expect(await contributors.findAccessRole(s.journal.id, s.lena.id)).toBe('viewer');
await roster.remove(s.trip.id, s.lena.id);
expect(await contributors.hasAccess(s.journal.id, s.lena.id)).toBe(false);
});
it('TJLREPO-005: a link member keeps a hide-skeletons choice of their own, apart from contributors', async () => {
const s = await scene();
await links.upsertLink(s.trip.id, s.journal.id, s.owner.id, 1);
await links.replaceMembers(s.trip.id, [{ user_id: s.lena.id, role: 'viewer' }], 1);
expect(await contributors.getHideSkeletons(s.journal.id, s.lena.id)).toBe(false);
await contributors.setHideSkeletons(s.journal.id, s.lena.id, 1);
expect(await contributors.getHideSkeletons(s.journal.id, s.lena.id)).toBe(true);
expect(await contributors.getHideSkeletons(s.journal.id, s.tom.id)).toBe(false);
await addJourneyContributor(t, s.journal.id, s.tom.id, 'viewer');
await contributors.setHideSkeletons(s.journal.id, s.tom.id, 1);
expect(await contributors.getHideSkeletons(s.journal.id, s.tom.id)).toBe(true);
});
it('TJLREPO-006: a direct editor stays one, and a link editor outranks a direct viewer', async () => {
const s = await scene();
await links.upsertLink(s.trip.id, s.journal.id, s.owner.id, 1);
await links.replaceMembers(
s.trip.id,
[
{ user_id: s.lena.id, role: 'viewer' },
{ user_id: s.tom.id, role: 'editor' },
],
1,
);
await addJourneyContributor(t, s.journal.id, s.lena.id, 'editor');
await addJourneyContributor(t, s.journal.id, s.tom.id, 'viewer');
expect(await contributors.findAccessRole(s.journal.id, s.lena.id)).toBe('editor');
expect(await contributors.findAccessRole(s.journal.id, s.tom.id)).toBe('editor');
expect(await contributors.hasAccess(s.journal.id, s.lena.id)).toBe(true);
expect(await contributors.hasAccess(s.journal.id, s.guest.id)).toBe(false);
});
it('TJLREPO-007: unlinking takes the members along, and leaving the trip takes the leaver off', async () => {
const s = await scene();
await links.upsertLink(s.trip.id, s.journal.id, s.owner.id, 1);
await links.replaceMembers(
s.trip.id,
[
{ user_id: s.lena.id, role: 'viewer' },
{ user_id: s.tom.id, role: 'viewer' },
],
1,
);
await roster.leaveJournalLink(s.trip.id, s.lena.id);
expect((await links.listMembers(s.trip.id)).map((m) => m.username)).toEqual(['Tom']);
await links.deleteForTrip(s.trip.id);
expect(await links.findForTrip(s.trip.id)).toBeNull();
expect(await links.listMembers(s.trip.id)).toEqual([]);
});
it('TJLREPO-008: the journal lists who the links let in, per trip, apart from its contributors', async () => {
const s = await scene();
await links.upsertLink(s.trip.id, s.journal.id, s.owner.id, 1);
await links.replaceMembers(
s.trip.id,
[
{ user_id: s.tom.id, role: 'editor' },
{ user_id: s.lena.id, role: 'viewer' },
],
1,
);
await addJourneyContributor(t, s.journal.id, s.tom.id, 'viewer');
const access = await contributors.listTripAccessForJourney(s.journal.id);
expect(access.map((a) => [a.username, a.role, a.trip_id])).toEqual([['Lena', 'viewer', s.trip.id]]);
expect(access[0]?.trip_title).toBe(s.trip.title);
});
});
@@ -655,7 +655,7 @@ describe('canAccessTrekPhoto — MA5 parity (TripsRepository.findAccessible rewr
// ---------------------------------------------------------------------------
// MA1/MA2/MA6 — the journey half of the cross-domain photo-access checks
// (Plan 3g Task 4), onto `JourneysRepository.isOwnedByUser`/
// `JourneyContributorsRepository.existsForUser` via the private
// `JourneyContributorsRepository.hasAccess` via the private
// `ownerOrContributor` helper. MEM-ACCESS-006/006b/006c/014/014b above
// already cover owner (006b), editor (006/014) and viewer (006c/014b)
// contributors passing, and MEM-ACCESS-007/008/016 cover a stranger/no-link
@@ -665,9 +665,9 @@ describe('canAccessTrekPhoto — MA5 parity (TripsRepository.findAccessible rewr
// Mutation proof (performed once by hand, not run in CI): editing
// `MemoriesAccessService.ownerOrContributor` from
// `if (await this.journeys.isOwnedByUser(...)) return true; return await
// this.journeyContributors.existsForUser(...);`
// this.journeyContributors.hasAccess(...);`
// to an AND (`return (await this.journeys.isOwnedByUser(...)) &&
// (await this.journeyContributors.existsForUser(...));`) — collapsing the
// (await this.journeyContributors.hasAccess(...));`) — collapsing the
// owner-OR-contributor union into an owner-AND-contributor intersection —
// turns MEM-ACCESS-006 (an editor who is NOT also the owner) and
// MEM-ACCESS-006c (a viewer who is NOT also the owner) red: both contributors
+3
View File
@@ -241,6 +241,9 @@ const journey: TranslationStrings = {
'journey.contributors.removeConfirm': 'Remove {username} from this journey?', // en-fallback
'journey.contributors.removed': 'Contributor removed', // en-fallback
'journey.contributors.removeFailed': 'Failed to remove contributor', // en-fallback
'journey.contributors.viaTrips': 'عبر الرحلات',
'journey.contributors.viaTripsHint': 'يمكنهم فتح هذه اليوميات لأن رحلة ترتبط بها. غيّر ذلك من إعدادات الرحلة.',
'journey.contributors.viaTrip': 'عبر {trip}',
'journey.share.publicShare': 'Public Share', // en-fallback
'journey.share.createLink': 'Create share link', // en-fallback
'journey.share.linkCreated': 'Share link created', // en-fallback
-1
View File
@@ -1,7 +1,6 @@
import type { TranslationStrings } from '../types';
const members: TranslationStrings = {
'members.shareTrip': 'مشاركة الرحلة',
'members.inviteUser': 'دعوة مستخدم',
'members.selectUser': 'اختر مستخدمًا…',
'members.invite': 'دعوة',
-1
View File
@@ -2,7 +2,6 @@ import type { TranslationStrings } from '../types';
const nav: TranslationStrings = {
'nav.trip': 'الرحلة',
'nav.share': 'مشاركة',
'nav.settings': 'الإعدادات',
'nav.admin': 'الإدارة',
'nav.logout': 'تسجيل الخروج',
+2
View File
@@ -32,6 +32,7 @@ const perm: TranslationStrings = {
'perm.action.collab_edit': 'التعاون (ملاحظات، استطلاعات، دردشة)',
'perm.action.share_manage': 'إدارة روابط المشاركة',
'perm.actionHint.trip_create': 'من يمكنه إنشاء رحلات جديدة',
'perm.action.journal_link': 'ربط يوميات',
'perm.actionHint.trip_edit': 'من يمكنه تغيير اسم الرحلة والتواريخ والوصف والعملة',
'perm.actionHint.trip_delete': 'من يمكنه حذف رحلة نهائياً',
'perm.actionHint.trip_archive': 'من يمكنه أرشفة أو إلغاء أرشفة رحلة',
@@ -47,5 +48,6 @@ const perm: TranslationStrings = {
'perm.actionHint.packing_edit': 'من يمكنه إدارة عناصر التعبئة والحقائب',
'perm.actionHint.collab_edit': 'من يمكنه إنشاء ملاحظات واستطلاعات وإرسال رسائل',
'perm.actionHint.share_manage': 'من يمكنه إنشاء أو حذف روابط المشاركة العامة',
'perm.actionHint.journal_link': 'من يمكنه ربط يومية من Journey بالرحلة وفتحها لأعضائها',
};
export default perm;
+39
View File
@@ -119,5 +119,44 @@ const trip: TranslationStrings = {
'سيؤدي ذلك أيضًا إلى حذف الإقامة المحجوزة في "{name}" والحجز "{booking}" وأي مصروف مرتبط به.',
'trip.confirm.deletePlaceBookedSame':
'سيؤدي ذلك أيضًا إلى حذف الإقامة المحجوزة في "{name}" وحجزها وأي مصروف مرتبط به.',
'trip.settings.title': 'إعدادات الرحلة',
'trip.settings.people': 'الأشخاص',
'trip.settings.peopleHint': 'من المشاركون في هذه الرحلة، والضيوف بلا حساب.',
'trip.settings.sharing': 'المشاركة',
'trip.settings.sharingHint': 'رابط للقراءة فقط لأي شخص، ورابط دعوة للانضمام.',
'trip.tabs.journal': 'اليوميات',
'trip.journal.title': 'اليوميات',
'trip.journal.hint': 'اربط إحدى يومياتك بهذه الرحلة. من تمنحه الوصول يفتحها هنا مباشرة في تبويب اليوميات، بشكل حي.',
'trip.journal.link': 'ربط يوميات',
'trip.journal.linkSubmit': 'ربط اليوميات',
'trip.journal.change': 'تغيير اليوميات',
'trip.journal.editMembers': 'تعديل الوصول',
'trip.journal.unlink': 'فك الربط',
'trip.journal.unlinkTitle': 'فك ربط هذه اليوميات؟',
'trip.journal.unlinkMessage':
'يختفي تبويب اليوميات من هذه الرحلة. يفقد الأعضاء الذين كانوا يفتحون اليوميات عبر هذه الرحلة فقط وصولهم؛ أما من أضيفوا في اليوميات نفسها فيحتفظون به.',
'trip.journal.linkedBadge': 'مرتبطة',
'trip.journal.yourRole.owner': 'يومياتك',
'trip.journal.yourRole.editor': 'يمكنك التعديل',
'trip.journal.yourRole.viewer': 'يمكنك القراءة',
'trip.journal.onlyYou': 'لا يملك أحد غيرك من هذه الرحلة وصولًا بعد.',
'trip.journal.noJournals': 'ليست لديك يوميات بعد. أنشئ واحدة أولًا في Journey.',
'trip.journal.pickPlaceholder': 'اختر يوميات',
'trip.journal.noMembers': 'لا يوجد أحد غيرك في هذه الرحلة بعد.',
'trip.journal.members': 'وصول أعضاء الرحلة',
'trip.journal.allRead': 'الجميع يقرأ',
'trip.journal.allNone': 'لا أحد',
'trip.journal.roleNone': 'بلا وصول',
'trip.journal.roleViewer': 'قراءة',
'trip.journal.roleEditor': 'تعديل',
'trip.journal.hasAccess': 'لديه وصول',
'trip.journal.warnNew':
'سيحصل {names} على وصول إلى هذه اليوميات لأول مرة، ومن ثم سيراها أيضًا في قائمة يومياته الخاصة.',
'trip.journal.errorOffline': 'يتطلب ربط اليوميات اتصالًا بالإنترنت.',
'trip.journal.errorForbidden': 'غير مسموح لك بذلك.',
'trip.journal.errorGeneric': 'لم ينجح ذلك. حاول مرة أخرى.',
'trip.journal.linked': 'تم ربط اليوميات',
'trip.journal.membersSaved': 'تم حفظ الوصول',
'trip.journal.unlinked': 'تم فك ربط اليوميات',
};
export default trip;
+4
View File
@@ -206,6 +206,10 @@ const journey: TranslationStrings = {
'journey.contributors.removeConfirm': '{username} bu səyahət gündəliyindən çıxarılsın?',
'journey.contributors.removed': 'İştirakçı çıxarıldı',
'journey.contributors.removeFailed': 'İştirakçını çıxarmaq mümkün olmadı',
'journey.contributors.viaTrips': 'Səyahətlər vasitəsilə',
'journey.contributors.viaTripsHint':
'Bu gündəliyi aça bilirlər, çünki bir səyahət onu bağlayır. Bunu səyahətin parametrlərində dəyişin.',
'journey.contributors.viaTrip': '{trip} vasitəsilə',
'journey.share.publicShare': 'İctimai paylaşım',
'journey.share.createLink': 'Paylaşım linki yarat',
-1
View File
@@ -1,7 +1,6 @@
import type { TranslationStrings } from '../types';
const members: TranslationStrings = {
'members.shareTrip': 'Səyahəti paylaş',
'members.inviteUser': 'İstifadəçi dəvət et',
'members.selectUser': 'İstifadəçi seçin…',
'members.invite': 'Dəvət et',
-1
View File
@@ -2,7 +2,6 @@ import type { TranslationStrings } from '../types';
const nav: TranslationStrings = {
'nav.trip': 'Səyahət',
'nav.share': 'Paylaş',
'nav.settings': 'Tənzimləmələr',
'nav.admin': 'Admin',
'nav.logout': 'Çıxış et',
+2
View File
@@ -32,6 +32,7 @@ const perm: TranslationStrings = {
'perm.action.collab_edit': 'Əməkdaşlıq (qeydlər, sorğular və çat)',
'perm.action.share_manage': 'Paylaşma keçidlərini idarə etmək',
'perm.actionHint.trip_create': 'Yeni səyahətləri kimin yarada biləcəyi',
'perm.action.journal_link': 'Jurnal bağlamaq',
'perm.actionHint.trip_edit': 'Səyahətin adını, tarixlərini, təsvirini və valyutasını kimin dəyişə biləcəyi',
'perm.actionHint.trip_delete': 'Səyahəti kimin həmişəlik silə biləcəyi',
'perm.actionHint.trip_archive': 'Səyahəti kimin arxivləşdirə və ya arxivdən çıxara biləcəyi',
@@ -47,6 +48,7 @@ const perm: TranslationStrings = {
'perm.actionHint.packing_edit': 'Baqaj elementlərini və çantaları kimin idarə edə biləcəyi',
'perm.actionHint.collab_edit': 'Qeydləri və sorğuları kimin yarada və mesajları kimin göndərə biləcəyi',
'perm.actionHint.share_manage': 'Açıq paylaşma keçidlərini kimin yarada və ya silə biləcəyi',
'perm.actionHint.journal_link': 'Kim Journey-dən jurnalı səfərə bağlaya və onu üzvlərinə aça bilər',
};
export default perm;
+40
View File
@@ -110,6 +110,46 @@ const trip: TranslationStrings = {
'Bu, həmçinin “{name}” məkanında rezervasiya edilmiş qalmanı, “{booking}” rezervasiyasını və onunla əlaqəli bütün xərcləri silir.',
'trip.confirm.deletePlaceBookedSame':
'Bu, həmçinin “{name}” məkanında rezervasiya edilmiş qalmanı, onun rezervasiyasını və əlaqəli bütün xərcləri silir.',
'trip.settings.title': 'Səyahət parametrləri',
'trip.settings.people': 'İnsanlar',
'trip.settings.peopleHint': 'Bu səyahətdə kimlərin olduğu və hesabı olmayan qonaqlar.',
'trip.settings.sharing': 'Paylaşma',
'trip.settings.sharingHint': 'Hər kəs üçün yalnız oxunan keçid və qoşulmaq üçün dəvət keçidi.',
'trip.tabs.journal': 'Gündəlik',
'trip.journal.title': 'Gündəlik',
'trip.journal.hint':
'Gündəliklərinizdən birini bu səyahətə bağlayın. Giriş verdiyiniz şəxslər onu burada, Gündəlik bölməsində canlı açır.',
'trip.journal.link': 'Gündəlik bağla',
'trip.journal.linkSubmit': 'Gündəliyi bağla',
'trip.journal.change': 'Gündəliyi dəyiş',
'trip.journal.editMembers': 'Girişi redaktə et',
'trip.journal.unlink': 'Ayır',
'trip.journal.unlinkTitle': 'Bu gündəlik ayrılsın?',
'trip.journal.unlinkMessage':
'Gündəlik bölməsi bu səyahətdən yox olur. Gündəliyi yalnız bu səyahət vasitəsilə aça bilən üzvlər girişi itirir; gündəliyin özündə əlavə olunanlar onu saxlayır.',
'trip.journal.linkedBadge': 'Bağlıdır',
'trip.journal.yourRole.owner': 'Sizin gündəliyiniz',
'trip.journal.yourRole.editor': 'Redaktə edə bilərsiniz',
'trip.journal.yourRole.viewer': 'Oxuya bilərsiniz',
'trip.journal.onlyYou': 'Bu səyahətdən hələ heç kimin girişi yoxdur.',
'trip.journal.noJournals': 'Hələ gündəliyiniz yoxdur. Əvvəlcə Səyahət gündəliyində yaradın.',
'trip.journal.pickPlaceholder': 'Gündəlik seçin',
'trip.journal.noMembers': 'Bu səyahətdə hələ başqa heç kim yoxdur.',
'trip.journal.members': 'Səyahət üzvləri üçün giriş',
'trip.journal.allRead': 'Hamı oxuya bilər',
'trip.journal.allNone': 'Heç kim',
'trip.journal.roleNone': 'Giriş yoxdur',
'trip.journal.roleViewer': 'Oxumaq',
'trip.journal.roleEditor': 'Redaktə',
'trip.journal.hasAccess': 'Girişi var',
'trip.journal.warnNew':
'{names} bu gündəliyə ilk dəfə giriş əldə edəcək və bundan sonra onu öz gündəlik siyahısında da görəcək.',
'trip.journal.errorOffline': 'Gündəliyi bağlamaq üçün bağlantı lazımdır.',
'trip.journal.errorForbidden': 'Bunu etməyə icazəniz yoxdur.',
'trip.journal.errorGeneric': 'Alınmadı. Yenidən cəhd edin.',
'trip.journal.linked': 'Gündəlik bağlandı',
'trip.journal.membersSaved': 'Giriş yadda saxlanıldı',
'trip.journal.unlinked': 'Gündəlik ayrıldı',
};
export default trip;
+4
View File
@@ -265,6 +265,10 @@ const journey: TranslationStrings = {
'journey.contributors.removeConfirm': 'Remove {username} from this journey?', // en-fallback
'journey.contributors.removed': 'Contributor removed', // en-fallback
'journey.contributors.removeFailed': 'Failed to remove contributor', // en-fallback
'journey.contributors.viaTrips': 'Por meio de viagens',
'journey.contributors.viaTripsHint':
'Podem abrir este diário porque uma viagem o vincula. Altere isso nas configurações da viagem.',
'journey.contributors.viaTrip': 'por {trip}',
'journey.editor.externalPhotos': 'External photos', // en-fallback
'journey.editor.externalPhotosFor': 'Photos for {date}', // en-fallback
'journey.editor.externalPhotosNearby': 'Nearby photos first', // en-fallback
-1
View File
@@ -1,7 +1,6 @@
import type { TranslationStrings } from '../types';
const members: TranslationStrings = {
'members.shareTrip': 'Compartilhar viagem',
'members.inviteUser': 'Convidar usuário',
'members.selectUser': 'Selecionar usuário…',
'members.invite': 'Convidar',
-1
View File
@@ -2,7 +2,6 @@ import type { TranslationStrings } from '../types';
const nav: TranslationStrings = {
'nav.trip': 'Viagem',
'nav.share': 'Compartilhar',
'nav.settings': 'Configurações',
'nav.admin': 'Admin',
'nav.logout': 'Sair',
+2
View File
@@ -32,6 +32,7 @@ const perm: TranslationStrings = {
'perm.action.collab_edit': 'Colaboração (notas, enquetes, chat)',
'perm.action.share_manage': 'Gerenciar links de compartilhamento',
'perm.actionHint.trip_create': 'Quem pode criar novas viagens',
'perm.action.journal_link': 'Vincular diário',
'perm.actionHint.trip_edit': 'Quem pode alterar nome, datas, descrição e moeda da viagem',
'perm.actionHint.trip_delete': 'Quem pode excluir permanentemente uma viagem',
'perm.actionHint.trip_archive': 'Quem pode arquivar ou desarquivar uma viagem',
@@ -47,5 +48,6 @@ const perm: TranslationStrings = {
'perm.actionHint.packing_edit': 'Quem pode gerenciar itens de bagagem e malas',
'perm.actionHint.collab_edit': 'Quem pode criar notas, enquetes e enviar mensagens',
'perm.actionHint.share_manage': 'Quem pode criar ou excluir links de compartilhamento públicos',
'perm.actionHint.journal_link': 'Quem pode vincular um diário do Journey à viagem e abri-lo aos membros',
};
export default perm;
+40
View File
@@ -99,5 +99,45 @@ const trip: TranslationStrings = {
'Isso também exclui a estadia reservada em “{name}”, a reserva “{booking}” e qualquer despesa vinculada a ela.',
'trip.confirm.deletePlaceBookedSame':
'Isso também exclui a estadia reservada em “{name}”, a reserva dela e qualquer despesa vinculada.',
'trip.settings.title': 'Configurações da viagem',
'trip.settings.people': 'Pessoas',
'trip.settings.peopleHint': 'Quem está nesta viagem e os convidados sem conta.',
'trip.settings.sharing': 'Compartilhar',
'trip.settings.sharingHint': 'Um link somente leitura para qualquer pessoa e um link de convite para entrar.',
'trip.tabs.journal': 'Diário',
'trip.journal.title': 'Diário',
'trip.journal.hint':
'Vincule um dos seus diários a esta viagem. Quem tiver acesso abre ele aqui mesmo na aba Diário, ao vivo.',
'trip.journal.link': 'Vincular um diário',
'trip.journal.linkSubmit': 'Vincular diário',
'trip.journal.change': 'Trocar diário',
'trip.journal.editMembers': 'Editar acesso',
'trip.journal.unlink': 'Desvincular',
'trip.journal.unlinkTitle': 'Desvincular este diário?',
'trip.journal.unlinkMessage':
'A aba Diário some desta viagem. Membros que só conseguiam abrir o diário por esta viagem perdem o acesso; quem foi adicionado no próprio diário continua com ele.',
'trip.journal.linkedBadge': 'Vinculado',
'trip.journal.yourRole.owner': 'Seu diário',
'trip.journal.yourRole.editor': 'Você pode editar',
'trip.journal.yourRole.viewer': 'Você pode ler',
'trip.journal.onlyYou': 'Ninguém mais desta viagem tem acesso ainda.',
'trip.journal.noJournals': 'Você ainda não tem nenhum diário. Crie um primeiro em Jornada.',
'trip.journal.pickPlaceholder': 'Escolha um diário',
'trip.journal.noMembers': 'Ainda não há mais ninguém nesta viagem.',
'trip.journal.members': 'Acesso para membros da viagem',
'trip.journal.allRead': 'Todos podem ler',
'trip.journal.allNone': 'Ninguém',
'trip.journal.roleNone': 'Sem acesso',
'trip.journal.roleViewer': 'Ler',
'trip.journal.roleEditor': 'Editar',
'trip.journal.hasAccess': 'Tem acesso',
'trip.journal.warnNew':
'{names} vai ganhar acesso a este diário pela primeira vez e a partir de então também vê-lo na própria lista de diários.',
'trip.journal.errorOffline': 'Para vincular um diário é preciso conexão.',
'trip.journal.errorForbidden': 'Você não tem permissão para isso.',
'trip.journal.errorGeneric': 'Não deu certo. Tente novamente.',
'trip.journal.linked': 'Diário vinculado',
'trip.journal.membersSaved': 'Acesso salvo',
'trip.journal.unlinked': 'Diário desvinculado',
};
export default trip;
+4
View File
@@ -265,6 +265,10 @@ const journey: TranslationStrings = {
'journey.contributors.removeConfirm': "Vols eliminar {username} d'aquesta travesia?",
'journey.contributors.removed': 'Col·laborador eliminat',
'journey.contributors.removeFailed': "No s'ha pogut eliminar el col·laborador",
'journey.contributors.viaTrips': 'A través de viatges',
'journey.contributors.viaTripsHint':
'Poden obrir aquest diari perquè un viatge el vincula. Canvia-ho a la configuració del viatge.',
'journey.contributors.viaTrip': 'a través de {trip}',
'journey.editor.externalPhotos': 'External photos', // en-fallback
'journey.editor.externalPhotosFor': 'Photos for {date}', // en-fallback
'journey.editor.externalPhotosNearby': 'Nearby photos first', // en-fallback
-1
View File
@@ -1,7 +1,6 @@
import type { TranslationStrings } from '../types';
const members: TranslationStrings = {
'members.shareTrip': 'Comparteix el viatge',
'members.inviteUser': 'Convida un usuari',
'members.selectUser': 'Selecciona un usuari…',
'members.invite': 'Convida',
-1
View File
@@ -2,7 +2,6 @@ import type { TranslationStrings } from '../types';
const nav: TranslationStrings = {
'nav.trip': 'Viatge',
'nav.share': 'Comparteix',
'nav.settings': 'Configuració',
'nav.admin': 'Administració',
'nav.logout': 'Tanca la sessió',
+2
View File
@@ -32,6 +32,7 @@ const perm: TranslationStrings = {
'perm.action.collab_edit': 'Col·laboració (notes, enquestes, xat)',
'perm.action.share_manage': 'Gestiona els enllaços compartits',
'perm.actionHint.trip_create': 'Permet crear viatges nous',
'perm.action.journal_link': 'Enllaçar un diari',
'perm.actionHint.trip_edit': 'Permet canviar el nom, les dates, la descripció i la moneda del viatge',
'perm.actionHint.trip_delete': 'Permet eliminar permanentment un viatge',
'perm.actionHint.trip_archive': "Permet arxivar o treure de l'arxiu un viatge",
@@ -47,5 +48,6 @@ const perm: TranslationStrings = {
'perm.actionHint.packing_edit': "Permet gestionar els elements d'equipatge i les bosses",
'perm.actionHint.collab_edit': 'Permet crear notes, enquestes i enviar missatges',
'perm.actionHint.share_manage': 'Permet crear o eliminar enllaços públics compartits',
'perm.actionHint.journal_link': 'Qui pot enllaçar un diari de Journey al viatge i obrir-lo als seus membres',
};
export default perm;
+40
View File
@@ -99,5 +99,45 @@ const trip: TranslationStrings = {
'També s’eliminen l’estada reservada a «{name}», la reserva «{booking}» i qualsevol despesa vinculada.',
'trip.confirm.deletePlaceBookedSame':
'També s’eliminen l’estada reservada a «{name}», la seva reserva i qualsevol despesa vinculada.',
'trip.settings.title': 'Configuració del viatge',
'trip.settings.people': 'Persones',
'trip.settings.peopleHint': 'Qui és en aquest viatge i els convidats sense compte.',
'trip.settings.sharing': 'Compartir',
'trip.settings.sharingHint': "Un enllaç de només lectura per a tothom i un enllaç d'invitació per unir-se.",
'trip.tabs.journal': 'Diari',
'trip.journal.title': 'Diari',
'trip.journal.hint':
"Vincula un dels teus diaris a aquest viatge. Qui hi tingui accés l'obre aquí mateix a la pestanya Diari, en directe.",
'trip.journal.link': 'Vincula un diari',
'trip.journal.linkSubmit': 'Vincula el diari',
'trip.journal.change': 'Canvia el diari',
'trip.journal.editMembers': "Edita l'accés",
'trip.journal.unlink': 'Desvincula',
'trip.journal.unlinkTitle': 'Vols desvincular aquest diari?',
'trip.journal.unlinkMessage':
"La pestanya Diari desapareix d'aquest viatge. Els membres que només podien obrir el diari a través d'aquest viatge en perden l'accés; les persones afegides al mateix diari el conserven.",
'trip.journal.linkedBadge': 'Vinculat',
'trip.journal.yourRole.owner': 'El teu diari',
'trip.journal.yourRole.editor': 'Pots editar',
'trip.journal.yourRole.viewer': 'Pots llegir',
'trip.journal.onlyYou': "Ningú més d'aquest viatge hi té accés encara.",
'trip.journal.noJournals': 'Encara no tens cap diari. Crea-ne un primer a Travesia.',
'trip.journal.pickPlaceholder': 'Tria un diari',
'trip.journal.noMembers': 'Encara no hi ha ningú més en aquest viatge.',
'trip.journal.members': 'Accés per als membres del viatge',
'trip.journal.allRead': 'Tothom pot llegir',
'trip.journal.allNone': 'Ningú',
'trip.journal.roleNone': 'Sense accés',
'trip.journal.roleViewer': 'Llegir',
'trip.journal.roleEditor': 'Editar',
'trip.journal.hasAccess': 'Té accés',
'trip.journal.warnNew':
"{names} tindrà accés a aquest diari per primera vegada i a partir d'ara també el veurà a la seva pròpia llista de diaris.",
'trip.journal.errorOffline': 'Per vincular un diari cal connexió.',
'trip.journal.errorForbidden': 'No tens permís per fer això.',
'trip.journal.errorGeneric': 'No ha funcionat. Torna-ho a provar.',
'trip.journal.linked': 'Diari vinculat',
'trip.journal.membersSaved': 'Accés desat',
'trip.journal.unlinked': 'Diari desvinculat',
};
export default trip;
+4
View File
@@ -270,6 +270,10 @@ const journey: TranslationStrings = {
'journey.contributors.removeConfirm': 'Remove {username} from this journey?', // en-fallback
'journey.contributors.removed': 'Contributor removed', // en-fallback
'journey.contributors.removeFailed': 'Failed to remove contributor', // en-fallback
'journey.contributors.viaTrips': 'Přes cesty',
'journey.contributors.viaTripsHint':
'Mohou tento deník otevřít, protože ho cesta propojuje. Změňte to v nastavení cesty.',
'journey.contributors.viaTrip': 'přes {trip}',
'journey.editor.externalPhotos': 'External photos', // en-fallback
'journey.editor.externalPhotosFor': 'Photos for {date}', // en-fallback
'journey.editor.externalPhotosNearby': 'Nearby photos first', // en-fallback
-1
View File
@@ -1,7 +1,6 @@
import type { TranslationStrings } from '../types';
const members: TranslationStrings = {
'members.shareTrip': 'Sdílet cestu',
'members.inviteUser': 'Pozvat uživatele',
'members.selectUser': 'Vyberte uživatele…',
'members.invite': 'Pozvat',

Some files were not shown because too many files have changed in this diff Show More