From fe05afec1928dddc0d5bea1233742ffa1ea0b510 Mon Sep 17 00:00:00 2001 From: Maurice Date: Sun, 18 Oct 2026 17:41:25 +0200 Subject: [PATCH] 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. --- client/src/api/tripJournal.ts | 31 ++ client/src/api/wsEventPolicy.ts | 4 + client/src/components/Layout/Navbar.test.tsx | 32 +- client/src/components/Layout/Navbar.tsx | 34 +- .../components/Trips/LinkedJournalIcon.tsx | 37 ++ .../Trips/TripJournalLinkSection.test.tsx | 150 ++++++++ .../Trips/TripJournalLinkSection.tsx | 324 ++++++++++++++++++ .../Trips/TripMembersModal.test.tsx | 8 +- .../src/components/Trips/TripMembersModal.tsx | 5 +- .../src/components/Trips/TripShareDialog.tsx | 93 ++++- .../Trips/useTripJournalLink.test.ts | 290 ++++++++++++++++ .../components/Trips/useTripJournalLink.ts | 228 ++++++++++++ client/src/constants/tripTabs.ts | 2 + client/src/index.css | 8 + client/src/mobile/mobile.css | 4 + .../mobile/screens/journey/MJourneyDetail.tsx | 34 +- .../mobile/screens/trip/sheets/MMehrSheet.tsx | 5 +- .../screens/trip/tabs/MTripTabPanel.tsx | 9 + client/src/pages/JourneyDetailPage.tsx | 61 ++-- client/src/pages/TripPlannerPage.tsx | 8 +- .../pages/journeyDetail/useJourneyDetail.ts | 33 +- .../pages/tripPlanner/TripJournalPanel.tsx | 16 + client/src/pages/tripPlanner/plannerLazy.ts | 2 + .../src/pages/tripPlanner/useTripPlanner.ts | 11 +- client/src/repo/tripJournalLinkRepo.test.ts | 106 ++++++ client/src/repo/tripJournalLinkRepo.ts | 70 ++++ client/src/store/tripJournalLinkStore.test.ts | 81 +++++ client/src/store/tripJournalLinkStore.ts | 49 +++ shared/src/i18n/ar/members.ts | 1 - shared/src/i18n/ar/nav.ts | 1 - shared/src/i18n/ar/trip.ts | 39 +++ shared/src/i18n/az/members.ts | 1 - shared/src/i18n/az/nav.ts | 1 - shared/src/i18n/az/trip.ts | 40 +++ shared/src/i18n/br/members.ts | 1 - shared/src/i18n/br/nav.ts | 1 - shared/src/i18n/br/trip.ts | 40 +++ shared/src/i18n/ca/members.ts | 1 - shared/src/i18n/ca/nav.ts | 1 - shared/src/i18n/ca/trip.ts | 40 +++ shared/src/i18n/cs/members.ts | 1 - shared/src/i18n/cs/nav.ts | 1 - shared/src/i18n/cs/trip.ts | 40 +++ shared/src/i18n/de/members.ts | 1 - shared/src/i18n/de/nav.ts | 1 - shared/src/i18n/de/trip.ts | 40 +++ shared/src/i18n/en/members.ts | 1 - shared/src/i18n/en/nav.ts | 1 - shared/src/i18n/en/trip.ts | 40 +++ shared/src/i18n/es/members.ts | 1 - shared/src/i18n/es/nav.ts | 1 - shared/src/i18n/es/trip.ts | 40 +++ shared/src/i18n/et/members.ts | 1 - shared/src/i18n/et/nav.ts | 1 - shared/src/i18n/et/trip.ts | 40 +++ shared/src/i18n/fr/members.ts | 1 - shared/src/i18n/fr/nav.ts | 1 - shared/src/i18n/fr/trip.ts | 40 +++ shared/src/i18n/gr/members.ts | 1 - shared/src/i18n/gr/nav.ts | 1 - shared/src/i18n/gr/trip.ts | 41 +++ shared/src/i18n/hu/members.ts | 1 - shared/src/i18n/hu/nav.ts | 1 - shared/src/i18n/hu/trip.ts | 40 +++ shared/src/i18n/id/members.ts | 1 - shared/src/i18n/id/nav.ts | 1 - shared/src/i18n/id/trip.ts | 40 +++ shared/src/i18n/it/members.ts | 1 - shared/src/i18n/it/nav.ts | 1 - shared/src/i18n/it/trip.ts | 40 +++ shared/src/i18n/ja/members.ts | 1 - shared/src/i18n/ja/nav.ts | 1 - shared/src/i18n/ja/trip.ts | 40 +++ shared/src/i18n/ko/members.ts | 1 - shared/src/i18n/ko/nav.ts | 1 - shared/src/i18n/ko/trip.ts | 39 +++ shared/src/i18n/nl/members.ts | 1 - shared/src/i18n/nl/nav.ts | 1 - shared/src/i18n/nl/trip.ts | 40 +++ shared/src/i18n/pl/members.ts | 1 - shared/src/i18n/pl/nav.ts | 1 - shared/src/i18n/pl/trip.ts | 40 +++ shared/src/i18n/ru/members.ts | 1 - shared/src/i18n/ru/nav.ts | 1 - shared/src/i18n/ru/trip.ts | 40 +++ shared/src/i18n/sk/members.ts | 1 - shared/src/i18n/sk/nav.ts | 1 - shared/src/i18n/sk/trip.ts | 40 +++ shared/src/i18n/sv/members.ts | 1 - shared/src/i18n/sv/nav.ts | 1 - shared/src/i18n/sv/trip.ts | 40 +++ shared/src/i18n/th/members.ts | 1 - shared/src/i18n/th/nav.ts | 1 - shared/src/i18n/th/trip.ts | 39 +++ shared/src/i18n/tr/members.ts | 1 - shared/src/i18n/tr/nav.ts | 1 - shared/src/i18n/tr/trip.ts | 40 +++ shared/src/i18n/uk/members.ts | 1 - shared/src/i18n/uk/nav.ts | 1 - shared/src/i18n/uk/trip.ts | 40 +++ shared/src/i18n/vi/members.ts | 1 - shared/src/i18n/vi/nav.ts | 1 - shared/src/i18n/vi/trip.ts | 40 +++ shared/src/i18n/zh-TW/members.ts | 1 - shared/src/i18n/zh-TW/nav.ts | 1 - shared/src/i18n/zh-TW/trip.ts | 38 ++ shared/src/i18n/zh/members.ts | 1 - shared/src/i18n/zh/nav.ts | 1 - shared/src/i18n/zh/trip.ts | 38 ++ 109 files changed, 2707 insertions(+), 156 deletions(-) create mode 100644 client/src/api/tripJournal.ts create mode 100644 client/src/components/Trips/LinkedJournalIcon.tsx create mode 100644 client/src/components/Trips/TripJournalLinkSection.test.tsx create mode 100644 client/src/components/Trips/TripJournalLinkSection.tsx create mode 100644 client/src/components/Trips/useTripJournalLink.test.ts create mode 100644 client/src/components/Trips/useTripJournalLink.ts create mode 100644 client/src/pages/tripPlanner/TripJournalPanel.tsx create mode 100644 client/src/repo/tripJournalLinkRepo.test.ts create mode 100644 client/src/repo/tripJournalLinkRepo.ts create mode 100644 client/src/store/tripJournalLinkStore.test.ts create mode 100644 client/src/store/tripJournalLinkStore.ts diff --git a/client/src/api/tripJournal.ts b/client/src/api/tripJournal.ts new file mode 100644 index 000000000..3b789fbe4 --- /dev/null +++ b/client/src/api/tripJournal.ts @@ -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 => + apiClient.get(`/trips/${tripId}/journal`).then((r) => r.data), + candidates: (tripId: number, journeyId: number): Promise => + apiClient.get(`/trips/${tripId}/journal/candidates`, { params: { journey_id: journeyId } }).then((r) => r.data), + link: ( + tripId: number, + journeyId: number, + members: TripJournalLinkMemberRequest[] + ): Promise => + apiClient.put(`/trips/${tripId}/journal`, { journey_id: journeyId, members }).then((r) => r.data), + setMembers: (tripId: number, members: TripJournalLinkMemberRequest[]): Promise => + apiClient.patch(`/trips/${tripId}/journal/members`, { members }).then((r) => r.data), + unlink: (tripId: number): Promise => + 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), +}; diff --git a/client/src/api/wsEventPolicy.ts b/client/src/api/wsEventPolicy.ts index 162aeeb77..b7c510062 100644 --- a/client/src/api/wsEventPolicy.ts +++ b/client/src/api/wsEventPolicy.ts @@ -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', diff --git a/client/src/components/Layout/Navbar.test.tsx b/client/src/components/Layout/Navbar.test.tsx index 40ec66be4..93c09c2c7 100644 --- a/client/src/components/Layout/Navbar.test.tsx +++ b/client/src/components/Layout/Navbar.test.tsx @@ -169,23 +169,24 @@ describe('Navbar', () => { expect(adminLink).toHaveAttribute('href', '/admin'); }); - it('FE-COMP-NAVBAR-019: share button rendered when onShare prop provided', () => { - render(); - 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(); + 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(); - const shareBtn = screen.getByRole('button', { name: /share/i }); - await user.click(shareBtn); - expect(onShare).toHaveBeenCalled(); + const onSettings = vi.fn(); + render(); + 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(); + 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(); + it('FE-W5NAV-009: the back and theme buttons reset their hover background', () => { + render(); 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); diff --git a/client/src/components/Layout/Navbar.tsx b/client/src/components/Layout/Navbar.tsx index 87f13786c..ac68d7861 100644 --- a/client/src/components/Layout/Navbar.tsx +++ b/client/src/components/Layout/Navbar.tsx @@ -6,7 +6,8 @@ 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 { Tooltip } from '../shared/Tooltip' import type { LucideIcon } from 'lucide-react' import InAppNotificationBell from './InAppNotificationBell.tsx' import HelpButton from '../Help/HelpButton' @@ -21,7 +22,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 +34,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 +175,21 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }: {tripTitle} ))} + + {/* 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 && ( + + + + )} {/* Centred liquid-glass tab menu (design handoff). @@ -245,17 +262,6 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }: itself on top of them. */}
- {/* Share button */} - {onShare && ( - - )} - {/* Contextual help for the current screen — same footprint as the theme toggle beside it */} {user && } diff --git a/client/src/components/Trips/LinkedJournalIcon.tsx b/client/src/components/Trips/LinkedJournalIcon.tsx new file mode 100644 index 000000000..da49313b6 --- /dev/null +++ b/client/src/components/Trips/LinkedJournalIcon.tsx @@ -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. + + + + + + + ); +} diff --git a/client/src/components/Trips/TripJournalLinkSection.test.tsx b/client/src/components/Trips/TripJournalLinkSection.test.tsx new file mode 100644 index 000000000..2b1114e60 --- /dev/null +++ b/client/src/components/Trips/TripJournalLinkSection.test.tsx @@ -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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + const root = container.firstElementChild as HTMLElement; + expect(root.getAttribute('dir')).toBe('ltr'); + expect(root.style.width).toBe('20px'); + expect(container.querySelectorAll('svg')).toHaveLength(2); + }); +}); diff --git a/client/src/components/Trips/TripJournalLinkSection.tsx b/client/src/components/Trips/TripJournalLinkSection.tsx new file mode 100644 index 000000000..e0fb66c0a --- /dev/null +++ b/client/src/components/Trips/TripJournalLinkSection.tsx @@ -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 ( + <> + + {j.mode === 'view' && j.link && } + {j.mode === 'view' && !j.link && j.canLink && ( + {t('trip.journal.link')} + )} + {j.mode === 'view' && j.link && ( +
+ {j.canEditMembers && ( + }> + {t('trip.journal.editMembers')} + + )} + {j.canLink && ( + }> + {t('trip.journal.change')} + + )} + {j.canUnlink && ( + + )} +
+ )} + {j.mode === 'pick' && } + {(j.mode === 'members' || (j.mode === 'pick' && j.pickedId != null)) && } + {j.mode !== 'view' && ( +
+ + {t('common.cancel')} + + void j.save()} + disabled={!j.canSave} + icon={} + > + {j.mode === 'pick' ? t('trip.journal.linkSubmit') : t('common.save')} + +
+ )} + + 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 ( + } + title={t('trip.journal.title')} + hint={t('trip.journal.hint')} + > + {children} + + ); +} + +function Bare({ children }: { children: ReactNode }) { + return
{children}
; +} + +type JournalLink = ReturnType; + +/** 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 ( +
+
+ + {link.cover_image ? ( + + ) : ( + + )} + +
+
+ {link.title} +
+ {link.my_role && ( +
+ {t(`trip.journal.yourRole.${link.my_role}`)} +
+ )} +
+ + + {t('trip.journal.linkedBadge')} + +
+ {link.members && link.members.length > 0 && ( +
+ {link.members.map((m) => ( + + + {m.username} + + {t(m.role === 'editor' ? 'trip.journal.roleEditor' : 'trip.journal.roleViewer')} + + + ))} +
+ )} + {link.members && link.members.length === 0 && ( +

+ {t('trip.journal.onlyYou')} +

+ )} +
+ ); +} + +/** 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
; + if (j.journals.length === 0) + return ( +

+ {t('trip.journal.noJournals')} +

+ ); + return ( + 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
; + if (j.candidates.length === 0) + return ( +

+ {t('trip.journal.noMembers')} +

+ ); + return ( +
+
+ + {t('trip.journal.members')} + +
+ + + / + + +
+
+
+ {j.candidates.map((c) => ( + + ))} +
+ {j.newlyGranted.length > 0 && ( +
+ +

+ {t('trip.journal.warnNew', { + count: j.newlyGranted.length, + names: j.newlyGranted.map((c) => c.username).join(', '), + })} +

+
+ )} +
+ ); +} + +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 ( +
+ +
+ + {c.username} + + {c.is_trip_owner && ( + + {t('members.owner')} + + )} + {showAccess && c.access && ( + + {t('trip.journal.hasAccess')} + + )} +
+
+ {ROLES.map((r) => ( + + ))} +
+
+ ); +} diff --git a/client/src/components/Trips/TripMembersModal.test.tsx b/client/src/components/Trips/TripMembersModal.test.tsx index 0cf77354c..bda4467cc 100644 --- a/client/src/components/Trips/TripMembersModal.test.tsx +++ b/client/src/components/Trips/TripMembersModal.test.tsx @@ -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(); - // 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(); - expect(screen.getByText('Share Trip')).toBeInTheDocument(); + expect(screen.getByText('Trip settings')).toBeInTheDocument(); }); // ── Share Link Section (016-021) ─────────────────────────────────────────── diff --git a/client/src/components/Trips/TripMembersModal.tsx b/client/src/components/Trips/TripMembersModal.tsx index 97841061f..d02e8120c 100644 --- a/client/src/components/Trips/TripMembersModal.tsx +++ b/client/src/components/Trips/TripMembersModal.tsx @@ -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 ( - +
@@ -402,6 +403,8 @@ function TripMembersSheet({ isOpen, onClose, tripId, tripTitle, onMembersChanged
} + +
diff --git a/client/src/components/Trips/TripShareDialog.tsx b/client/src/components/Trips/TripShareDialog.tsx index c8807966c..6562a7e07 100644 --- a/client/src/components/Trips/TripShareDialog.tsx +++ b/client/src/components/Trips/TripShareDialog.tsx @@ -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 ? : 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: () => , labelKey: 'trip.settings.people', hintKey: 'trip.settings.peopleHint' }, + { id: 'sharing', icon: () => , labelKey: 'trip.settings.sharing', hintKey: 'trip.settings.sharingHint' }, + { id: 'journal', icon: () => , 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(null) + const [area, setArea] = useState('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,17 @@ function OpenDialog({ onClose, tripId, tripTitle, onMembersChanged }: TripShareD 1 ? 'wide' : 'detail'} + align="top" blocked={!!ask} + bodyClassName={BODY} header={( } + tile={} tint={NEUTRAL_TINT} labelId={labelId} onClose={onClose} - eyebrow={t('members.shareTrip')} + eyebrow={t('trip.settings.title')} title={tripTitle} pills={m.loading ? undefined : ( <> @@ -67,17 +92,24 @@ function OpenDialog({ onClose, tripId, tripTitle, onMembersChanged }: TripShareD /> )} > -
-
+ {areas.length > 1 && } +
+ {/* Every area stays mounted, so its links and lists are loaded by the time it is picked. */} + 1}> {m.canManageMembers && } {(m.isCurrentOwner || m.guests.length > 0) && } -
+ {m.canManageShare && ( -
+ -
+ + )} + {journalSection && ( + + + )}
@@ -93,6 +125,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 ( + + ) +} + +/** 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. + + ) +} + function InviteUser({ m }: { m: TripMembersState }) { const { t } = useTranslation() return ( @@ -291,7 +362,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 (
diff --git a/client/src/components/Trips/useTripJournalLink.test.ts b/client/src/components/Trips/useTripJournalLink.test.ts new file mode 100644 index 000000000..0b600d82e --- /dev/null +++ b/client/src/components/Trips/useTripJournalLink.test.ts @@ -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) => void>(); +vi.mock('../../api/websocket', async (importOriginal) => ({ + ...(await importOriginal()), + addListener: (fn: (e: Record) => void) => { + listeners.add(fn); + }, + removeListener: (fn: (e: Record) => 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('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('Access saved'); + }); + + 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('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('You are not allowed to do that.'); + 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('Linking a journal needs a connection.')); + 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('That did not work. Please try again.')); + expect(result.current.candidates).toEqual([]); + act(() => result.current.cancel()); + expect(result.current.mode).toBe('view'); + expect(result.current.canSave).toBe(false); + }); +}); diff --git a/client/src/components/Trips/useTripJournalLink.ts b/client/src/components/Trips/useTripJournalLink.ts new file mode 100644 index 000000000..9c0c8f379 --- /dev/null +++ b/client/src/components/Trips/useTripJournalLink.ts @@ -0,0 +1,228 @@ +import type { TripJournalCandidate, 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; + +/** 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 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 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(null); + const [pickedId, setPickedId] = useState(null); + const [candidates, setCandidates] = useState(null); + const [choice, setChoice] = useState({}); + const [busy, setBusy] = useState(false); + + const fail = 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 the chosen journal, each ticked as they stand: + // on the link with their role, or (for a journal not linked yet) everybody as + // a reader, the default this section promises. + useEffect(() => { + const journeyId = mode === 'members' ? (link?.journey_id ?? null) : mode === 'pick' ? pickedId : null; + if (journeyId == null) return; + let cancelled = false; + tripJournalLinkRepo + .candidates(tripId, journeyId) + .then((list) => { + if (cancelled) return; + setCandidates(list); + const onLink = new Map( + (link?.journey_id === journeyId ? (link.members ?? []) : []).map((m) => [m.user_id, m.role]) + ); + const keepCurrent = mode === 'members' || onLink.size > 0; + setChoice( + Object.fromEntries(list.map((c) => [c.user_id, keepCurrent ? (onLink.get(c.user_id) ?? null) : 'viewer'])) + ); + }) + .catch((err: unknown) => { + if (!cancelled) { + fail(err); + setCandidates([]); + } + }); + return () => { + cancelled = true; + }; + }, [mode, pickedId, link, tripId, fail]); + + const startPick = useCallback(() => { + setCandidates(null); + setMode('pick'); + setPickedId(null); + if (userId == null) return; + tripJournalLinkRepo + .ownJournals(userId) + .then(setJournals) + .catch((err: unknown) => { + fail(err); + setJournals([]); + }); + }, [userId, fail]); + + // A new pick shows its own people only: the last journal's stay out until they load. + const pickJournal = useCallback((journeyId: number | null) => { + setCandidates(null); + setPickedId(journeyId); + }, []); + + const editMembers = useCallback(() => { + setCandidates(null); + setMode('members'); + }, []); + + const cancel = useCallback(() => { + setMode('view'); + setPickedId(null); + setCandidates(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] + ); + + const run = useCallback( + async (write: () => Promise, done: string) => { + setBusy(true); + try { + await write(); + toast.success(t(done)); + cancel(); + } catch (err: unknown) { + fail(err); + } finally { + setBusy(false); + } + }, + [cancel, fail, t, toast] + ); + + const save = useCallback(async () => { + if (mode === 'pick' && pickedId != null) { + await run(() => store().link(tripId, pickedId, chosenMembers), 'trip.journal.linked'); + } else if (mode === 'members') { + await run(() => store().setMembers(tripId, chosenMembers), 'trip.journal.membersSaved'); + } + }, [mode, pickedId, run, store, tripId, chosenMembers]); + + const unlink = useCallback(async () => { + await run(() => store().unlink(tripId), 'trip.journal.unlinked'); + }, [run, store, tripId]); + + 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, + candidates, + choice, + setRole, + setAll, + newlyGranted, + canSave: !busy && (mode === 'members' || (mode === 'pick' && pickedId != null)), + busy, + save, + unlink, + }; +} diff --git a/client/src/constants/tripTabs.ts b/client/src/constants/tripTabs.ts index b46c8e4ab..153f60d22 100644 --- a/client/src/constants/tripTabs.ts +++ b/client/src/constants/tripTabs.ts @@ -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 = { 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 => diff --git a/client/src/index.css b/client/src/index.css index 2bf9bee9b..64c0dd69a 100644 --- a/client/src/index.css +++ b/client/src/index.css @@ -824,6 +824,14 @@ input[type="number"], input[type="time"], input[type="date"], input[type="dateti :root { --nav-h: calc(64px + env(safe-area-inset-top, 0px)); } } +/* A journal shown inside a trip (its Journal tab): the journal's own fixed bars + measure themselves from --nav-h, so below the trip's navbar they also have to + clear the trip's tab bar. */ +.trip-embedded-journal { --nav-h: 44px; } +@media (min-width: 768px) { + .trip-embedded-journal { --nav-h: calc(64px + env(safe-area-inset-top, 0px) + 44px); } +} + .dark { --bg-primary: #121215; --bg-secondary: #1a1a1e; diff --git a/client/src/mobile/mobile.css b/client/src/mobile/mobile.css index cf38e9f96..f03519d12 100644 --- a/client/src/mobile/mobile.css +++ b/client/src/mobile/mobile.css @@ -132,6 +132,10 @@ to { opacity: 0; transform: translateY(6px); } } +/* A journal inside a trip's Journal tab starts below the trip's own floating + top controls rather than under them. */ +.m-embedded-journal { --m-safe-top: calc(env(safe-area-inset-top, 0px) + 62px); } + .m-sheet-in { animation: m-sheet-in 280ms var(--ease-drawer) both; } .m-sheet-out { animation: m-sheet-out 280ms var(--ease-drawer) both; } .m-drawer-in { animation: m-drawer-in 280ms var(--ease-drawer) both; } diff --git a/client/src/mobile/screens/journey/MJourneyDetail.tsx b/client/src/mobile/screens/journey/MJourneyDetail.tsx index 77b34d5ac..07d0e0ffc 100644 --- a/client/src/mobile/screens/journey/MJourneyDetail.tsx +++ b/client/src/mobile/screens/journey/MJourneyDetail.tsx @@ -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 */}
- + {embedded ? ( + + ) : ( + + )}
+ {!embedded && ( + + )}
@@ -270,7 +281,7 @@ function JourneyDetailPageDesktop() {
)} -
+
- + {!embedded && ( + + )} {/* Status badge — keep completed/upcoming/draft/archived, but drop live + synced-with-trips per UX trim */}
{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. */} - + {!embedded && }
) } diff --git a/client/src/pages/TripPlannerPage.tsx b/client/src/pages/TripPlannerPage.tsx index 9df6a9e7a..b13ef9470 100644 --- a/client/src/pages/TripPlannerPage.tsx +++ b/client/src/pages/TripPlannerPage.tsx @@ -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 (
- navigate('/dashboard')} onShare={() => setShowMembersModal(true)} /> + navigate('/dashboard')} onSettings={() => setShowMembersModal(true)} />
)} - + {activeTab === 'journal' && } {activeTab.startsWith('plugin:') && (
diff --git a/client/src/pages/journeyDetail/useJourneyDetail.ts b/client/src/pages/journeyDetail/useJourneyDetail.ts index 00bdd849e..4d255ddd3 100644 --- a/client/src/pages/journeyDetail/useJourneyDetail.ts +++ b/client/src/pages/journeyDetail/useJourneyDetail.ts @@ -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, diff --git a/client/src/pages/tripPlanner/TripJournalPanel.tsx b/client/src/pages/tripPlanner/TripJournalPanel.tsx new file mode 100644 index 000000000..358ebce36 --- /dev/null +++ b/client/src/pages/tripPlanner/TripJournalPanel.tsx @@ -0,0 +1,16 @@ +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 wrapper moves the view's fixed + * bars below the trip's tab bar (see .trip-embedded-journal). + */ +export default function TripJournalPanel({ journeyId }: { journeyId: number | null }) { + if (journeyId == null) return null; + return ( +
+ +
+ ); +} diff --git a/client/src/pages/tripPlanner/plannerLazy.ts b/client/src/pages/tripPlanner/plannerLazy.ts index 2ac6fcb61..5fcfc19fb 100644 --- a/client/src/pages/tripPlanner/plannerLazy.ts +++ b/client/src/pages/tripPlanner/plannerLazy.ts @@ -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')) diff --git a/client/src/pages/tripPlanner/useTripPlanner.ts b/client/src/pages/tripPlanner/useTripPlanner.ts index 3dffad1c4..4425106e2 100644 --- a/client/src/pages/tripPlanner/useTripPlanner.ts +++ b/client/src/pages/tripPlanner/useTripPlanner.ts @@ -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_TAB_LABEL_KEYS.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, diff --git a/client/src/repo/tripJournalLinkRepo.test.ts b/client/src/repo/tripJournalLinkRepo.test.ts new file mode 100644 index 000000000..f9e25cf04 --- /dev/null +++ b/client/src/repo/tripJournalLinkRepo.test.ts @@ -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/); + }); +}); diff --git a/client/src/repo/tripJournalLinkRepo.ts b/client/src/repo/tripJournalLinkRepo.ts new file mode 100644 index 000000000..be1aca227 --- /dev/null +++ b/client/src/repo/tripJournalLinkRepo.ts @@ -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 { + requireOnline(); + return tripJournalApi.get(tripId); + }, + + async candidates(tripId: number, journeyId: number): Promise { + requireOnline(); + return (await tripJournalApi.candidates(tripId, journeyId)).candidates; + }, + + async link( + tripId: number, + journeyId: number, + members: TripJournalLinkMemberRequest[] + ): Promise { + requireOnline(); + return tripJournalApi.link(tripId, journeyId, members); + }, + + async setMembers(tripId: number, members: TripJournalLinkMemberRequest[]): Promise { + requireOnline(); + return tripJournalApi.setMembers(tripId, members); + }, + + async unlink(tripId: number): Promise { + 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 { + 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 })); + }, +}; diff --git a/client/src/store/tripJournalLinkStore.test.ts b/client/src/store/tripJournalLinkStore.test.ts new file mode 100644 index 000000000..38ab4438a --- /dev/null +++ b/client/src/store/tripJournalLinkStore.test.ts @@ -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); + }); +}); diff --git a/client/src/store/tripJournalLinkStore.ts b/client/src/store/tripJournalLinkStore.ts new file mode 100644 index 000000000..462605178 --- /dev/null +++ b/client/src/store/tripJournalLinkStore.ts @@ -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; + refresh: (tripId: number) => Promise; + link: (tripId: number, journeyId: number, members: TripJournalLinkMemberRequest[]) => Promise; + setMembers: (tripId: number, members: TripJournalLinkMemberRequest[]) => Promise; + unlink: (tripId: number) => Promise; +} + +/** 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((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)), + }; +}); diff --git a/shared/src/i18n/ar/members.ts b/shared/src/i18n/ar/members.ts index 2ebf931af..b381f7e76 100644 --- a/shared/src/i18n/ar/members.ts +++ b/shared/src/i18n/ar/members.ts @@ -1,7 +1,6 @@ import type { TranslationStrings } from '../types'; const members: TranslationStrings = { - 'members.shareTrip': 'مشاركة الرحلة', 'members.inviteUser': 'دعوة مستخدم', 'members.selectUser': 'اختر مستخدمًا…', 'members.invite': 'دعوة', diff --git a/shared/src/i18n/ar/nav.ts b/shared/src/i18n/ar/nav.ts index 52a2638bd..9cb899219 100644 --- a/shared/src/i18n/ar/nav.ts +++ b/shared/src/i18n/ar/nav.ts @@ -2,7 +2,6 @@ import type { TranslationStrings } from '../types'; const nav: TranslationStrings = { 'nav.trip': 'الرحلة', - 'nav.share': 'مشاركة', 'nav.settings': 'الإعدادات', 'nav.admin': 'الإدارة', 'nav.logout': 'تسجيل الخروج', diff --git a/shared/src/i18n/ar/trip.ts b/shared/src/i18n/ar/trip.ts index d282ff38a..3faf0509d 100644 --- a/shared/src/i18n/ar/trip.ts +++ b/shared/src/i18n/ar/trip.ts @@ -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; diff --git a/shared/src/i18n/az/members.ts b/shared/src/i18n/az/members.ts index 223c25628..8393c96c8 100644 --- a/shared/src/i18n/az/members.ts +++ b/shared/src/i18n/az/members.ts @@ -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', diff --git a/shared/src/i18n/az/nav.ts b/shared/src/i18n/az/nav.ts index 03300f1dc..ac7a287a7 100644 --- a/shared/src/i18n/az/nav.ts +++ b/shared/src/i18n/az/nav.ts @@ -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', diff --git a/shared/src/i18n/az/trip.ts b/shared/src/i18n/az/trip.ts index 2f285d68d..5aa34a905 100644 --- a/shared/src/i18n/az/trip.ts +++ b/shared/src/i18n/az/trip.ts @@ -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; diff --git a/shared/src/i18n/br/members.ts b/shared/src/i18n/br/members.ts index 515099216..45e8c5846 100644 --- a/shared/src/i18n/br/members.ts +++ b/shared/src/i18n/br/members.ts @@ -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', diff --git a/shared/src/i18n/br/nav.ts b/shared/src/i18n/br/nav.ts index c5720928c..476264f0c 100644 --- a/shared/src/i18n/br/nav.ts +++ b/shared/src/i18n/br/nav.ts @@ -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', diff --git a/shared/src/i18n/br/trip.ts b/shared/src/i18n/br/trip.ts index 07bee4af7..0b85c71e7 100644 --- a/shared/src/i18n/br/trip.ts +++ b/shared/src/i18n/br/trip.ts @@ -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; diff --git a/shared/src/i18n/ca/members.ts b/shared/src/i18n/ca/members.ts index 44ad5d474..411a38f9c 100644 --- a/shared/src/i18n/ca/members.ts +++ b/shared/src/i18n/ca/members.ts @@ -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', diff --git a/shared/src/i18n/ca/nav.ts b/shared/src/i18n/ca/nav.ts index 396c13ed7..6309341f5 100644 --- a/shared/src/i18n/ca/nav.ts +++ b/shared/src/i18n/ca/nav.ts @@ -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ó', diff --git a/shared/src/i18n/ca/trip.ts b/shared/src/i18n/ca/trip.ts index 6db92782c..c11120f31 100644 --- a/shared/src/i18n/ca/trip.ts +++ b/shared/src/i18n/ca/trip.ts @@ -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; diff --git a/shared/src/i18n/cs/members.ts b/shared/src/i18n/cs/members.ts index eec0e65e1..627aceadd 100644 --- a/shared/src/i18n/cs/members.ts +++ b/shared/src/i18n/cs/members.ts @@ -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', diff --git a/shared/src/i18n/cs/nav.ts b/shared/src/i18n/cs/nav.ts index 8bc5d410f..0afe0a518 100644 --- a/shared/src/i18n/cs/nav.ts +++ b/shared/src/i18n/cs/nav.ts @@ -2,7 +2,6 @@ import type { TranslationStrings } from '../types'; const nav: TranslationStrings = { 'nav.trip': 'Cesta', - 'nav.share': 'Sdílet', 'nav.settings': 'Nastavení', 'nav.admin': 'Administrace', 'nav.logout': 'Odhlásit se', diff --git a/shared/src/i18n/cs/trip.ts b/shared/src/i18n/cs/trip.ts index 2d32095de..fd0533493 100644 --- a/shared/src/i18n/cs/trip.ts +++ b/shared/src/i18n/cs/trip.ts @@ -104,5 +104,45 @@ const trip: TranslationStrings = { 'Tím se smažou i ubytování rezervované v „{name}“, rezervace „{booking}“ a všechny navázané výdaje.', 'trip.confirm.deletePlaceBookedSame': 'Tím se smažou i ubytování rezervované v „{name}“, jeho rezervace a všechny navázané výdaje.', + 'trip.settings.title': 'Nastavení cesty', + 'trip.settings.people': 'Lidé', + 'trip.settings.peopleHint': 'Kdo je na této cestě a hosté bez účtu.', + 'trip.settings.sharing': 'Sdílení', + 'trip.settings.sharingHint': 'Odkaz jen pro čtení pro kohokoli a pozvánka k připojení.', + 'trip.tabs.journal': 'Deník', + 'trip.journal.title': 'Deník', + 'trip.journal.hint': + 'Propojte s touto cestou jeden ze svých deníků. Kdo dostane přístup, otevře ho přímo tady na kartě Deník, živě.', + 'trip.journal.link': 'Propojit deník', + 'trip.journal.linkSubmit': 'Propojit deník', + 'trip.journal.change': 'Změnit deník', + 'trip.journal.editMembers': 'Upravit přístup', + 'trip.journal.unlink': 'Odpojit', + 'trip.journal.unlinkTitle': 'Odpojit tento deník?', + 'trip.journal.unlinkMessage': + 'Karta Deník z této cesty zmizí. Členové, kteří deník mohli otevřít jen přes tuto cestu, přijdou o přístup; lidé přidaní přímo v deníku si ho ponechají.', + 'trip.journal.linkedBadge': 'Propojeno', + 'trip.journal.yourRole.owner': 'Váš deník', + 'trip.journal.yourRole.editor': 'Můžete upravovat', + 'trip.journal.yourRole.viewer': 'Můžete číst', + 'trip.journal.onlyYou': 'Nikdo další z této cesty zatím nemá přístup.', + 'trip.journal.noJournals': 'Zatím nemáte žádný deník. Nejprve ho vytvořte v Cestovním deníku.', + 'trip.journal.pickPlaceholder': 'Vyberte deník', + 'trip.journal.noMembers': 'Na této cestě zatím nikdo další není.', + 'trip.journal.members': 'Přístup pro členy cesty', + 'trip.journal.allRead': 'Všichni čtou', + 'trip.journal.allNone': 'Nikdo', + 'trip.journal.roleNone': 'Bez přístupu', + 'trip.journal.roleViewer': 'Číst', + 'trip.journal.roleEditor': 'Upravovat', + 'trip.journal.hasAccess': 'Má přístup', + 'trip.journal.warnNew': + '{names} poprvé získá přístup k tomuto deníku a od té doby ho uvidí i ve vlastním seznamu deníků.', + 'trip.journal.errorOffline': 'K propojení deníku je potřeba připojení.', + 'trip.journal.errorForbidden': 'K tomu nemáte oprávnění.', + 'trip.journal.errorGeneric': 'Nepovedlo se. Zkuste to znovu.', + 'trip.journal.linked': 'Deník propojen', + 'trip.journal.membersSaved': 'Přístup uložen', + 'trip.journal.unlinked': 'Deník odpojen', }; export default trip; diff --git a/shared/src/i18n/de/members.ts b/shared/src/i18n/de/members.ts index 7715491f4..8fc093b50 100644 --- a/shared/src/i18n/de/members.ts +++ b/shared/src/i18n/de/members.ts @@ -1,7 +1,6 @@ import type { TranslationStrings } from '../types'; const members: TranslationStrings = { - 'members.shareTrip': 'Reise teilen', 'members.inviteUser': 'Benutzer einladen', 'members.selectUser': 'Benutzer auswählen…', 'members.invite': 'Einladen', diff --git a/shared/src/i18n/de/nav.ts b/shared/src/i18n/de/nav.ts index fcd56ac74..c3f0bc6d9 100644 --- a/shared/src/i18n/de/nav.ts +++ b/shared/src/i18n/de/nav.ts @@ -2,7 +2,6 @@ import type { TranslationStrings } from '../types'; const nav: TranslationStrings = { 'nav.trip': 'Reise', - 'nav.share': 'Teilen', 'nav.settings': 'Einstellungen', 'nav.admin': 'Admin', 'nav.logout': 'Abmelden', diff --git a/shared/src/i18n/de/trip.ts b/shared/src/i18n/de/trip.ts index 0f5b343e2..418d8e3df 100644 --- a/shared/src/i18n/de/trip.ts +++ b/shared/src/i18n/de/trip.ts @@ -99,5 +99,45 @@ const trip: TranslationStrings = { 'Damit werden auch die in „{name}“ gebuchte Unterkunft, die Buchung „{booking}“ und alle damit verknüpften Ausgaben gelöscht.', 'trip.confirm.deletePlaceBookedSame': 'Damit werden auch die in „{name}“ gebuchte Unterkunft, ihre Buchung und alle damit verknüpften Ausgaben gelöscht.', + 'trip.settings.title': 'Reiseeinstellungen', + 'trip.settings.people': 'Personen', + 'trip.settings.peopleHint': 'Wer auf dieser Reise ist, und Gäste ohne Konto.', + 'trip.settings.sharing': 'Teilen', + 'trip.settings.sharingHint': 'Ein Nur-Lesen-Link für alle und ein Einladungslink zum Beitreten.', + 'trip.tabs.journal': 'Journal', // same-as-en + 'trip.journal.title': 'Journal', // same-as-en + 'trip.journal.hint': + 'Verknüpfe eines deiner Journale mit dieser Reise. Wen du freigibst, der öffnet es direkt hier im Journal-Tab, live.', + 'trip.journal.link': 'Journal verknüpfen', + 'trip.journal.linkSubmit': 'Journal verknüpfen', + 'trip.journal.change': 'Journal wechseln', + 'trip.journal.editMembers': 'Zugriff bearbeiten', + 'trip.journal.unlink': 'Lösen', + 'trip.journal.unlinkTitle': 'Verknüpfung lösen?', + 'trip.journal.unlinkMessage': + 'Der Journal-Tab verschwindet aus dieser Reise. Mitglieder, die das Journal nur über diese Reise öffnen konnten, verlieren den Zugriff; wen du im Journal selbst hinzugefügt hast, behält ihn.', + 'trip.journal.linkedBadge': 'Verknüpft', + 'trip.journal.yourRole.owner': 'Dein Journal', + 'trip.journal.yourRole.editor': 'Du kannst bearbeiten', + 'trip.journal.yourRole.viewer': 'Du kannst lesen', + 'trip.journal.onlyYou': 'Außer dir hat noch niemand aus dieser Reise Zugriff.', + 'trip.journal.noJournals': 'Du hast noch kein Journal. Leg zuerst eines in Journey an.', + 'trip.journal.pickPlaceholder': 'Journal auswählen', + 'trip.journal.noMembers': 'Außer dir ist noch niemand auf dieser Reise.', + 'trip.journal.members': 'Zugriff für Reisemitglieder', + 'trip.journal.allRead': 'Alle lesen', + 'trip.journal.allNone': 'Niemand', + 'trip.journal.roleNone': 'Kein Zugriff', + 'trip.journal.roleViewer': 'Lesen', + 'trip.journal.roleEditor': 'Bearbeiten', + 'trip.journal.hasAccess': 'Hat Zugriff', + 'trip.journal.warnNew': + '{names} bekommt zum ersten Mal Zugriff auf dieses Journal und sieht es ab dann auch in der eigenen Journal-Liste.', + 'trip.journal.errorOffline': 'Zum Verknüpfen eines Journals brauchst du eine Verbindung.', + 'trip.journal.errorForbidden': 'Das darfst du nicht.', + 'trip.journal.errorGeneric': 'Das hat nicht geklappt. Bitte versuch es noch einmal.', + 'trip.journal.linked': 'Journal verknüpft', + 'trip.journal.membersSaved': 'Zugriff gespeichert', + 'trip.journal.unlinked': 'Verknüpfung gelöst', }; export default trip; diff --git a/shared/src/i18n/en/members.ts b/shared/src/i18n/en/members.ts index ccb260ac1..9d271edc1 100644 --- a/shared/src/i18n/en/members.ts +++ b/shared/src/i18n/en/members.ts @@ -1,7 +1,6 @@ import type { TranslationStrings } from '../types'; const members: TranslationStrings = { - 'members.shareTrip': 'Share Trip', 'members.inviteUser': 'Invite User', 'members.selectUser': 'Select user…', 'members.invite': 'Invite', diff --git a/shared/src/i18n/en/nav.ts b/shared/src/i18n/en/nav.ts index 1c747759a..6855368e2 100644 --- a/shared/src/i18n/en/nav.ts +++ b/shared/src/i18n/en/nav.ts @@ -2,7 +2,6 @@ import type { TranslationStrings } from '../types'; const nav: TranslationStrings = { 'nav.trip': 'Trip', - 'nav.share': 'Share', 'nav.settings': 'Settings', 'nav.admin': 'Admin', 'nav.logout': 'Log out', diff --git a/shared/src/i18n/en/trip.ts b/shared/src/i18n/en/trip.ts index 7ac7ec698..5186458ed 100644 --- a/shared/src/i18n/en/trip.ts +++ b/shared/src/i18n/en/trip.ts @@ -99,5 +99,45 @@ const trip: TranslationStrings = { 'This also deletes the stay booked at “{name}”, the booking “{booking}” and any expense linked to it.', 'trip.confirm.deletePlaceBookedSame': 'This also deletes the stay booked at “{name}”, its booking and any expense linked to it.', + 'trip.settings.title': 'Trip settings', + 'trip.settings.people': 'People', + 'trip.settings.peopleHint': 'Who is on this trip, and the guests without an account.', + 'trip.settings.sharing': 'Sharing', + 'trip.settings.sharingHint': 'A read-only link for anyone, and an invite link to join.', + 'trip.tabs.journal': 'Journal', + 'trip.journal.title': 'Journal', + 'trip.journal.hint': + 'Link one of your journals to this trip. Whoever you let in opens it right here in the Journal tab, live.', + 'trip.journal.link': 'Link a journal', + 'trip.journal.linkSubmit': 'Link journal', + 'trip.journal.change': 'Change journal', + 'trip.journal.editMembers': 'Edit access', + 'trip.journal.unlink': 'Unlink', + 'trip.journal.unlinkTitle': 'Unlink this journal?', + 'trip.journal.unlinkMessage': + 'The Journal tab disappears from this trip. Members who could only open the journal through this trip lose access; people you added in the journal itself keep theirs.', + 'trip.journal.linkedBadge': 'Linked', + 'trip.journal.yourRole.owner': 'Your journal', + 'trip.journal.yourRole.editor': 'You can edit', + 'trip.journal.yourRole.viewer': 'You can read', + 'trip.journal.onlyYou': 'Nobody else from this trip has access yet.', + 'trip.journal.noJournals': 'You have no journal yet. Create one in Journey first.', + 'trip.journal.pickPlaceholder': 'Choose a journal', + 'trip.journal.noMembers': 'Nobody else is on this trip yet.', + 'trip.journal.members': 'Access for trip members', + 'trip.journal.allRead': 'Everyone can read', + 'trip.journal.allNone': 'Nobody', + 'trip.journal.roleNone': 'No access', + 'trip.journal.roleViewer': 'Read', + 'trip.journal.roleEditor': 'Edit', + 'trip.journal.hasAccess': 'Has access', + 'trip.journal.warnNew': + '{names} will get access to this journal for the first time and from then on also see it in their own journal list.', + 'trip.journal.errorOffline': 'Linking a journal needs a connection.', + 'trip.journal.errorForbidden': 'You are not allowed to do that.', + 'trip.journal.errorGeneric': 'That did not work. Please try again.', + 'trip.journal.linked': 'Journal linked', + 'trip.journal.membersSaved': 'Access saved', + 'trip.journal.unlinked': 'Journal unlinked', }; export default trip; diff --git a/shared/src/i18n/es/members.ts b/shared/src/i18n/es/members.ts index e7c7e2df3..2787616f7 100644 --- a/shared/src/i18n/es/members.ts +++ b/shared/src/i18n/es/members.ts @@ -1,7 +1,6 @@ import type { TranslationStrings } from '../types'; const members: TranslationStrings = { - 'members.shareTrip': 'Compartir viaje', 'members.inviteUser': 'Invitar usuario', 'members.selectUser': 'Seleccionar usuario…', 'members.invite': 'Invitar', diff --git a/shared/src/i18n/es/nav.ts b/shared/src/i18n/es/nav.ts index dc934b83a..ef14bec40 100644 --- a/shared/src/i18n/es/nav.ts +++ b/shared/src/i18n/es/nav.ts @@ -2,7 +2,6 @@ import type { TranslationStrings } from '../types'; const nav: TranslationStrings = { 'nav.trip': 'Viaje', - 'nav.share': 'Compartir', 'nav.settings': 'Ajustes', 'nav.admin': 'Administración', 'nav.logout': 'Cerrar sesión', diff --git a/shared/src/i18n/es/trip.ts b/shared/src/i18n/es/trip.ts index f9b760e87..6f82e7a12 100644 --- a/shared/src/i18n/es/trip.ts +++ b/shared/src/i18n/es/trip.ts @@ -99,5 +99,45 @@ const trip: TranslationStrings = { 'También se eliminan la estancia reservada en «{name}», la reserva «{booking}» y cualquier gasto vinculado.', 'trip.confirm.deletePlaceBookedSame': 'También se eliminan la estancia reservada en «{name}», su reserva y cualquier gasto vinculado.', + 'trip.settings.title': 'Ajustes del viaje', + 'trip.settings.people': 'Personas', + 'trip.settings.peopleHint': 'Quién está en este viaje y los invitados sin cuenta.', + 'trip.settings.sharing': 'Compartir', + 'trip.settings.sharingHint': 'Un enlace de solo lectura para cualquiera y un enlace de invitación para unirse.', + 'trip.tabs.journal': 'Diario', + 'trip.journal.title': 'Diario', + 'trip.journal.hint': + 'Vincula uno de tus diarios a este viaje. Quien tenga acceso lo abre aquí mismo en la pestaña Diario, en directo.', + 'trip.journal.link': 'Vincular un diario', + 'trip.journal.linkSubmit': 'Vincular diario', + 'trip.journal.change': 'Cambiar diario', + 'trip.journal.editMembers': 'Editar acceso', + 'trip.journal.unlink': 'Desvincular', + 'trip.journal.unlinkTitle': '¿Desvincular este diario?', + 'trip.journal.unlinkMessage': + 'La pestaña Diario desaparece de este viaje. Los miembros que solo podían abrir el diario a través de este viaje pierden el acceso; las personas añadidas en el propio diario lo conservan.', + 'trip.journal.linkedBadge': 'Vinculado', + 'trip.journal.yourRole.owner': 'Tu diario', + 'trip.journal.yourRole.editor': 'Puedes editar', + 'trip.journal.yourRole.viewer': 'Puedes leer', + 'trip.journal.onlyYou': 'Nadie más de este viaje tiene acceso todavía.', + 'trip.journal.noJournals': 'Aún no tienes ningún diario. Crea uno primero en Travesía.', + 'trip.journal.pickPlaceholder': 'Elige un diario', + 'trip.journal.noMembers': 'Todavía no hay nadie más en este viaje.', + 'trip.journal.members': 'Acceso para los miembros del viaje', + 'trip.journal.allRead': 'Todos pueden leer', + 'trip.journal.allNone': 'Nadie', + 'trip.journal.roleNone': 'Sin acceso', + 'trip.journal.roleViewer': 'Leer', + 'trip.journal.roleEditor': 'Editar', + 'trip.journal.hasAccess': 'Tiene acceso', + 'trip.journal.warnNew': + '{names} obtendrá acceso a este diario por primera vez y desde entonces también lo verá en su propia lista de diarios.', + 'trip.journal.errorOffline': 'Para vincular un diario necesitas conexión.', + 'trip.journal.errorForbidden': 'No tienes permiso para hacer eso.', + 'trip.journal.errorGeneric': 'No ha funcionado. Inténtalo de nuevo.', + 'trip.journal.linked': 'Diario vinculado', + 'trip.journal.membersSaved': 'Acceso guardado', + 'trip.journal.unlinked': 'Diario desvinculado', }; export default trip; diff --git a/shared/src/i18n/et/members.ts b/shared/src/i18n/et/members.ts index 608e8e0e1..8268c2611 100644 --- a/shared/src/i18n/et/members.ts +++ b/shared/src/i18n/et/members.ts @@ -1,7 +1,6 @@ import type { TranslationStrings } from '../types'; const members: TranslationStrings = { - 'members.shareTrip': 'Jaga reisi', 'members.inviteUser': 'Kutsu kasutaja', 'members.selectUser': 'Vali kasutaja…', 'members.invite': 'Kutsu', diff --git a/shared/src/i18n/et/nav.ts b/shared/src/i18n/et/nav.ts index 6f8ffd5d9..b3de48c0b 100644 --- a/shared/src/i18n/et/nav.ts +++ b/shared/src/i18n/et/nav.ts @@ -2,7 +2,6 @@ import type { TranslationStrings } from '../types'; const nav: TranslationStrings = { 'nav.trip': 'Reis', - 'nav.share': 'Jaga', 'nav.settings': 'Seaded', 'nav.admin': 'Haldus', 'nav.logout': 'Logi välja', diff --git a/shared/src/i18n/et/trip.ts b/shared/src/i18n/et/trip.ts index 14dc8ec43..cd03f735a 100644 --- a/shared/src/i18n/et/trip.ts +++ b/shared/src/i18n/et/trip.ts @@ -99,5 +99,45 @@ const trip: TranslationStrings = { 'Kohas „{name}“ broneeritud ööbimine liigub kohaga kaasa koos broneeringuga „{booking}“ ja sellega seotud kuludega.', 'trip.confirm.deletePlaceBookedSame': 'See kustutab ka kohas „{name}“ broneeritud peatumise, selle broneeringu ja sellega seotud kulu.', + 'trip.settings.title': 'Reisi seaded', + 'trip.settings.people': 'Inimesed', + 'trip.settings.peopleHint': 'Kes on sellel reisil, ja kontota külalised.', + 'trip.settings.sharing': 'Jagamine', + 'trip.settings.sharingHint': 'Kirjutuskaitstud link kõigile ja kutselink liitumiseks.', + 'trip.tabs.journal': 'Päevik', + 'trip.journal.title': 'Päevik', + 'trip.journal.hint': + 'Seo üks oma päevikutest selle reisiga. Kellele annad ligipääsu, avab selle siinsamas vahekaardil Päevik, otse.', + 'trip.journal.link': 'Seo päevik', + 'trip.journal.linkSubmit': 'Seo päevik', + 'trip.journal.change': 'Vaheta päevikut', + 'trip.journal.editMembers': 'Muuda ligipääsu', + 'trip.journal.unlink': 'Eemalda seos', + 'trip.journal.unlinkTitle': 'Eemaldada selle päeviku seos?', + 'trip.journal.unlinkMessage': + 'Vahekaart Päevik kaob sellelt reisilt. Liikmed, kes said päevikut avada ainult selle reisi kaudu, kaotavad ligipääsu; päevikus endas lisatud inimesed jäävad alles.', + 'trip.journal.linkedBadge': 'Seotud', + 'trip.journal.yourRole.owner': 'Sinu päevik', + 'trip.journal.yourRole.editor': 'Saad muuta', + 'trip.journal.yourRole.viewer': 'Saad lugeda', + 'trip.journal.onlyYou': 'Sellelt reisilt pole kellelgi teisel veel ligipääsu.', + 'trip.journal.noJournals': 'Sul pole veel päevikut. Loo see kõigepealt Reisiloos.', + 'trip.journal.pickPlaceholder': 'Vali päevik', + 'trip.journal.noMembers': 'Sellel reisil pole veel kedagi teist.', + 'trip.journal.members': 'Ligipääs reisi liikmetele', + 'trip.journal.allRead': 'Kõik saavad lugeda', + 'trip.journal.allNone': 'Mitte keegi', + 'trip.journal.roleNone': 'Ligipääs puudub', + 'trip.journal.roleViewer': 'Lugemine', + 'trip.journal.roleEditor': 'Muutmine', + 'trip.journal.hasAccess': 'On ligipääs', + 'trip.journal.warnNew': + '{names} saab sellele päevikule esimest korda ligipääsu ja näeb seda edaspidi ka oma päevikute loendis.', + 'trip.journal.errorOffline': 'Päeviku sidumiseks on vaja ühendust.', + 'trip.journal.errorForbidden': 'Sul pole selleks õigust.', + 'trip.journal.errorGeneric': 'See ei õnnestunud. Proovi uuesti.', + 'trip.journal.linked': 'Päevik seotud', + 'trip.journal.membersSaved': 'Ligipääs salvestatud', + 'trip.journal.unlinked': 'Päeviku seos eemaldatud', }; export default trip; diff --git a/shared/src/i18n/fr/members.ts b/shared/src/i18n/fr/members.ts index 4151ca992..e0bb270ed 100644 --- a/shared/src/i18n/fr/members.ts +++ b/shared/src/i18n/fr/members.ts @@ -1,7 +1,6 @@ import type { TranslationStrings } from '../types'; const members: TranslationStrings = { - 'members.shareTrip': 'Partager le voyage', 'members.inviteUser': 'Inviter un utilisateur', 'members.selectUser': 'Sélectionner un utilisateur…', 'members.invite': 'Inviter', diff --git a/shared/src/i18n/fr/nav.ts b/shared/src/i18n/fr/nav.ts index ac680d5a5..9f96508f6 100644 --- a/shared/src/i18n/fr/nav.ts +++ b/shared/src/i18n/fr/nav.ts @@ -2,7 +2,6 @@ import type { TranslationStrings } from '../types'; const nav: TranslationStrings = { 'nav.trip': 'Voyage', - 'nav.share': 'Partager', 'nav.settings': 'Paramètres', 'nav.admin': 'Admin', 'nav.logout': 'Déconnexion', diff --git a/shared/src/i18n/fr/trip.ts b/shared/src/i18n/fr/trip.ts index 1a45030c5..cb07abd54 100644 --- a/shared/src/i18n/fr/trip.ts +++ b/shared/src/i18n/fr/trip.ts @@ -100,5 +100,45 @@ const trip: TranslationStrings = { 'Le séjour réservé à « {name} », la réservation « {booking} » et toute dépense liée seront également supprimés.', 'trip.confirm.deletePlaceBookedSame': 'Le séjour réservé à « {name} », sa réservation et toute dépense liée seront également supprimés.', + 'trip.settings.title': 'Paramètres du voyage', + 'trip.settings.people': 'Personnes', + 'trip.settings.peopleHint': 'Qui participe à ce voyage, et les invités sans compte.', + 'trip.settings.sharing': 'Partage', + 'trip.settings.sharingHint': "Un lien en lecture seule pour tous, et un lien d'invitation pour rejoindre.", + 'trip.tabs.journal': 'Journal', // same-as-en + 'trip.journal.title': 'Journal', // same-as-en + 'trip.journal.hint': + "Liez l'un de vos journaux à ce voyage. Les personnes que vous autorisez l'ouvrent directement ici dans l'onglet Journal, en direct.", + 'trip.journal.link': 'Lier un journal', + 'trip.journal.linkSubmit': 'Lier le journal', + 'trip.journal.change': 'Changer de journal', + 'trip.journal.editMembers': "Modifier l'accès", + 'trip.journal.unlink': 'Délier', + 'trip.journal.unlinkTitle': 'Délier ce journal ?', + 'trip.journal.unlinkMessage': + "L'onglet Journal disparaît de ce voyage. Les membres qui ne pouvaient ouvrir le journal que via ce voyage perdent l'accès ; les personnes ajoutées dans le journal lui-même le conservent.", + 'trip.journal.linkedBadge': 'Lié', + 'trip.journal.yourRole.owner': 'Votre journal', + 'trip.journal.yourRole.editor': 'Vous pouvez modifier', + 'trip.journal.yourRole.viewer': 'Vous pouvez lire', + 'trip.journal.onlyYou': "Personne d'autre de ce voyage n'y a encore accès.", + 'trip.journal.noJournals': "Vous n'avez pas encore de journal. Créez-en un d'abord dans Journal de voyage.", + 'trip.journal.pickPlaceholder': 'Choisir un journal', + 'trip.journal.noMembers': "Personne d'autre ne participe encore à ce voyage.", + 'trip.journal.members': 'Accès des membres du voyage', + 'trip.journal.allRead': 'Tous en lecture', + 'trip.journal.allNone': 'Personne', + 'trip.journal.roleNone': 'Aucun accès', + 'trip.journal.roleViewer': 'Lire', + 'trip.journal.roleEditor': 'Modifier', + 'trip.journal.hasAccess': 'A accès', + 'trip.journal.warnNew': + '{names} obtiendra accès à ce journal pour la première fois et le verra désormais aussi dans sa propre liste de journaux.', + 'trip.journal.errorOffline': 'Lier un journal nécessite une connexion.', + 'trip.journal.errorForbidden': "Vous n'êtes pas autorisé à faire cela.", + 'trip.journal.errorGeneric': "Cela n'a pas fonctionné. Veuillez réessayer.", + 'trip.journal.linked': 'Journal lié', + 'trip.journal.membersSaved': 'Accès enregistré', + 'trip.journal.unlinked': 'Journal délié', }; export default trip; diff --git a/shared/src/i18n/gr/members.ts b/shared/src/i18n/gr/members.ts index d19d28360..44c9eb432 100644 --- a/shared/src/i18n/gr/members.ts +++ b/shared/src/i18n/gr/members.ts @@ -1,7 +1,6 @@ import type { TranslationStrings } from '../types'; const members: TranslationStrings = { - 'members.shareTrip': 'Κοινή Χρήση Ταξιδιού', 'members.inviteUser': 'Πρόσκληση Χρήστη', 'members.selectUser': 'Επιλέξτε χρήστη…', 'members.invite': 'Πρόσκληση', diff --git a/shared/src/i18n/gr/nav.ts b/shared/src/i18n/gr/nav.ts index 4b00a56c1..5a146eaf1 100644 --- a/shared/src/i18n/gr/nav.ts +++ b/shared/src/i18n/gr/nav.ts @@ -2,7 +2,6 @@ import type { TranslationStrings } from '../types'; const nav: TranslationStrings = { 'nav.trip': 'Ταξίδι', - 'nav.share': 'Διαμοιρασμός', 'nav.settings': 'Ρυθμίσεις', 'nav.admin': 'Διαχείριση', 'nav.logout': 'Αποσύνδεση', diff --git a/shared/src/i18n/gr/trip.ts b/shared/src/i18n/gr/trip.ts index 5dd4c844c..9252a8037 100644 --- a/shared/src/i18n/gr/trip.ts +++ b/shared/src/i18n/gr/trip.ts @@ -101,5 +101,46 @@ 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': 'Δεν έχετε ακόμη ημερολόγιο. Δημιουργήστε πρώτα ένα στο Ταξίδι.', + '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; diff --git a/shared/src/i18n/hu/members.ts b/shared/src/i18n/hu/members.ts index e151f4dac..3c9e2dd9a 100644 --- a/shared/src/i18n/hu/members.ts +++ b/shared/src/i18n/hu/members.ts @@ -1,7 +1,6 @@ import type { TranslationStrings } from '../types'; const members: TranslationStrings = { - 'members.shareTrip': 'Utazás megosztása', 'members.inviteUser': 'Felhasználó meghívása', 'members.selectUser': 'Felhasználó kiválasztása…', 'members.invite': 'Meghívás', diff --git a/shared/src/i18n/hu/nav.ts b/shared/src/i18n/hu/nav.ts index 98126039b..a4df55c2d 100644 --- a/shared/src/i18n/hu/nav.ts +++ b/shared/src/i18n/hu/nav.ts @@ -2,7 +2,6 @@ import type { TranslationStrings } from '../types'; const nav: TranslationStrings = { 'nav.trip': 'Utazás', - 'nav.share': 'Megosztás', 'nav.settings': 'Beállítások', 'nav.admin': 'Admin', 'nav.logout': 'Kijelentkezés', diff --git a/shared/src/i18n/hu/trip.ts b/shared/src/i18n/hu/trip.ts index c692e41ea..d71c14bbc 100644 --- a/shared/src/i18n/hu/trip.ts +++ b/shared/src/i18n/hu/trip.ts @@ -102,5 +102,45 @@ const trip: TranslationStrings = { 'Ezzel a(z) „{name}” helyen foglalt szállás, a(z) „{booking}” foglalás és a hozzá kapcsolt kiadások is törlődnek.', 'trip.confirm.deletePlaceBookedSame': 'Ezzel a(z) „{name}” helyen foglalt szállás, a foglalása és a hozzá kapcsolt kiadások is törlődnek.', + 'trip.settings.title': 'Utazás beállításai', + 'trip.settings.people': 'Személyek', + 'trip.settings.peopleHint': 'Kik vannak ezen az utazáson, és a fiók nélküli vendégek.', + 'trip.settings.sharing': 'Megosztás', + 'trip.settings.sharingHint': 'Csak olvasható link bárkinek, és meghívó link a csatlakozáshoz.', + 'trip.tabs.journal': 'Napló', + 'trip.journal.title': 'Napló', + 'trip.journal.hint': + 'Kapcsold az egyik naplódat ehhez az utazáshoz. Akinek hozzáférést adsz, itt nyitja meg a Napló fülön, élőben.', + 'trip.journal.link': 'Napló kapcsolása', + 'trip.journal.linkSubmit': 'Napló kapcsolása', + 'trip.journal.change': 'Másik napló', + 'trip.journal.editMembers': 'Hozzáférés szerkesztése', + 'trip.journal.unlink': 'Leválasztás', + 'trip.journal.unlinkTitle': 'Leválasztod ezt a naplót?', + 'trip.journal.unlinkMessage': + 'A Napló fül eltűnik az utazásból. Azok a tagok, akik csak ezen az utazáson keresztül nyithatták meg a naplót, elveszítik a hozzáférést; akiket magában a naplóban adtál hozzá, megtartják.', + 'trip.journal.linkedBadge': 'Kapcsolva', + 'trip.journal.yourRole.owner': 'A te naplód', + 'trip.journal.yourRole.editor': 'Szerkesztheted', + 'trip.journal.yourRole.viewer': 'Olvashatod', + 'trip.journal.onlyYou': 'Az utazásból még senki más nem fér hozzá.', + 'trip.journal.noJournals': 'Még nincs naplód. Először hozz létre egyet az Útinaplókban.', + 'trip.journal.pickPlaceholder': 'Válassz naplót', + 'trip.journal.noMembers': 'Rajtad kívül még senki sincs ezen az utazáson.', + 'trip.journal.members': 'Hozzáférés az utazás tagjainak', + 'trip.journal.allRead': 'Mindenki olvashatja', + 'trip.journal.allNone': 'Senki', + 'trip.journal.roleNone': 'Nincs hozzáférés', + 'trip.journal.roleViewer': 'Olvasás', + 'trip.journal.roleEditor': 'Szerkesztés', + 'trip.journal.hasAccess': 'Hozzáfér', + 'trip.journal.warnNew': + '{names} először kap hozzáférést ehhez a naplóhoz, és ettől kezdve a saját naplólistájában is látja.', + 'trip.journal.errorOffline': 'Napló kapcsolásához kapcsolat szükséges.', + 'trip.journal.errorForbidden': 'Ehhez nincs jogosultságod.', + 'trip.journal.errorGeneric': 'Ez nem sikerült. Próbáld újra.', + 'trip.journal.linked': 'Napló kapcsolva', + 'trip.journal.membersSaved': 'Hozzáférés mentve', + 'trip.journal.unlinked': 'Napló leválasztva', }; export default trip; diff --git a/shared/src/i18n/id/members.ts b/shared/src/i18n/id/members.ts index 33561a91a..5a10b427c 100644 --- a/shared/src/i18n/id/members.ts +++ b/shared/src/i18n/id/members.ts @@ -1,7 +1,6 @@ import type { TranslationStrings } from '../types'; const members: TranslationStrings = { - 'members.shareTrip': 'Bagikan Perjalanan', 'members.inviteUser': 'Undang Pengguna', 'members.selectUser': 'Pilih pengguna…', 'members.invite': 'Undang', diff --git a/shared/src/i18n/id/nav.ts b/shared/src/i18n/id/nav.ts index 7dfee707b..6ab97f1e3 100644 --- a/shared/src/i18n/id/nav.ts +++ b/shared/src/i18n/id/nav.ts @@ -2,7 +2,6 @@ import type { TranslationStrings } from '../types'; const nav: TranslationStrings = { 'nav.trip': 'Perjalanan', - 'nav.share': 'Bagikan', 'nav.settings': 'Pengaturan', 'nav.admin': 'Admin', 'nav.logout': 'Keluar', diff --git a/shared/src/i18n/id/trip.ts b/shared/src/i18n/id/trip.ts index 7602db69c..756c1cc22 100644 --- a/shared/src/i18n/id/trip.ts +++ b/shared/src/i18n/id/trip.ts @@ -95,5 +95,45 @@ const trip: TranslationStrings = { 'Ini juga menghapus penginapan yang dipesan di “{name}”, pemesanan “{booking}”, dan pengeluaran yang terkait.', 'trip.confirm.deletePlaceBookedSame': 'Ini juga menghapus penginapan yang dipesan di “{name}”, pemesanannya, dan pengeluaran yang terkait.', + 'trip.settings.title': 'Pengaturan perjalanan', + 'trip.settings.people': 'Orang', + 'trip.settings.peopleHint': 'Siapa saja dalam perjalanan ini, dan tamu tanpa akun.', + 'trip.settings.sharing': 'Berbagi', + 'trip.settings.sharingHint': 'Tautan hanya-baca untuk siapa saja, dan tautan undangan untuk bergabung.', + 'trip.tabs.journal': 'Jurnal', + 'trip.journal.title': 'Jurnal', + 'trip.journal.hint': + 'Tautkan salah satu jurnal Anda ke perjalanan ini. Siapa pun yang Anda beri akses membukanya langsung di tab Jurnal, secara langsung.', + 'trip.journal.link': 'Tautkan jurnal', + 'trip.journal.linkSubmit': 'Tautkan jurnal', + 'trip.journal.change': 'Ganti jurnal', + 'trip.journal.editMembers': 'Ubah akses', + 'trip.journal.unlink': 'Lepas tautan', + 'trip.journal.unlinkTitle': 'Lepas tautan jurnal ini?', + 'trip.journal.unlinkMessage': + 'Tab Jurnal hilang dari perjalanan ini. Anggota yang hanya bisa membuka jurnal lewat perjalanan ini kehilangan akses; orang yang ditambahkan di jurnal itu sendiri tetap memilikinya.', + 'trip.journal.linkedBadge': 'Tertaut', + 'trip.journal.yourRole.owner': 'Jurnal Anda', + 'trip.journal.yourRole.editor': 'Anda bisa mengedit', + 'trip.journal.yourRole.viewer': 'Anda bisa membaca', + 'trip.journal.onlyYou': 'Belum ada orang lain dari perjalanan ini yang punya akses.', + 'trip.journal.noJournals': 'Anda belum punya jurnal. Buat dulu di Journey.', + 'trip.journal.pickPlaceholder': 'Pilih jurnal', + 'trip.journal.noMembers': 'Belum ada orang lain dalam perjalanan ini.', + 'trip.journal.members': 'Akses untuk anggota perjalanan', + 'trip.journal.allRead': 'Semua bisa membaca', + 'trip.journal.allNone': 'Tidak ada', + 'trip.journal.roleNone': 'Tanpa akses', + 'trip.journal.roleViewer': 'Baca', + 'trip.journal.roleEditor': 'Edit', // same-as-en + 'trip.journal.hasAccess': 'Punya akses', + 'trip.journal.warnNew': + '{names} akan mendapat akses ke jurnal ini untuk pertama kalinya dan sejak itu juga melihatnya di daftar jurnalnya sendiri.', + 'trip.journal.errorOffline': 'Menautkan jurnal memerlukan koneksi.', + 'trip.journal.errorForbidden': 'Anda tidak diizinkan melakukan itu.', + 'trip.journal.errorGeneric': 'Tidak berhasil. Silakan coba lagi.', + 'trip.journal.linked': 'Jurnal ditautkan', + 'trip.journal.membersSaved': 'Akses disimpan', + 'trip.journal.unlinked': 'Tautan jurnal dilepas', }; export default trip; diff --git a/shared/src/i18n/it/members.ts b/shared/src/i18n/it/members.ts index a2d4c87d7..f0ba82de7 100644 --- a/shared/src/i18n/it/members.ts +++ b/shared/src/i18n/it/members.ts @@ -1,7 +1,6 @@ import type { TranslationStrings } from '../types'; const members: TranslationStrings = { - 'members.shareTrip': 'Condividi viaggio', 'members.inviteUser': 'Invita utente', 'members.selectUser': 'Seleziona utente...', 'members.invite': 'Invita', diff --git a/shared/src/i18n/it/nav.ts b/shared/src/i18n/it/nav.ts index 0a0bffb9f..6a4230351 100644 --- a/shared/src/i18n/it/nav.ts +++ b/shared/src/i18n/it/nav.ts @@ -2,7 +2,6 @@ import type { TranslationStrings } from '../types'; const nav: TranslationStrings = { 'nav.trip': 'Viaggio', - 'nav.share': 'Condividi', 'nav.settings': 'Impostazioni', 'nav.admin': 'Amministrazione', 'nav.logout': 'Esci', diff --git a/shared/src/i18n/it/trip.ts b/shared/src/i18n/it/trip.ts index 0df22a267..1544c78f9 100644 --- a/shared/src/i18n/it/trip.ts +++ b/shared/src/i18n/it/trip.ts @@ -99,5 +99,45 @@ const trip: TranslationStrings = { 'Verranno eliminati anche il soggiorno prenotato a «{name}», la prenotazione «{booking}» e ogni spesa collegata.', 'trip.confirm.deletePlaceBookedSame': 'Verranno eliminati anche il soggiorno prenotato a «{name}», la sua prenotazione e ogni spesa collegata.', + 'trip.settings.title': 'Impostazioni del viaggio', + 'trip.settings.people': 'Persone', + 'trip.settings.peopleHint': 'Chi partecipa a questo viaggio e gli ospiti senza account.', + 'trip.settings.sharing': 'Condivisione', + 'trip.settings.sharingHint': 'Un link di sola lettura per tutti e un link di invito per unirsi.', + 'trip.tabs.journal': 'Diario', + 'trip.journal.title': 'Diario', + 'trip.journal.hint': + 'Collega uno dei tuoi diari a questo viaggio. Chi ha accesso lo apre direttamente qui nella scheda Diario, in tempo reale.', + 'trip.journal.link': 'Collega un diario', + 'trip.journal.linkSubmit': 'Collega diario', + 'trip.journal.change': 'Cambia diario', + 'trip.journal.editMembers': "Modifica l'accesso", + 'trip.journal.unlink': 'Scollega', + 'trip.journal.unlinkTitle': 'Scollegare questo diario?', + 'trip.journal.unlinkMessage': + "La scheda Diario scompare da questo viaggio. I membri che potevano aprire il diario solo tramite questo viaggio perdono l'accesso; le persone aggiunte nel diario stesso lo mantengono.", + 'trip.journal.linkedBadge': 'Collegato', + 'trip.journal.yourRole.owner': 'Il tuo diario', + 'trip.journal.yourRole.editor': 'Puoi modificare', + 'trip.journal.yourRole.viewer': 'Puoi leggere', + 'trip.journal.onlyYou': 'Nessun altro di questo viaggio ha ancora accesso.', + 'trip.journal.noJournals': 'Non hai ancora nessun diario. Creane prima uno in Diario di viaggio.', + 'trip.journal.pickPlaceholder': 'Scegli un diario', + 'trip.journal.noMembers': 'Non c’è ancora nessun altro in questo viaggio.', + 'trip.journal.members': 'Accesso per i membri del viaggio', + 'trip.journal.allRead': 'Tutti in lettura', + 'trip.journal.allNone': 'Nessuno', + 'trip.journal.roleNone': 'Nessun accesso', + 'trip.journal.roleViewer': 'Lettura', + 'trip.journal.roleEditor': 'Modifica', + 'trip.journal.hasAccess': 'Ha accesso', + 'trip.journal.warnNew': + '{names} otterrà accesso a questo diario per la prima volta e da allora lo vedrà anche nel proprio elenco di diari.', + 'trip.journal.errorOffline': 'Per collegare un diario serve una connessione.', + 'trip.journal.errorForbidden': 'Non sei autorizzato a farlo.', + 'trip.journal.errorGeneric': 'Non ha funzionato. Riprova.', + 'trip.journal.linked': 'Diario collegato', + 'trip.journal.membersSaved': 'Accesso salvato', + 'trip.journal.unlinked': 'Diario scollegato', }; export default trip; diff --git a/shared/src/i18n/ja/members.ts b/shared/src/i18n/ja/members.ts index e4f79408a..a865adbe4 100644 --- a/shared/src/i18n/ja/members.ts +++ b/shared/src/i18n/ja/members.ts @@ -1,7 +1,6 @@ import type { TranslationStrings } from '../types'; const members: TranslationStrings = { - 'members.shareTrip': '旅行を共有', 'members.inviteUser': 'ユーザーを招待', 'members.selectUser': 'ユーザーを選択…', 'members.invite': '招待', diff --git a/shared/src/i18n/ja/nav.ts b/shared/src/i18n/ja/nav.ts index 0af5013eb..9845634eb 100644 --- a/shared/src/i18n/ja/nav.ts +++ b/shared/src/i18n/ja/nav.ts @@ -2,7 +2,6 @@ import type { TranslationStrings } from '../types'; const nav: TranslationStrings = { 'nav.trip': '旅行', - 'nav.share': '共有', 'nav.settings': '設定', 'nav.admin': '管理', 'nav.logout': 'ログアウト', diff --git a/shared/src/i18n/ja/trip.ts b/shared/src/i18n/ja/trip.ts index 03dbe795a..2392e08d9 100644 --- a/shared/src/i18n/ja/trip.ts +++ b/shared/src/i18n/ja/trip.ts @@ -92,5 +92,45 @@ const trip: TranslationStrings = { 'trip.confirm.deletePlaceNight': '「{name}」で予約した宿泊も削除されます。', 'trip.confirm.deletePlaceBooked': '「{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': 'まだジャーナルがありません。先に日記で作成してください。', + '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; diff --git a/shared/src/i18n/ko/members.ts b/shared/src/i18n/ko/members.ts index 7101fd760..4d5a1f95d 100644 --- a/shared/src/i18n/ko/members.ts +++ b/shared/src/i18n/ko/members.ts @@ -1,7 +1,6 @@ import type { TranslationStrings } from '../types'; const members: TranslationStrings = { - 'members.shareTrip': '여행 공유', 'members.inviteUser': '사용자 초대', 'members.selectUser': '사용자 선택…', 'members.invite': '초대', diff --git a/shared/src/i18n/ko/nav.ts b/shared/src/i18n/ko/nav.ts index 4d46ee7e6..a92ac2d36 100644 --- a/shared/src/i18n/ko/nav.ts +++ b/shared/src/i18n/ko/nav.ts @@ -2,7 +2,6 @@ import type { TranslationStrings } from '../types'; const nav: TranslationStrings = { 'nav.trip': '여행', - 'nav.share': '공유', 'nav.settings': '설정', 'nav.admin': '관리자', 'nav.logout': '로그아웃', diff --git a/shared/src/i18n/ko/trip.ts b/shared/src/i18n/ko/trip.ts index 23fd90360..84eb0ed41 100644 --- a/shared/src/i18n/ko/trip.ts +++ b/shared/src/i18n/ko/trip.ts @@ -92,5 +92,44 @@ const trip: TranslationStrings = { 'trip.confirm.deletePlaceNight': '“{name}”에 예약된 숙박도 함께 삭제됩니다.', 'trip.confirm.deletePlaceBooked': '“{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; diff --git a/shared/src/i18n/nl/members.ts b/shared/src/i18n/nl/members.ts index d683f7516..d74a08722 100644 --- a/shared/src/i18n/nl/members.ts +++ b/shared/src/i18n/nl/members.ts @@ -1,7 +1,6 @@ import type { TranslationStrings } from '../types'; const members: TranslationStrings = { - 'members.shareTrip': 'Reis delen', 'members.inviteUser': 'Gebruiker uitnodigen', 'members.selectUser': 'Selecteer gebruiker…', 'members.invite': 'Uitnodigen', diff --git a/shared/src/i18n/nl/nav.ts b/shared/src/i18n/nl/nav.ts index acae56425..555d09580 100644 --- a/shared/src/i18n/nl/nav.ts +++ b/shared/src/i18n/nl/nav.ts @@ -2,7 +2,6 @@ import type { TranslationStrings } from '../types'; const nav: TranslationStrings = { 'nav.trip': 'Reis', - 'nav.share': 'Delen', 'nav.settings': 'Instellingen', 'nav.admin': 'Admin', 'nav.logout': 'Uitloggen', diff --git a/shared/src/i18n/nl/trip.ts b/shared/src/i18n/nl/trip.ts index 81f41d38c..847f6e8eb 100644 --- a/shared/src/i18n/nl/trip.ts +++ b/shared/src/i18n/nl/trip.ts @@ -99,5 +99,45 @@ const trip: TranslationStrings = { 'Hiermee worden ook het verblijf geboekt bij ‘{name}’, de boeking ‘{booking}’ en elke gekoppelde uitgave verwijderd.', 'trip.confirm.deletePlaceBookedSame': 'Hiermee worden ook het verblijf geboekt bij ‘{name}’, de bijbehorende boeking en elke gekoppelde uitgave verwijderd.', + 'trip.settings.title': 'Reisinstellingen', + 'trip.settings.people': 'Personen', + 'trip.settings.peopleHint': 'Wie er op deze reis zijn, en gasten zonder account.', + 'trip.settings.sharing': 'Delen', + 'trip.settings.sharingHint': 'Een alleen-lezen-link voor iedereen en een uitnodigingslink om mee te doen.', + 'trip.tabs.journal': 'Dagboek', + 'trip.journal.title': 'Dagboek', + 'trip.journal.hint': + 'Koppel een van je dagboeken aan deze reis. Wie je toegang geeft, opent het hier in het tabblad Dagboek, live.', + 'trip.journal.link': 'Dagboek koppelen', + 'trip.journal.linkSubmit': 'Dagboek koppelen', + 'trip.journal.change': 'Ander dagboek', + 'trip.journal.editMembers': 'Toegang bewerken', + 'trip.journal.unlink': 'Ontkoppelen', + 'trip.journal.unlinkTitle': 'Dit dagboek ontkoppelen?', + 'trip.journal.unlinkMessage': + 'Het tabblad Dagboek verdwijnt uit deze reis. Leden die het dagboek alleen via deze reis konden openen, verliezen de toegang; wie je in het dagboek zelf hebt toegevoegd, houdt die.', + 'trip.journal.linkedBadge': 'Gekoppeld', + 'trip.journal.yourRole.owner': 'Jouw dagboek', + 'trip.journal.yourRole.editor': 'Je kunt bewerken', + 'trip.journal.yourRole.viewer': 'Je kunt lezen', + 'trip.journal.onlyYou': 'Verder heeft nog niemand van deze reis toegang.', + 'trip.journal.noJournals': 'Je hebt nog geen dagboek. Maak er eerst een aan in Reisverslag.', + 'trip.journal.pickPlaceholder': 'Kies een dagboek', + 'trip.journal.noMembers': 'Verder is er nog niemand op deze reis.', + 'trip.journal.members': 'Toegang voor reisleden', + 'trip.journal.allRead': 'Iedereen lezen', + 'trip.journal.allNone': 'Niemand', + 'trip.journal.roleNone': 'Geen toegang', + 'trip.journal.roleViewer': 'Lezen', + 'trip.journal.roleEditor': 'Bewerken', + 'trip.journal.hasAccess': 'Heeft toegang', + 'trip.journal.warnNew': + '{names} krijgt voor het eerst toegang tot dit dagboek en ziet het voortaan ook in de eigen lijst met dagboeken.', + 'trip.journal.errorOffline': 'Voor het koppelen van een dagboek is een verbinding nodig.', + 'trip.journal.errorForbidden': 'Dat mag je niet doen.', + 'trip.journal.errorGeneric': 'Dat is niet gelukt. Probeer het opnieuw.', + 'trip.journal.linked': 'Dagboek gekoppeld', + 'trip.journal.membersSaved': 'Toegang opgeslagen', + 'trip.journal.unlinked': 'Dagboek ontkoppeld', }; export default trip; diff --git a/shared/src/i18n/pl/members.ts b/shared/src/i18n/pl/members.ts index 4a5c30e51..c74d1e6d8 100644 --- a/shared/src/i18n/pl/members.ts +++ b/shared/src/i18n/pl/members.ts @@ -1,7 +1,6 @@ import type { TranslationStrings } from '../types'; const members: TranslationStrings = { - 'members.shareTrip': 'Udostępnij podróż', 'members.inviteUser': 'Zaproś użytkownika', 'members.selectUser': 'Wybierz użytkownika...', 'members.invite': 'Zaproś', diff --git a/shared/src/i18n/pl/nav.ts b/shared/src/i18n/pl/nav.ts index 0fbd7e286..fcad104e1 100644 --- a/shared/src/i18n/pl/nav.ts +++ b/shared/src/i18n/pl/nav.ts @@ -2,7 +2,6 @@ import type { TranslationStrings } from '../types'; const nav: TranslationStrings = { 'nav.trip': 'Podróż', - 'nav.share': 'Udostępnij', 'nav.settings': 'Ustawienia', 'nav.admin': 'Admin', 'nav.logout': 'Wyloguj', diff --git a/shared/src/i18n/pl/trip.ts b/shared/src/i18n/pl/trip.ts index 33f20cd0b..d36924d24 100644 --- a/shared/src/i18n/pl/trip.ts +++ b/shared/src/i18n/pl/trip.ts @@ -109,5 +109,45 @@ const trip: TranslationStrings = { 'Zostaną też usunięte nocleg zarezerwowany w „{name}”, rezerwacja „{booking}” i powiązane wydatki.', 'trip.confirm.deletePlaceBookedSame': 'Zostaną też usunięte nocleg zarezerwowany w „{name}”, jego rezerwacja i powiązane wydatki.', + 'trip.settings.title': 'Ustawienia podróży', + 'trip.settings.people': 'Osoby', + 'trip.settings.peopleHint': 'Kto bierze udział w podróży oraz goście bez konta.', + 'trip.settings.sharing': 'Udostępnianie', + 'trip.settings.sharingHint': 'Link tylko do odczytu dla każdego i link z zaproszeniem do dołączenia.', + 'trip.tabs.journal': 'Dziennik', + 'trip.journal.title': 'Dziennik', + 'trip.journal.hint': + 'Połącz jeden ze swoich dzienników z tą podróżą. Osoby z dostępem otworzą go tutaj, w karcie Dziennik, na żywo.', + 'trip.journal.link': 'Połącz dziennik', + 'trip.journal.linkSubmit': 'Połącz dziennik', + 'trip.journal.change': 'Zmień dziennik', + 'trip.journal.editMembers': 'Edytuj dostęp', + 'trip.journal.unlink': 'Odłącz', + 'trip.journal.unlinkTitle': 'Odłączyć ten dziennik?', + 'trip.journal.unlinkMessage': + 'Karta Dziennik zniknie z tej podróży. Członkowie, którzy mogli otworzyć dziennik tylko przez tę podróż, stracą dostęp; osoby dodane w samym dzienniku go zachowają.', + 'trip.journal.linkedBadge': 'Połączony', + 'trip.journal.yourRole.owner': 'Twój dziennik', + 'trip.journal.yourRole.editor': 'Możesz edytować', + 'trip.journal.yourRole.viewer': 'Możesz czytać', + 'trip.journal.onlyYou': 'Nikt inny z tej podróży nie ma jeszcze dostępu.', + 'trip.journal.noJournals': 'Nie masz jeszcze dziennika. Najpierw utwórz go w Dzienniku podróży.', + 'trip.journal.pickPlaceholder': 'Wybierz dziennik', + 'trip.journal.noMembers': 'Nikogo innego nie ma jeszcze w tej podróży.', + 'trip.journal.members': 'Dostęp dla członków podróży', + 'trip.journal.allRead': 'Wszyscy czytają', + 'trip.journal.allNone': 'Nikt', + 'trip.journal.roleNone': 'Brak dostępu', + 'trip.journal.roleViewer': 'Odczyt', + 'trip.journal.roleEditor': 'Edycja', + 'trip.journal.hasAccess': 'Ma dostęp', + 'trip.journal.warnNew': + '{names} po raz pierwszy otrzyma dostęp do tego dziennika i od teraz będzie go widzieć także na własnej liście dzienników.', + 'trip.journal.errorOffline': 'Do połączenia dziennika potrzebne jest połączenie z siecią.', + 'trip.journal.errorForbidden': 'Nie masz uprawnień, aby to zrobić.', + 'trip.journal.errorGeneric': 'Nie udało się. Spróbuj ponownie.', + 'trip.journal.linked': 'Dziennik połączony', + 'trip.journal.membersSaved': 'Dostęp zapisany', + 'trip.journal.unlinked': 'Dziennik odłączony', }; export default trip; diff --git a/shared/src/i18n/ru/members.ts b/shared/src/i18n/ru/members.ts index 99cf4a88d..0445fd5a3 100644 --- a/shared/src/i18n/ru/members.ts +++ b/shared/src/i18n/ru/members.ts @@ -1,7 +1,6 @@ import type { TranslationStrings } from '../types'; const members: TranslationStrings = { - 'members.shareTrip': 'Поделиться поездкой', 'members.inviteUser': 'Пригласить пользователя', 'members.selectUser': 'Выберите пользователя…', 'members.invite': 'Пригласить', diff --git a/shared/src/i18n/ru/nav.ts b/shared/src/i18n/ru/nav.ts index 0ab938e79..4c95a043f 100644 --- a/shared/src/i18n/ru/nav.ts +++ b/shared/src/i18n/ru/nav.ts @@ -2,7 +2,6 @@ import type { TranslationStrings } from '../types'; const nav: TranslationStrings = { 'nav.trip': 'Поездка', - 'nav.share': 'Поделиться', 'nav.settings': 'Настройки', 'nav.admin': 'Администрирование', 'nav.logout': 'Выйти', diff --git a/shared/src/i18n/ru/trip.ts b/shared/src/i18n/ru/trip.ts index 3a1a853c3..a0c40cd1d 100644 --- a/shared/src/i18n/ru/trip.ts +++ b/shared/src/i18n/ru/trip.ts @@ -109,5 +109,45 @@ 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': 'У вас ещё нет дневника. Сначала создайте его в разделе «Путешествие».', + '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; diff --git a/shared/src/i18n/sk/members.ts b/shared/src/i18n/sk/members.ts index 03434238d..afc7bedd7 100644 --- a/shared/src/i18n/sk/members.ts +++ b/shared/src/i18n/sk/members.ts @@ -1,7 +1,6 @@ import type { TranslationStrings } from '../types'; const members: TranslationStrings = { - 'members.shareTrip': 'Zdieľať cestu', 'members.inviteUser': 'Pozvať používateľa', 'members.selectUser': 'Vyberte používateľa…', 'members.invite': 'Pozvať', diff --git a/shared/src/i18n/sk/nav.ts b/shared/src/i18n/sk/nav.ts index 11a6d8f68..479624e2c 100644 --- a/shared/src/i18n/sk/nav.ts +++ b/shared/src/i18n/sk/nav.ts @@ -2,7 +2,6 @@ import type { TranslationStrings } from '../types'; const nav: TranslationStrings = { 'nav.trip': 'Cesta', - 'nav.share': 'Zdieľať', 'nav.settings': 'Nastavenia', 'nav.admin': 'Správa', 'nav.logout': 'Odhlásiť sa', diff --git a/shared/src/i18n/sk/trip.ts b/shared/src/i18n/sk/trip.ts index 98c04c62c..35d463220 100644 --- a/shared/src/i18n/sk/trip.ts +++ b/shared/src/i18n/sk/trip.ts @@ -104,5 +104,45 @@ const trip: TranslationStrings = { 'Noc rezervovaná v „{name}“ zmizne spolu s miestom, vrátane rezervácie „{booking}“ a všetkých naviazaných výdavkov.', 'trip.confirm.deletePlaceBookedSame': 'Týmto sa vymaže aj pobyt rezervovaný v „{name}“, jeho rezervácia a všetky súvisiace výdavky.', + 'trip.settings.title': 'Nastavenia cesty', + 'trip.settings.people': 'Ľudia', + 'trip.settings.peopleHint': 'Kto je na tejto ceste a hostia bez účtu.', + 'trip.settings.sharing': 'Zdieľanie', + 'trip.settings.sharingHint': 'Odkaz len na čítanie pre kohokoľvek a pozvánka na pripojenie.', + 'trip.tabs.journal': 'Denník', + 'trip.journal.title': 'Denník', + 'trip.journal.hint': + 'Prepojte s touto cestou jeden zo svojich denníkov. Kto dostane prístup, otvorí ho priamo tu na karte Denník, naživo.', + 'trip.journal.link': 'Prepojiť denník', + 'trip.journal.linkSubmit': 'Prepojiť denník', + 'trip.journal.change': 'Zmeniť denník', + 'trip.journal.editMembers': 'Upraviť prístup', + 'trip.journal.unlink': 'Odpojiť', + 'trip.journal.unlinkTitle': 'Odpojiť tento denník?', + 'trip.journal.unlinkMessage': + 'Karta Denník z tejto cesty zmizne. Členovia, ktorí denník mohli otvoriť len cez túto cestu, prídu o prístup; ľudia pridaní priamo v denníku si ho ponechajú.', + 'trip.journal.linkedBadge': 'Prepojené', + 'trip.journal.yourRole.owner': 'Váš denník', + 'trip.journal.yourRole.editor': 'Môžete upravovať', + 'trip.journal.yourRole.viewer': 'Môžete čítať', + 'trip.journal.onlyYou': 'Nikto ďalší z tejto cesty zatiaľ nemá prístup.', + 'trip.journal.noJournals': 'Zatiaľ nemáte žiadny denník. Najprv ho vytvorte v Cestovnom denníku.', + 'trip.journal.pickPlaceholder': 'Vyberte denník', + 'trip.journal.noMembers': 'Na tejto ceste zatiaľ nikto ďalší nie je.', + 'trip.journal.members': 'Prístup pre členov cesty', + 'trip.journal.allRead': 'Všetci čítajú', + 'trip.journal.allNone': 'Nikto', + 'trip.journal.roleNone': 'Bez prístupu', + 'trip.journal.roleViewer': 'Čítať', + 'trip.journal.roleEditor': 'Upravovať', + 'trip.journal.hasAccess': 'Má prístup', + 'trip.journal.warnNew': + '{names} prvýkrát získa prístup k tomuto denníku a odvtedy ho uvidí aj vo vlastnom zozname denníkov.', + 'trip.journal.errorOffline': 'Na prepojenie denníka je potrebné pripojenie.', + 'trip.journal.errorForbidden': 'Na to nemáte oprávnenie.', + 'trip.journal.errorGeneric': 'Nepodarilo sa. Skúste to znova.', + 'trip.journal.linked': 'Denník prepojený', + 'trip.journal.membersSaved': 'Prístup uložený', + 'trip.journal.unlinked': 'Denník odpojený', }; export default trip; diff --git a/shared/src/i18n/sv/members.ts b/shared/src/i18n/sv/members.ts index cdf50bc6f..042a0cf9e 100644 --- a/shared/src/i18n/sv/members.ts +++ b/shared/src/i18n/sv/members.ts @@ -1,7 +1,6 @@ import type { TranslationStrings } from '../types'; const members: TranslationStrings = { - 'members.shareTrip': 'Dela resan', 'members.inviteUser': 'Bjud in användare', 'members.selectUser': 'Välj användare…', 'members.invite': 'Bjud in', diff --git a/shared/src/i18n/sv/nav.ts b/shared/src/i18n/sv/nav.ts index 996a40be8..5d9dfa7e5 100644 --- a/shared/src/i18n/sv/nav.ts +++ b/shared/src/i18n/sv/nav.ts @@ -2,7 +2,6 @@ import type { TranslationStrings } from '../types'; const nav: TranslationStrings = { 'nav.trip': 'Resa', - 'nav.share': 'Dela', 'nav.settings': 'Inställningar', 'nav.admin': 'Admin', 'nav.logout': 'Logga ut', diff --git a/shared/src/i18n/sv/trip.ts b/shared/src/i18n/sv/trip.ts index 1b62b8590..05b6ca9a2 100644 --- a/shared/src/i18n/sv/trip.ts +++ b/shared/src/i18n/sv/trip.ts @@ -99,5 +99,45 @@ const trip: TranslationStrings = { 'Då raderas även vistelsen som är bokad på ”{name}”, bokningen ”{booking}” och alla utgifter som hör till den.', 'trip.confirm.deletePlaceBookedSame': 'Då raderas även vistelsen som är bokad på ”{name}”, dess bokning och alla utgifter som hör till den.', + 'trip.settings.title': 'Reseinställningar', + 'trip.settings.people': 'Personer', + 'trip.settings.peopleHint': 'Vilka som är med på resan, och gäster utan konto.', + 'trip.settings.sharing': 'Dela', + 'trip.settings.sharingHint': 'En skrivskyddad länk för alla och en inbjudningslänk för att gå med.', + 'trip.tabs.journal': 'Dagbok', + 'trip.journal.title': 'Dagbok', + 'trip.journal.hint': + 'Koppla en av dina dagböcker till den här resan. De du ger åtkomst öppnar den direkt här på fliken Dagbok, live.', + 'trip.journal.link': 'Koppla en dagbok', + 'trip.journal.linkSubmit': 'Koppla dagbok', + 'trip.journal.change': 'Byt dagbok', + 'trip.journal.editMembers': 'Redigera åtkomst', + 'trip.journal.unlink': 'Koppla från', + 'trip.journal.unlinkTitle': 'Koppla från den här dagboken?', + 'trip.journal.unlinkMessage': + 'Fliken Dagbok försvinner från resan. Medlemmar som bara kunde öppna dagboken via den här resan förlorar åtkomsten; de du har lagt till i själva dagboken behåller den.', + 'trip.journal.linkedBadge': 'Kopplad', + 'trip.journal.yourRole.owner': 'Din dagbok', + 'trip.journal.yourRole.editor': 'Du kan redigera', + 'trip.journal.yourRole.viewer': 'Du kan läsa', + 'trip.journal.onlyYou': 'Ingen annan från resan har åtkomst än.', + 'trip.journal.noJournals': 'Du har ingen dagbok än. Skapa en i Journey först.', + 'trip.journal.pickPlaceholder': 'Välj en dagbok', + 'trip.journal.noMembers': 'Ingen annan är med på resan än.', + 'trip.journal.members': 'Åtkomst för resans medlemmar', + 'trip.journal.allRead': 'Alla kan läsa', + 'trip.journal.allNone': 'Ingen', + 'trip.journal.roleNone': 'Ingen åtkomst', + 'trip.journal.roleViewer': 'Läsa', + 'trip.journal.roleEditor': 'Redigera', + 'trip.journal.hasAccess': 'Har åtkomst', + 'trip.journal.warnNew': + '{names} får åtkomst till dagboken för första gången och ser den från och med nu även i sin egen lista med dagböcker.', + 'trip.journal.errorOffline': 'Du behöver en anslutning för att koppla en dagbok.', + 'trip.journal.errorForbidden': 'Det har du inte behörighet till.', + 'trip.journal.errorGeneric': 'Det fungerade inte. Försök igen.', + 'trip.journal.linked': 'Dagbok kopplad', + 'trip.journal.membersSaved': 'Åtkomst sparad', + 'trip.journal.unlinked': 'Dagbok frånkopplad', }; export default trip; diff --git a/shared/src/i18n/th/members.ts b/shared/src/i18n/th/members.ts index c9ba1d4ff..70219f227 100644 --- a/shared/src/i18n/th/members.ts +++ b/shared/src/i18n/th/members.ts @@ -1,7 +1,6 @@ import type { TranslationStrings } from '../types'; const members: TranslationStrings = { - 'members.shareTrip': 'แบ่งปันการเดินทาง', 'members.inviteUser': 'เชิญผู้ใช้', 'members.selectUser': 'เลือกผู้ใช้...', 'members.invite': 'เชิญ', diff --git a/shared/src/i18n/th/nav.ts b/shared/src/i18n/th/nav.ts index 28ac44269..93d588a32 100644 --- a/shared/src/i18n/th/nav.ts +++ b/shared/src/i18n/th/nav.ts @@ -2,7 +2,6 @@ import type { TranslationStrings } from '../types'; const nav: TranslationStrings = { 'nav.trip': 'การเดินทาง', - 'nav.share': 'แบ่งปัน', 'nav.settings': 'การตั้งค่า', 'nav.admin': 'ผู้ดูแลระบบ', 'nav.logout': 'ออกจากระบบ', diff --git a/shared/src/i18n/th/trip.ts b/shared/src/i18n/th/trip.ts index 1c4980110..7c9574c3e 100644 --- a/shared/src/i18n/th/trip.ts +++ b/shared/src/i18n/th/trip.ts @@ -94,5 +94,44 @@ const trip: TranslationStrings = { 'การดำเนินการนี้จะลบการพักที่จองไว้ที่ “{name}” รวมถึงการจองและค่าใช้จ่ายที่ผูกไว้ด้วย', 'trip.toast.loadError': 'ไม่สามารถโหลดทริปได้', 'transit.noResultsVia': 'ไม่พบเส้นทางผ่าน {provider} ลองเปลี่ยนเวลาหรือตัวกรอง', + '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': 'คุณยังไม่มีบันทึก สร้างก่อนใน การเดินทาง', + '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; diff --git a/shared/src/i18n/tr/members.ts b/shared/src/i18n/tr/members.ts index 0b0b4a1a5..b8b1bdc9f 100644 --- a/shared/src/i18n/tr/members.ts +++ b/shared/src/i18n/tr/members.ts @@ -1,7 +1,6 @@ import type { TranslationStrings } from '../types'; const members: TranslationStrings = { - 'members.shareTrip': 'Seyahati Paylaş', 'members.inviteUser': 'Kullanıcı davet et', 'members.selectUser': 'Kullanıcı seç…', 'members.invite': 'Davet et', diff --git a/shared/src/i18n/tr/nav.ts b/shared/src/i18n/tr/nav.ts index 1c45a78e6..8c21d144d 100644 --- a/shared/src/i18n/tr/nav.ts +++ b/shared/src/i18n/tr/nav.ts @@ -2,7 +2,6 @@ import type { TranslationStrings } from '../types'; const nav: TranslationStrings = { 'nav.trip': 'Seyahat', - 'nav.share': 'Paylaş', 'nav.settings': 'Ayarlar', 'nav.admin': 'Yönetici', 'nav.logout': 'Çıkış yap', diff --git a/shared/src/i18n/tr/trip.ts b/shared/src/i18n/tr/trip.ts index 7638c08f3..0ef7267ba 100644 --- a/shared/src/i18n/tr/trip.ts +++ b/shared/src/i18n/tr/trip.ts @@ -100,5 +100,45 @@ const trip: TranslationStrings = { '“{name}” için ayrılan konaklama, “{booking}” rezervasyonu ve ona bağlı tüm harcamalar da silinir.', 'trip.confirm.deletePlaceBookedSame': '“{name}” için ayrılan konaklama, rezervasyonu ve ona bağlı tüm harcamalar da silinir.', + 'trip.settings.title': 'Seyahat ayarları', + 'trip.settings.people': 'Kişiler', + 'trip.settings.peopleHint': 'Bu seyahatte kimlerin olduğu ve hesabı olmayan misafirler.', + 'trip.settings.sharing': 'Paylaşım', + 'trip.settings.sharingHint': 'Herkes için salt okunur bir bağlantı ve katılmak için davet bağlantısı.', + 'trip.tabs.journal': 'Günlük', + 'trip.journal.title': 'Günlük', + 'trip.journal.hint': + 'Günlüklerinden birini bu seyahate bağla. Erişim verdiğin kişiler onu burada, Günlük sekmesinde canlı olarak açar.', + 'trip.journal.link': 'Günlük bağla', + 'trip.journal.linkSubmit': 'Günlüğü bağla', + 'trip.journal.change': 'Günlüğü değiştir', + 'trip.journal.editMembers': 'Erişimi düzenle', + 'trip.journal.unlink': 'Bağlantıyı kaldır', + 'trip.journal.unlinkTitle': 'Bu günlüğün bağlantısı kaldırılsın mı?', + 'trip.journal.unlinkMessage': + 'Günlük sekmesi bu seyahatten kaybolur. Günlüğü yalnızca bu seyahat üzerinden açabilen üyeler erişimini kaybeder; günlüğün kendisine eklenen kişiler erişimini korur.', + 'trip.journal.linkedBadge': 'Bağlı', + 'trip.journal.yourRole.owner': 'Senin günlüğün', + 'trip.journal.yourRole.editor': 'Düzenleyebilirsin', + 'trip.journal.yourRole.viewer': 'Okuyabilirsin', + 'trip.journal.onlyYou': 'Bu seyahatten henüz başka kimsenin erişimi yok.', + 'trip.journal.noJournals': 'Henüz bir günlüğün yok. Önce Seyahat bölümünde bir tane oluştur.', + 'trip.journal.pickPlaceholder': 'Bir günlük seç', + 'trip.journal.noMembers': 'Bu seyahatte henüz başka kimse yok.', + 'trip.journal.members': 'Seyahat üyeleri için erişim', + 'trip.journal.allRead': 'Herkes okuyabilir', + 'trip.journal.allNone': 'Hiç kimse', + 'trip.journal.roleNone': 'Erişim yok', + 'trip.journal.roleViewer': 'Okuma', + 'trip.journal.roleEditor': 'Düzenleme', + 'trip.journal.hasAccess': 'Erişimi var', + 'trip.journal.warnNew': + '{names} bu günlüğe ilk kez erişim kazanacak ve bundan sonra onu kendi günlük listesinde de görecek.', + 'trip.journal.errorOffline': 'Günlük bağlamak için bağlantı gerekir.', + 'trip.journal.errorForbidden': 'Bunu yapmaya yetkin yok.', + 'trip.journal.errorGeneric': 'Olmadı. Lütfen tekrar dene.', + 'trip.journal.linked': 'Günlük bağlandı', + 'trip.journal.membersSaved': 'Erişim kaydedildi', + 'trip.journal.unlinked': 'Günlük bağlantısı kaldırıldı', }; export default trip; diff --git a/shared/src/i18n/uk/members.ts b/shared/src/i18n/uk/members.ts index 526ac458b..e16517d06 100644 --- a/shared/src/i18n/uk/members.ts +++ b/shared/src/i18n/uk/members.ts @@ -1,7 +1,6 @@ import type { TranslationStrings } from '../types'; const members: TranslationStrings = { - 'members.shareTrip': 'Поділитися поїздкою', 'members.inviteUser': 'Запросити користувача', 'members.selectUser': 'Оберіть користувача…', 'members.invite': 'Запросити', diff --git a/shared/src/i18n/uk/nav.ts b/shared/src/i18n/uk/nav.ts index bc43eac81..60bfbae46 100644 --- a/shared/src/i18n/uk/nav.ts +++ b/shared/src/i18n/uk/nav.ts @@ -2,7 +2,6 @@ import type { TranslationStrings } from '../types'; const nav: TranslationStrings = { 'nav.trip': 'Поїздка', - 'nav.share': 'Поділитися', 'nav.settings': 'Налаштування', 'nav.admin': 'Адмін', 'nav.logout': 'Вийти', diff --git a/shared/src/i18n/uk/trip.ts b/shared/src/i18n/uk/trip.ts index 8a9242274..536191d1f 100644 --- a/shared/src/i18n/uk/trip.ts +++ b/shared/src/i18n/uk/trip.ts @@ -109,5 +109,45 @@ 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; diff --git a/shared/src/i18n/vi/members.ts b/shared/src/i18n/vi/members.ts index df6c850e9..954c21d82 100644 --- a/shared/src/i18n/vi/members.ts +++ b/shared/src/i18n/vi/members.ts @@ -1,7 +1,6 @@ import type { TranslationStrings } from '../types'; const members: TranslationStrings = { - 'members.shareTrip': 'Chia sẻ chuyến đi', 'members.inviteUser': 'Mời người dùng', 'members.selectUser': 'Chọn người dùng…', 'members.invite': 'Mời', diff --git a/shared/src/i18n/vi/nav.ts b/shared/src/i18n/vi/nav.ts index 0588172ab..a463c3c32 100644 --- a/shared/src/i18n/vi/nav.ts +++ b/shared/src/i18n/vi/nav.ts @@ -2,7 +2,6 @@ import type { TranslationStrings } from '../types'; const nav: TranslationStrings = { 'nav.trip': 'Chuyến đi', - 'nav.share': 'Chia sẻ', 'nav.settings': 'Cài đặt', 'nav.admin': 'Quản trị viên', 'nav.logout': 'Đăng xuất', diff --git a/shared/src/i18n/vi/trip.ts b/shared/src/i18n/vi/trip.ts index f5b665b7c..067a53613 100644 --- a/shared/src/i18n/vi/trip.ts +++ b/shared/src/i18n/vi/trip.ts @@ -94,5 +94,45 @@ const trip: TranslationStrings = { 'Thao tác này cũng xóa chỗ lưu trú đã đặt tại “{name}”, đặt chỗ “{booking}” và mọi chi phí liên quan.', 'trip.confirm.deletePlaceBookedSame': 'Thao tác này cũng xóa chỗ lưu trú đã đặt tại “{name}”, đặt chỗ của nó và mọi chi phí liên quan.', + 'trip.settings.title': 'Cài đặt chuyến đi', + 'trip.settings.people': 'Mọi người', + 'trip.settings.peopleHint': 'Những ai trong chuyến đi này, và khách không có tài khoản.', + 'trip.settings.sharing': 'Chia sẻ', + 'trip.settings.sharingHint': 'Liên kết chỉ đọc cho mọi người và liên kết mời để tham gia.', + 'trip.tabs.journal': 'Nhật ký', + 'trip.journal.title': 'Nhật ký', + 'trip.journal.hint': + 'Liên kết một nhật ký của bạn với chuyến đi này. Những người bạn cho phép sẽ mở nó ngay tại tab Nhật ký, trực tiếp.', + 'trip.journal.link': 'Liên kết nhật ký', + 'trip.journal.linkSubmit': 'Liên kết nhật ký', + 'trip.journal.change': 'Đổi nhật ký', + 'trip.journal.editMembers': 'Sửa quyền truy cập', + 'trip.journal.unlink': 'Hủy liên kết', + 'trip.journal.unlinkTitle': 'Hủy liên kết nhật ký này?', + 'trip.journal.unlinkMessage': + 'Tab Nhật ký biến mất khỏi chuyến đi này. Thành viên chỉ mở được nhật ký qua chuyến đi này sẽ mất quyền truy cập; người được thêm trực tiếp trong nhật ký vẫn giữ quyền.', + 'trip.journal.linkedBadge': 'Đã liên kết', + 'trip.journal.yourRole.owner': 'Nhật ký của bạn', + 'trip.journal.yourRole.editor': 'Bạn có thể sửa', + 'trip.journal.yourRole.viewer': 'Bạn có thể đọc', + 'trip.journal.onlyYou': 'Chưa có ai khác trong chuyến đi này có quyền truy cập.', + 'trip.journal.noJournals': 'Bạn chưa có nhật ký nào. Hãy tạo một nhật ký trong Hành trình trước.', + 'trip.journal.pickPlaceholder': 'Chọn nhật ký', + 'trip.journal.noMembers': 'Chưa có ai khác trong chuyến đi này.', + 'trip.journal.members': 'Quyền truy cập cho thành viên chuyến đi', + 'trip.journal.allRead': 'Mọi người đều đọc', + 'trip.journal.allNone': 'Không ai', + 'trip.journal.roleNone': 'Không có quyền', + 'trip.journal.roleViewer': 'Đọc', + 'trip.journal.roleEditor': 'Sửa', + 'trip.journal.hasAccess': 'Có quyền truy cập', + 'trip.journal.warnNew': + '{names} sẽ có quyền truy cập nhật ký này lần đầu và từ đó cũng thấy nó trong danh sách nhật ký của mình.', + 'trip.journal.errorOffline': 'Cần có kết nối để liên kết nhật ký.', + 'trip.journal.errorForbidden': 'Bạn không được phép làm điều đó.', + 'trip.journal.errorGeneric': 'Không thành công. Vui lòng thử lại.', + 'trip.journal.linked': 'Đã liên kết nhật ký', + 'trip.journal.membersSaved': 'Đã lưu quyền truy cập', + 'trip.journal.unlinked': 'Đã hủy liên kết nhật ký', }; export default trip; diff --git a/shared/src/i18n/zh-TW/members.ts b/shared/src/i18n/zh-TW/members.ts index 94ca45542..5881b1ca7 100644 --- a/shared/src/i18n/zh-TW/members.ts +++ b/shared/src/i18n/zh-TW/members.ts @@ -1,7 +1,6 @@ import type { TranslationStrings } from '../types'; const members: TranslationStrings = { - 'members.shareTrip': '分享旅行', 'members.inviteUser': '邀請使用者', 'members.selectUser': '選擇使用者…', 'members.invite': '邀請', diff --git a/shared/src/i18n/zh-TW/nav.ts b/shared/src/i18n/zh-TW/nav.ts index 615c65447..f42b98828 100644 --- a/shared/src/i18n/zh-TW/nav.ts +++ b/shared/src/i18n/zh-TW/nav.ts @@ -2,7 +2,6 @@ import type { TranslationStrings } from '../types'; const nav: TranslationStrings = { 'nav.trip': '旅行', - 'nav.share': '分享', 'nav.settings': '設定', 'nav.admin': '管理', 'nav.logout': '退出登入', diff --git a/shared/src/i18n/zh-TW/trip.ts b/shared/src/i18n/zh-TW/trip.ts index 63b20377d..959d55791 100644 --- a/shared/src/i18n/zh-TW/trip.ts +++ b/shared/src/i18n/zh-TW/trip.ts @@ -91,5 +91,43 @@ const trip: TranslationStrings = { 'trip.confirm.deletePlaceNight': '在「{name}」預訂的住宿也會一併刪除。', 'trip.confirm.deletePlaceBooked': '在「{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': '你還沒有日誌。請先在旅程中建立一本。', + '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; diff --git a/shared/src/i18n/zh/members.ts b/shared/src/i18n/zh/members.ts index 41b98753d..ae1d46b4d 100644 --- a/shared/src/i18n/zh/members.ts +++ b/shared/src/i18n/zh/members.ts @@ -1,7 +1,6 @@ import type { TranslationStrings } from '../types'; const members: TranslationStrings = { - 'members.shareTrip': '分享旅行', 'members.inviteUser': '邀请用户', 'members.selectUser': '选择用户…', 'members.invite': '邀请', diff --git a/shared/src/i18n/zh/nav.ts b/shared/src/i18n/zh/nav.ts index 2671d3d8d..b67fb1097 100644 --- a/shared/src/i18n/zh/nav.ts +++ b/shared/src/i18n/zh/nav.ts @@ -2,7 +2,6 @@ import type { TranslationStrings } from '../types'; const nav: TranslationStrings = { 'nav.trip': '旅行', - 'nav.share': '分享', 'nav.settings': '设置', 'nav.admin': '管理', 'nav.logout': '退出登录', diff --git a/shared/src/i18n/zh/trip.ts b/shared/src/i18n/zh/trip.ts index 696cf961a..a0808ff44 100644 --- a/shared/src/i18n/zh/trip.ts +++ b/shared/src/i18n/zh/trip.ts @@ -91,5 +91,43 @@ const trip: TranslationStrings = { 'trip.confirm.deletePlaceNight': '在“{name}”预订的住宿也会一并删除。', 'trip.confirm.deletePlaceBooked': '在“{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': '你还没有日志。请先在旅程中创建一本。', + '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;