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.
This commit is contained in:
Maurice
2026-10-18 17:41:25 +02:00
parent 89f3055a29
commit fe05afec19
109 changed files with 2707 additions and 156 deletions
+31
View File
@@ -0,0 +1,31 @@
import type {
TripJournalCandidatesResponse,
TripJournalLinkMemberRequest,
TripJournalLinkResponse,
} from '@trek/shared';
import apiClient from './client';
/**
* `/api/trips/:tripId/journal`: the journal from the Journey addon a trip shows,
* and who it is open to. In its own file rather than in the `client.ts` god
* module; repo/tripJournalLinkRepo.ts is the one caller.
*/
export const tripJournalApi = {
get: (tripId: number): Promise<TripJournalLinkResponse> =>
apiClient.get(`/trips/${tripId}/journal`).then((r) => r.data),
candidates: (tripId: number, journeyId: number): Promise<TripJournalCandidatesResponse> =>
apiClient.get(`/trips/${tripId}/journal/candidates`, { params: { journey_id: journeyId } }).then((r) => r.data),
link: (
tripId: number,
journeyId: number,
members: TripJournalLinkMemberRequest[]
): Promise<TripJournalLinkResponse> =>
apiClient.put(`/trips/${tripId}/journal`, { journey_id: journeyId, members }).then((r) => r.data),
setMembers: (tripId: number, members: TripJournalLinkMemberRequest[]): Promise<TripJournalLinkResponse> =>
apiClient.patch(`/trips/${tripId}/journal/members`, { members }).then((r) => r.data),
unlink: (tripId: number): Promise<TripJournalLinkResponse> =>
apiClient.delete(`/trips/${tripId}/journal`).then((r) => r.data),
/** The journals this person can open; the dialog offers the ones they own. */
journals: (): Promise<{ journeys: { id: number; user_id: number; title: string; subtitle?: string | null }[] }> =>
apiClient.get('/journeys').then((r) => r.data),
};
+4
View File
@@ -93,6 +93,10 @@ export const HANDLED_OUTSIDE_TRIP_STORE = [
// the bag ping: what a run changed is per-binding server state, and the
// files it moved arrive as file:* events.
'docsync:changed',
// Linked journal: components/Trips/useTripJournalLink asks again which journal
// the trip shows and who it is open to. Content-free, because that answer is
// per person (a member left off the link must not learn its title).
'trip:journal:changed',
// Booking import — BackgroundTasks/BackgroundTasksWidget ('import:' prefix listener)
'import:progress',
'import:done',
+14 -18
View File
@@ -169,23 +169,24 @@ describe('Navbar', () => {
expect(adminLink).toHaveAttribute('href', '/admin');
});
it('FE-COMP-NAVBAR-019: share button rendered when onShare prop provided', () => {
render(<Navbar onShare={vi.fn()} />);
const shareBtn = screen.getByRole('button', { name: /share/i });
expect(shareBtn).toBeInTheDocument();
it('FE-COMP-NAVBAR-019: the trip settings button sits beside the back pill when onSettings is given', () => {
render(<Navbar tripTitle="Japan" showBack onBack={vi.fn()} onSettings={vi.fn()} />);
const back = screen.getByRole('button', { name: /back/i });
const settings = screen.getByRole('button', { name: 'Trip settings' });
expect(back.parentElement).toBe(settings.closest('div'));
});
it('FE-COMP-NAVBAR-020: share button click calls onShare', async () => {
it('FE-COMP-NAVBAR-020: the trip settings button calls onSettings', async () => {
const user = userEvent.setup();
const onShare = vi.fn();
render(<Navbar onShare={onShare} />);
const shareBtn = screen.getByRole('button', { name: /share/i });
await user.click(shareBtn);
expect(onShare).toHaveBeenCalled();
const onSettings = vi.fn();
render(<Navbar onSettings={onSettings} />);
await user.click(screen.getByRole('button', { name: 'Trip settings' }));
expect(onSettings).toHaveBeenCalled();
});
it('FE-COMP-NAVBAR-021: share button NOT rendered when onShare prop omitted', () => {
it('FE-COMP-NAVBAR-021: no trip settings button without onSettings, and no share button any more', () => {
render(<Navbar />);
expect(screen.queryByRole('button', { name: 'Trip settings' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: /share/i })).not.toBeInTheDocument();
});
@@ -453,8 +454,8 @@ describe('Navbar styling and menu details', () => {
expect(screen.queryByText('3.5.0-rc1')).not.toBeInTheDocument();
});
it('FE-W5NAV-009: the back, share and theme buttons reset their hover background', () => {
render(<Navbar showBack onBack={vi.fn()} onShare={vi.fn()} />);
it('FE-W5NAV-009: the back and theme buttons reset their hover background', () => {
render(<Navbar showBack onBack={vi.fn()} onSettings={vi.fn()} />);
const back = screen.getByRole('button', { name: /back/i });
fireEvent.mouseEnter(back);
@@ -462,11 +463,6 @@ describe('Navbar styling and menu details', () => {
fireEvent.mouseLeave(back);
expect(back.style.background).toBe('transparent');
const share = screen.getByRole('button', { name: /share/i });
fireEvent.mouseEnter(share);
expect(share.style.background).toBe('var(--bg-hover)');
fireEvent.mouseLeave(share);
expect(share.style.background).toBe('var(--bg-card)');
const theme = document.querySelector('button[title]:not([data-help-button])') as HTMLElement;
fireEvent.mouseEnter(theme);
+20 -14
View File
@@ -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 }:
<span className="truncate text-caption font-semibold text-content">{tripTitle}</span>
</span>
))}
{/* The trip's Settings sit with the trip, beside its pill: they are about this
trip, not about the app the right side of the bar belongs to. */}
{onSettings && (
<Tooltip label={t('trip.settings.title')}>
<button
type="button"
onClick={onSettings}
aria-label={t('trip.settings.title')}
className="inline-flex h-[30px] w-[30px] flex-none items-center justify-center rounded-full bg-surface-card text-content-muted shadow-sm ring-1 ring-inset ring-edge-faint transition-colors hover:bg-surface-hover hover:text-content focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
>
<Settings className="h-4 w-4" aria-hidden="true" />
</button>
</Tooltip>
)}
</div>
{/* 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. */}
<div className="flex items-center gap-4 flex-1 basis-0 min-w-max justify-end">
{/* Share button */}
{onShare && (
<button type="button" onClick={onShare}
className="flex items-center gap-1.5 py-1.5 px-3 rounded-lg border transition-colors text-sm font-medium flex-shrink-0 border-edge text-content-secondary bg-surface-card"
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-card)'}>
<Users className="w-4 h-4" />
<span className="hidden sm:inline">{t('nav.share')}</span>
</button>
)}
{/* Contextual help for the current screen — same footprint as the theme toggle beside it */}
{user && <HelpButton />}
@@ -0,0 +1,37 @@
import { BookOpen, Link2 } from 'lucide-react';
/**
* The trip's Journal tab: a book with a link on its corner, because the journal
* is not the trip's own but one from the Journey addon tied to it. Takes the
* props the tab bars hand a lucide icon.
*
* The book is masked out under the link rather than the link given a backing:
* a backing has to match whatever the icon sits on (a card, the accent of a
* picked tab), and the moment it does not, the link vanishes into it.
*/
export function LinkedJournalIcon({
size = 16,
strokeWidth = 2,
className,
}: {
size?: number;
strokeWidth?: number;
className?: string;
}) {
const badge = Math.round(size * 0.6);
// The hole: a circle round the link's centre, a little wider than the link.
const hole = `radial-gradient(circle at calc(100% + 2px) calc(100% + 1px), transparent ${badge * 0.62}px, currentColor ${badge * 0.62 + 0.5}px)`;
return (
// dir="ltr": the hole is cut on the right, so the link has to stay there in Arabic too.
<span
dir="ltr"
className={`relative inline-flex flex-none ${className ?? ''}`}
style={{ width: size, height: size }}
>
<span className="inline-flex" style={{ maskImage: hole, WebkitMaskImage: hole }}>
<BookOpen size={size} strokeWidth={strokeWidth} />
</span>
<Link2 size={badge} strokeWidth={strokeWidth + 0.6} className="absolute -bottom-[3px] -end-[4px]" />
</span>
);
}
@@ -0,0 +1,150 @@
// FE-TJL-SECTION-001 to FE-TJL-SECTION-007
/**
* The Journal part of the trip's Settings, rendered: what someone sees with no
* journal linked, with one linked, while picking, and the warning that names
* the people a save would let in for the first time.
*/
import userEvent from '@testing-library/user-event';
import type { TripJournalCandidate, TripJournalLinkResponse } from '@trek/shared';
import { http, HttpResponse } from 'msw';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { buildUser } from '../../../tests/helpers/factories';
import { server } from '../../../tests/helpers/msw/server';
import { render, screen, waitFor } from '../../../tests/helpers/render';
import { seedStore } from '../../../tests/helpers/store';
import { useAddonStore } from '../../store/addonStore';
import { useAuthStore } from '../../store/authStore';
import { useTripJournalLinkStore } from '../../store/tripJournalLinkStore';
import { LinkedJournalIcon } from './LinkedJournalIcon';
import { TripJournalLinkSection } from './TripJournalLinkSection';
const toast = { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() };
vi.mock('../shared/Toast', () => ({ useToast: () => toast }));
const TRIP = 4;
const EMPTY: TripJournalLinkResponse = { link: null, can_link: true, can_unlink: false, can_edit_members: false };
const LINKED: TripJournalLinkResponse = {
link: {
journey_id: 9,
title: 'Japan',
subtitle: null,
cover_image: 'covers/j.jpg',
owner_id: 1,
linked_by: 1,
created_at: 1,
my_role: 'owner',
members: [{ user_id: 2, role: 'editor', username: 'lena', avatar_url: null }],
},
can_link: true,
can_unlink: true,
can_edit_members: true,
};
const CANDIDATES: TripJournalCandidate[] = [
{ user_id: 2, username: 'lena', avatar_url: null, is_trip_owner: true, access: 'editor' },
{ user_id: 3, username: 'tom', avatar_url: null, is_trip_owner: false, access: null },
];
let answer: TripJournalLinkResponse = EMPTY;
let deletes = 0;
beforeEach(() => {
answer = EMPTY;
deletes = 0;
vi.clearAllMocks();
useTripJournalLinkStore.setState({ byTrip: {} });
seedStore(useAuthStore, { user: buildUser({ id: 1 }) });
useAddonStore.setState({
loaded: true,
addons: [{ id: 'journey', name: 'Journey', type: 'global', icon: 'BookOpen', enabled: true }],
});
server.use(
http.get('/api/trips/:tripId/journal', () => HttpResponse.json(answer)),
http.get('/api/trips/:tripId/journal/candidates', () => HttpResponse.json({ candidates: CANDIDATES })),
http.get('/api/journeys', () => HttpResponse.json({ journeys: [{ id: 9, user_id: 1, title: 'Japan' }] })),
http.delete('/api/trips/:tripId/journal', () => {
deletes += 1;
return HttpResponse.json(EMPTY);
})
);
});
describe('TripJournalLinkSection', () => {
it('FE-TJL-SECTION-001: renders nothing with the Journey addon off', async () => {
useAddonStore.setState({ addons: [] });
const { container } = render(<TripJournalLinkSection tripId={TRIP} />);
await new Promise((r) => setTimeout(r, 30));
expect(container).toBeEmptyDOMElement();
});
it('FE-TJL-SECTION-002: with no journal linked it offers to link one, in its own card', async () => {
render(<TripJournalLinkSection tripId={TRIP} />);
expect(await screen.findByText('Link a journal')).toBeInTheDocument();
expect(screen.getByText('Journal')).toBeInTheDocument();
});
it('FE-TJL-SECTION-003: picking a journal lists the trip and warns about who gets in for the first time', async () => {
const user = userEvent.setup();
render(<TripJournalLinkSection tripId={TRIP} framed={false} />);
await user.click(await screen.findByText('Link a journal'));
await user.click(await screen.findByText('Choose a journal'));
// The select opens its list in a portal, one button per journal.
await user.click(await screen.findByRole('button', { name: 'Japan' }));
expect(await screen.findByText('tom')).toBeInTheDocument();
expect(screen.getByText('Has access')).toBeInTheDocument();
expect(screen.getByRole('note')).toHaveTextContent('tom will get access to this journal for the first time');
await user.click(screen.getAllByRole('radio', { name: 'No access' })[1]);
expect(screen.queryByRole('note')).not.toBeInTheDocument();
await user.click(screen.getByText('Everyone can read'));
expect(screen.getByRole('note')).toBeInTheDocument();
await user.click(screen.getByText('Nobody'));
expect(screen.queryByRole('note')).not.toBeInTheDocument();
await user.click(screen.getByText('Cancel'));
expect(screen.getByText('Link a journal')).toBeInTheDocument();
});
it('FE-TJL-SECTION-004: a linked journal shows its cover, my role and who it is open to', async () => {
answer = LINKED;
render(<TripJournalLinkSection tripId={TRIP} />);
expect(await screen.findByText('Japan')).toBeInTheDocument();
expect(screen.getByText('Your journal')).toBeInTheDocument();
expect(screen.getByText('Linked')).toBeInTheDocument();
expect(screen.getByText('lena')).toBeInTheDocument();
expect(document.querySelector('img[src="/uploads/covers/j.jpg"]')).not.toBeNull();
});
it('FE-TJL-SECTION-005: unlinking asks first, then takes it off', async () => {
const user = userEvent.setup();
answer = LINKED;
render(<TripJournalLinkSection tripId={TRIP} />);
await user.click(await screen.findByRole('button', { name: 'Unlink' }));
expect(screen.getByText('Unlink this journal?')).toBeInTheDocument();
const buttons = screen.getAllByRole('button', { name: 'Unlink' });
await user.click(buttons[buttons.length - 1]);
await waitFor(() => expect(deletes).toBe(1));
expect(await screen.findByText('Link a journal')).toBeInTheDocument();
});
it('FE-TJL-SECTION-006: editing access lists the trip with each one as the link holds them', async () => {
const user = userEvent.setup();
answer = { ...LINKED, link: LINKED.link && { ...LINKED.link, members: [] } };
render(<TripJournalLinkSection tripId={TRIP} />);
expect(await screen.findByText('Nobody else from this trip has access yet.')).toBeInTheDocument();
await user.click(screen.getByText('Edit access'));
expect(await screen.findByText('tom')).toBeInTheDocument();
expect(screen.getByText('Owner')).toBeInTheDocument();
expect(
screen.getAllByRole('radio', { name: 'No access' }).every((r) => r.getAttribute('aria-checked') === 'true')
).toBe(true);
});
it('FE-TJL-SECTION-007: the linked-journal icon draws a book with the link on its corner', () => {
const { container } = render(<LinkedJournalIcon size={20} className="x" />);
const root = container.firstElementChild as HTMLElement;
expect(root.getAttribute('dir')).toBe('ltr');
expect(root.style.width).toBe('20px');
expect(container.querySelectorAll('svg')).toHaveLength(2);
});
});
@@ -0,0 +1,324 @@
import type { TripJournalCandidate, TripJournalLinkRole } from '@trek/shared';
import { AlertTriangle, BookOpen, Link2, Unlink, Users } from 'lucide-react';
import { useState, type ReactNode } from 'react';
import { useTranslation } from '../../i18n';
import ConfirmDialog from '../shared/ConfirmDialog';
import CustomSelect from '../shared/CustomSelect';
import { DialogButton, fs } from '../shared/DialogShell';
import { AddRowButton } from '../shared/dialogParts';
import { LinkedJournalIcon } from './LinkedJournalIcon';
import { TripMemberAvatar } from './TripMemberAvatar';
import { LinkCard } from './TripShareDialog';
import { useTripJournalLink, type JournalMemberChoice } from './useTripJournalLink';
const ROWS = 'flex flex-col gap-0.5 rounded-[14px] border border-edge-faint bg-surface-secondary p-1.5';
const ROW = 'flex min-h-[48px] items-center gap-3 rounded-[10px] px-2.5 py-1.5';
const SEGMENT = 'rounded-full px-2.5 py-1 font-semibold';
const ROLES: { value: TripJournalLinkRole | null; labelKey: string }[] = [
{ value: null, labelKey: 'trip.journal.roleNone' },
{ value: 'viewer', labelKey: 'trip.journal.roleViewer' },
{ value: 'editor', labelKey: 'trip.journal.roleEditor' },
];
/**
* The Journal part of the trip's Settings, for the desktop dialog and the phone
* sheet alike: the journal the trip shows, and who of the trip it is open to.
* Hidden with the Journey addon off, and for someone who can neither link one
* nor see one that is linked.
*/
export function TripJournalLinkSection({ tripId, framed = true }: { tripId: number; framed?: boolean }) {
const { t } = useTranslation();
const j = useTripJournalLink(tripId);
const [askUnlink, setAskUnlink] = useState(false);
if (!j.visible) return null;
// The desktop dialog names the area above it already; the phone sheet gets the card with its own title.
const Frame = framed ? JournalCard : Bare;
return (
<>
<Frame>
{j.mode === 'view' && j.link && <LinkedJournal j={j} />}
{j.mode === 'view' && !j.link && j.canLink && (
<AddRowButton onClick={j.startPick}>{t('trip.journal.link')}</AddRowButton>
)}
{j.mode === 'view' && j.link && (
<div className="flex flex-wrap gap-2">
{j.canEditMembers && (
<DialogButton onClick={j.editMembers} icon={<Users size={14} strokeWidth={2.2} />}>
{t('trip.journal.editMembers')}
</DialogButton>
)}
{j.canLink && (
<DialogButton onClick={j.startPick} icon={<BookOpen size={14} strokeWidth={2.2} />}>
{t('trip.journal.change')}
</DialogButton>
)}
{j.canUnlink && (
<button
type="button"
onClick={() => setAskUnlink(true)}
disabled={j.busy}
className="inline-flex items-center gap-1.5 rounded-[10px] bg-surface-card px-3.5 py-2 font-medium text-danger shadow-sm ring-1 ring-edge-faint hover:bg-danger-soft disabled:opacity-50"
style={fs(12.5, 'body')}
>
<Unlink size={13} strokeWidth={2.2} />
{t('trip.journal.unlink')}
</button>
)}
</div>
)}
{j.mode === 'pick' && <JournalPicker j={j} />}
{(j.mode === 'members' || (j.mode === 'pick' && j.pickedId != null)) && <MemberChooser j={j} />}
{j.mode !== 'view' && (
<div className="flex justify-end gap-2">
<DialogButton onClick={j.cancel} disabled={j.busy}>
{t('common.cancel')}
</DialogButton>
<DialogButton
variant="primary"
onClick={() => void j.save()}
disabled={!j.canSave}
icon={<Link2 size={14} strokeWidth={2.2} />}
>
{j.mode === 'pick' ? t('trip.journal.linkSubmit') : t('common.save')}
</DialogButton>
</div>
)}
</Frame>
<ConfirmDialog
isOpen={askUnlink}
onClose={() => setAskUnlink(false)}
onConfirm={() => {
setAskUnlink(false);
void j.unlink();
}}
title={t('trip.journal.unlinkTitle')}
message={t('trip.journal.unlinkMessage')}
confirmLabel={t('trip.journal.unlink')}
/>
</>
);
}
function JournalCard({ children }: { children: ReactNode }) {
const { t } = useTranslation();
return (
<LinkCard
icon={<LinkedJournalIcon size={15} strokeWidth={2.2} />}
title={t('trip.journal.title')}
hint={t('trip.journal.hint')}
>
{children}
</LinkCard>
);
}
function Bare({ children }: { children: ReactNode }) {
return <div className="flex flex-col gap-4">{children}</div>;
}
type JournalLink = ReturnType<typeof useTripJournalLink>;
/** The linked journal: cover, title, what the reader may do in it, and who it is open to. */
function LinkedJournal({ j }: { j: JournalLink }) {
const { t } = useTranslation();
const link = j.link;
if (!link) return null;
return (
<div className="flex flex-col gap-2.5">
<div className="flex items-center gap-3 rounded-[14px] border border-edge-faint bg-surface-card p-2.5">
<span className="grid h-11 w-11 flex-none place-items-center overflow-hidden rounded-[10px] bg-surface-tertiary text-content-muted">
{link.cover_image ? (
<img src={`/uploads/${link.cover_image}`} alt="" className="h-full w-full object-cover" />
) : (
<BookOpen size={18} strokeWidth={2} />
)}
</span>
<div className="min-w-0 flex-1">
<div className="truncate font-semibold text-content" style={fs(13.5, 'body')}>
{link.title}
</div>
{link.my_role && (
<div className="truncate text-content-faint" style={fs(11.5)}>
{t(`trip.journal.yourRole.${link.my_role}`)}
</div>
)}
</div>
<span
className="inline-flex flex-none items-center gap-1 rounded-full bg-success-soft px-2 py-[2px] font-semibold text-success"
style={fs(10.5)}
>
<Link2 size={10} strokeWidth={2.6} />
{t('trip.journal.linkedBadge')}
</span>
</div>
{link.members && link.members.length > 0 && (
<div className="flex flex-wrap items-center gap-1.5">
{link.members.map((m) => (
<span
key={m.user_id}
className="inline-flex items-center gap-1.5 rounded-full bg-surface-card py-[3px] pe-2.5 ps-[3px] shadow-sm ring-1 ring-edge-faint"
style={fs(11.5)}
>
<TripMemberAvatar username={m.username} avatarUrl={m.avatar_url} size={20} />
<span className="font-medium text-content">{m.username}</span>
<span className="text-content-faint">
{t(m.role === 'editor' ? 'trip.journal.roleEditor' : 'trip.journal.roleViewer')}
</span>
</span>
))}
</div>
)}
{link.members && link.members.length === 0 && (
<p className="m-0 text-content-faint" style={fs(11.5)}>
{t('trip.journal.onlyYou')}
</p>
)}
</div>
);
}
/** The journals of one's own to choose from; one only contributed to is not one's to give away. */
function JournalPicker({ j }: { j: JournalLink }) {
const { t } = useTranslation();
if (j.journals == null) return <div className="h-10 animate-pulse rounded-[10px] bg-surface-tertiary" />;
if (j.journals.length === 0)
return (
<p className="m-0 text-content-faint" style={fs(12)}>
{t('trip.journal.noJournals')}
</p>
);
return (
<CustomSelect
value={j.pickedId == null ? '' : String(j.pickedId)}
onChange={(value) => j.pickJournal(value === '' ? null : Number(value))}
placeholder={t('trip.journal.pickPlaceholder')}
options={[
{ value: '', label: t('trip.journal.pickPlaceholder') },
...j.journals.map((journal) => ({ value: String(journal.id), label: journal.title })),
]}
searchable
size="sm"
/>
);
}
/**
* Everyone of the trip, each with no access, read or edit. Before saving, the
* people the link would let in for the first time are named: from then on they
* see the journal in their own Journey list as well.
*/
function MemberChooser({ j }: { j: JournalLink }) {
const { t } = useTranslation();
if (j.candidates == null) return <div className="h-24 animate-pulse rounded-[14px] bg-surface-tertiary" />;
if (j.candidates.length === 0)
return (
<p className="m-0 text-content-faint" style={fs(12)}>
{t('trip.journal.noMembers')}
</p>
);
return (
<div className="flex flex-col gap-2.5">
<div className="flex items-center justify-between gap-2">
<span className="font-semibold text-content-secondary" style={fs(12, 'body')}>
{t('trip.journal.members')}
</span>
<div className="flex gap-1.5">
<button
type="button"
onClick={() => j.setAll('viewer')}
className="text-content-muted hover:text-content"
style={fs(11.5)}
>
{t('trip.journal.allRead')}
</button>
<span className="text-content-faint" aria-hidden>
/
</span>
<button
type="button"
onClick={() => j.setAll(null)}
className="text-content-muted hover:text-content"
style={fs(11.5)}
>
{t('trip.journal.allNone')}
</button>
</div>
</div>
<div className={ROWS}>
{j.candidates.map((c) => (
<MemberRow key={c.user_id} c={c} choice={j.choice} onRole={j.setRole} showAccess={j.mode === 'pick'} />
))}
</div>
{j.newlyGranted.length > 0 && (
<div role="note" className="flex items-start gap-2 rounded-[12px] bg-warning-soft px-3 py-2.5 text-warning">
<AlertTriangle size={15} strokeWidth={2.2} className="mt-px flex-none" />
<p className="m-0 leading-normal" style={fs(12)}>
{t('trip.journal.warnNew', {
count: j.newlyGranted.length,
names: j.newlyGranted.map((c) => c.username).join(', '),
})}
</p>
</div>
)}
</div>
);
}
function MemberRow({
c,
choice,
onRole,
showAccess,
}: {
c: TripJournalCandidate;
choice: JournalMemberChoice;
onRole: (userId: number, role: TripJournalLinkRole | null) => void;
showAccess: boolean;
}) {
const { t } = useTranslation();
const current = choice[c.user_id] ?? null;
return (
<div className={ROW}>
<TripMemberAvatar username={c.username} avatarUrl={c.avatar_url} />
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-1.5">
<span className="truncate font-semibold text-content" style={fs(13, 'body')}>
{c.username}
</span>
{c.is_trip_owner && (
<span
className="rounded-full bg-surface-tertiary px-2 py-[2px] font-semibold text-content-muted"
style={fs(10.5)}
>
{t('members.owner')}
</span>
)}
{showAccess && c.access && (
<span className="rounded-full bg-success-soft px-2 py-[2px] font-semibold text-success" style={fs(10.5)}>
{t('trip.journal.hasAccess')}
</span>
)}
</div>
<div
role="radiogroup"
aria-label={c.username}
className="flex flex-none gap-0.5 rounded-full bg-surface-card p-0.5 ring-1 ring-edge-faint"
>
{ROLES.map((r) => (
<button
key={r.labelKey}
type="button"
role="radio"
aria-checked={current === r.value}
onClick={() => onRole(c.user_id, r.value)}
className={`${SEGMENT} ${current === r.value ? 'bg-accent text-accent-text' : 'text-content-muted hover:text-content'}`}
style={fs(11, 'body')}
>
{t(r.labelKey)}
</button>
))}
</div>
</div>
);
}
@@ -122,10 +122,10 @@ describe('TripMembersModal', () => {
expect(document.body).toBeInTheDocument();
});
it('FE-COMP-MEMBERS-002: shows Share Trip title', () => {
it('FE-COMP-MEMBERS-002: shows the Trip settings title', () => {
render(<TripMembersModal {...defaultProps} />);
// members.shareTrip = "Share Trip"
expect(screen.getByText('Share Trip')).toBeInTheDocument();
// trip.settings.title = "Trip settings"
expect(screen.getByText('Trip settings')).toBeInTheDocument();
});
it('FE-COMP-MEMBERS-003: shows owner username after load', async () => {
@@ -245,7 +245,7 @@ describe('TripMembersModal', () => {
it('FE-COMP-MEMBERS-015: modal renders when isOpen is true', () => {
render(<TripMembersModal {...defaultProps} isOpen={true} />);
expect(screen.getByText('Share Trip')).toBeInTheDocument();
expect(screen.getByText('Trip settings')).toBeInTheDocument();
});
// ── Share Link Section (016-021) ───────────────────────────────────────────
@@ -1,3 +1,4 @@
import { TripJournalLinkSection } from './TripJournalLinkSection'
import Modal from '../shared/Modal'
import { Crown, UserMinus, UserPlus, Users, LogOut, Link2, Trash2, Copy, Check, UserRound, Pencil, Plus } from 'lucide-react'
import { useTranslation } from '../../i18n'
@@ -181,7 +182,7 @@ function TripMembersSheet({ isOpen, onClose, tripId, tripTitle, onMembersChanged
}
return (
<Modal isOpen={isOpen} onClose={onClose} title={t('members.shareTrip')} size="3xl">
<Modal isOpen={isOpen} onClose={onClose} title={t('trip.settings.title')} size="3xl">
<div style={{ display: 'grid', gridTemplateColumns: canManageShare ? '1fr 1fr' : '1fr', gap: 24, fontFamily: "var(--font-system)" }} className="share-modal-grid">
<style>{`@media (max-width: 640px) { .share-modal-grid { grid-template-columns: 1fr !important; } }`}</style>
@@ -402,6 +403,8 @@ function TripMembersSheet({ isOpen, onClose, tripId, tripTitle, onMembersChanged
<TripInviteLinkSection tripId={tripId} t={t} />
</div>}
<TripJournalLinkSection tripId={tripId} />
<style>{`@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.5} }`}</style>
</div>
</Modal>
+82 -11
View File
@@ -1,12 +1,15 @@
import { useId, useState, type ReactNode } from 'react'
import { Check, Copy, Crown, Link2, LogOut, Pencil, Plus, Trash2, UserMinus, UserPlus, UserRound, Users } from 'lucide-react'
import { Check, Copy, Crown, Link2, LogOut, Pencil, Plus, Settings, Trash2, UserMinus, UserPlus, UserRound, Users } from 'lucide-react'
import { useTranslation } from '../../i18n'
import CustomSelect from '../shared/CustomSelect'
import ConfirmDialog from '../shared/ConfirmDialog'
import { Tooltip } from '../shared/Tooltip'
import { DialogButton, DialogHeader, DialogSection, DialogShell, DialogTile, NEUTRAL_TINT, PILL, fs } from '../shared/DialogShell'
import { AddRowButton, INPUT, LABEL, PANEL } from '../shared/dialogParts'
import { LinkedJournalIcon } from './LinkedJournalIcon'
import { TripJournalLinkSection } from './TripJournalLinkSection'
import { TripMemberAvatar } from './TripMemberAvatar'
import { useTripJournalSectionVisible } from './useTripJournalLink'
import { SHARE_OPTIONS, SHARE_SECTIONS, useShareLink, useTripInviteLink, useTripMembers, type TripMembersState } from './useTripShare'
interface TripShareDialogProps {
@@ -29,18 +32,38 @@ const COUNT = 'rounded-full bg-surface-tertiary px-2 py-[2px] font-geist font-bo
const ROWS = 'flex flex-col gap-0.5 rounded-[14px] border border-edge-faint bg-surface-secondary p-1.5'
const ROW = 'flex min-h-[48px] items-center gap-3 rounded-[10px] px-2.5 py-1.5 hover:bg-surface-card'
const BADGE = 'inline-flex flex-none items-center gap-1 rounded-full px-2 py-[2px] font-semibold'
/** A fixed height on a wide screen, so the dialog does not jump when another area is picked. */
const BODY = 'flex min-h-0 flex-1 max-md:flex-col md:h-[min(640px,calc(100dvh-240px))]'
const NAV = 'flex flex-none gap-1 border-edge-faint bg-surface-secondary p-3 md:w-[236px] md:flex-col md:border-e max-md:overflow-x-auto max-md:border-b'
const DANGER_BUTTON = 'inline-flex items-center gap-1.5 rounded-[10px] bg-surface-card px-3.5 py-2 font-medium text-danger shadow-sm ring-1 ring-edge-faint hover:bg-danger-soft disabled:cursor-default disabled:opacity-50'
/** The desktop dialog behind Share: who is on the trip, its guests, and the two links that let others in. */
/**
* The trip's Settings on the desktop: who is on the trip, its guests, the two
* links that let others in, and the journal the trip shows.
*/
export default function TripShareDialog(props: TripShareDialogProps) {
return props.isOpen ? <OpenDialog {...props} /> : null
}
type Area = 'people' | 'sharing' | 'journal'
/** The areas of the dialog, each with its icon, its name and the line under the name. */
const AREAS: { id: Area; icon: (on: boolean) => ReactNode; labelKey: string; hintKey: string }[] = [
{ id: 'people', icon: () => <Users size={16} strokeWidth={2} />, labelKey: 'trip.settings.people', hintKey: 'trip.settings.peopleHint' },
{ id: 'sharing', icon: () => <Link2 size={16} strokeWidth={2} />, labelKey: 'trip.settings.sharing', hintKey: 'trip.settings.sharingHint' },
{ id: 'journal', icon: () => <LinkedJournalIcon size={16} />, labelKey: 'trip.journal.title', hintKey: 'trip.journal.hint' },
]
function OpenDialog({ onClose, tripId, tripTitle, onMembersChanged }: TripShareDialogProps) {
const { t } = useTranslation()
const labelId = useId()
const m = useTripMembers({ isOpen: true, tripId, onClose, onMembersChanged })
const [ask, setAsk] = useState<Ask | null>(null)
const [area, setArea] = useState<Area>('people')
const journalSection = useTripJournalSectionVisible(tripId)
const areas = AREAS.filter(a => a.id === 'people' || (a.id === 'sharing' ? m.canManageShare : journalSection))
const current = areas.find(a => a.id === area) ?? areas[0]
const panelOf = (id: Area) => areas.find(a => a.id === id)
const persons = `${m.realMembers.length} ${t('members.persons', { count: m.realMembers.length })}`
return (
@@ -48,15 +71,17 @@ function OpenDialog({ onClose, tripId, tripTitle, onMembersChanged }: TripShareD
<DialogShell
onClose={onClose}
labelledBy={labelId}
width={m.canManageShare ? 'wide' : 'detail'}
width={areas.length > 1 ? 'wide' : 'detail'}
align="top"
blocked={!!ask}
bodyClassName={BODY}
header={(
<DialogHeader
tile={<DialogTile><Users size={20} strokeWidth={1.9} className="text-content-muted" /></DialogTile>}
tile={<DialogTile><Settings size={20} strokeWidth={1.9} className="text-content-muted" /></DialogTile>}
tint={NEUTRAL_TINT}
labelId={labelId}
onClose={onClose}
eyebrow={t('members.shareTrip')}
eyebrow={t('trip.settings.title')}
title={tripTitle}
pills={m.loading ? undefined : (
<>
@@ -67,17 +92,24 @@ function OpenDialog({ onClose, tripId, tripTitle, onMembersChanged }: TripShareD
/>
)}
>
<div className={m.canManageShare ? 'grid grid-cols-2 items-start gap-6 max-md:grid-cols-1' : ''}>
<div className="flex flex-col gap-5">
{areas.length > 1 && <AreaNav areas={areas} current={current.id} onPick={setArea} />}
<div className="min-h-0 flex-1 overflow-y-auto px-7 pb-7 pt-6 max-sm:px-5">
{/* Every area stays mounted, so its links and lists are loaded by the time it is picked. */}
<AreaPanel area={panelOf('people')} shown={current.id === 'people'} titled={areas.length > 1}>
{m.canManageMembers && <InviteUser m={m} />}
<MembersList m={m} onAsk={setAsk} />
{(m.isCurrentOwner || m.guests.length > 0) && <GuestsList m={m} onAsk={setAsk} />}
</div>
</AreaPanel>
{m.canManageShare && (
<div className="flex flex-col gap-5">
<AreaPanel area={panelOf('sharing')} shown={current.id === 'sharing'} titled>
<PublicLink tripId={tripId} />
<InviteLink tripId={tripId} />
</div>
</AreaPanel>
)}
{journalSection && (
<AreaPanel area={panelOf('journal')} shown={current.id === 'journal'} titled>
<TripJournalLinkSection tripId={tripId} framed={false} />
</AreaPanel>
)}
</div>
</DialogShell>
@@ -93,6 +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 (
<nav aria-label={t('trip.settings.title')} className={NAV}>
{areas.map(a => {
const on = a.id === current
return (
<button key={a.id} type="button" aria-current={on ? 'page' : undefined} onClick={() => onPick(a.id)}
className={`flex flex-none items-center gap-2.5 rounded-[12px] px-2.5 py-2 text-start transition-colors ${on ? 'bg-surface-card text-content shadow-sm ring-1 ring-edge-faint' : 'text-content-muted hover:bg-surface-hover hover:text-content'}`}>
<span className={`grid h-8 w-8 flex-none place-items-center rounded-[9px] ${on ? 'bg-accent text-accent-text' : 'bg-surface-tertiary'}`}>{a.icon(on)}</span>
<span className="min-w-0">
<span className="block truncate font-semibold" style={fs(13, 'body')}>{t(a.labelKey)}</span>
<span className="block truncate text-content-faint max-md:hidden" style={fs(11)}>{t(a.hintKey)}</span>
</span>
</button>
)
})}
</nav>
)
}
/** One area: its name and what it is for, then its parts. Hidden rather than unmounted while another one shows. */
function AreaPanel({ area, shown, titled, children }: { area: (typeof AREAS)[number] | undefined; shown: boolean; titled: boolean; children: ReactNode }) {
const { t } = useTranslation()
return (
// The class, not the hidden attribute alone: flex would win over the attribute's display:none.
<section hidden={!shown} className={shown ? 'flex flex-col gap-5' : 'hidden'}>
{titled && area && (
<header>
<h3 className="m-0 font-semibold text-content" style={fs(17, 'subtitle')}>{t(area.labelKey)}</h3>
<p className="m-0 mt-1 max-w-[560px] leading-normal text-content-muted" style={fs(12.5, 'body')}>{t(area.hintKey)}</p>
</header>
)}
{children}
</section>
)
}
function InviteUser({ m }: { m: TripMembersState }) {
const { t } = useTranslation()
return (
@@ -291,7 +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 (
<section className={PANEL}>
<div className="flex items-start gap-2.5">
@@ -0,0 +1,290 @@
// FE-TJL-HOOK-001 to FE-TJL-HOOK-014
/**
* The trip's linked journal as the planner and the Settings dialog read it.
*
* `useTripJournal` decides whether the trip has a Journal tab at all: only with
* the Journey addon on, only once the server answered, and live on every
* `trip:journal:changed`. `useTripJournalLink` is the Settings section: pick one
* of your own journals, everybody of the trip ticked as a reader by default,
* the people who would get in for the first time named before the save.
*/
import type { TripJournalCandidate, TripJournalLinkResponse } from '@trek/shared';
import { http, HttpResponse } from 'msw';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { buildUser } from '../../../tests/helpers/factories';
import { server } from '../../../tests/helpers/msw/server';
import { act, renderHook, waitFor } from '../../../tests/helpers/render';
import { seedStore } from '../../../tests/helpers/store';
import { useAddonStore } from '../../store/addonStore';
import { useAuthStore } from '../../store/authStore';
import { useTripJournalLinkStore } from '../../store/tripJournalLinkStore';
import { _resetNetworkMode, setForcedOffline } from '../../sync/networkMode';
import { useTripJournal, useTripJournalLink, useTripJournalSectionVisible } from './useTripJournalLink';
const toast = { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() };
vi.mock('../shared/Toast', () => ({ useToast: () => toast }));
const listeners = new Set<(e: Record<string, unknown>) => void>();
vi.mock('../../api/websocket', async (importOriginal) => ({
...(await importOriginal<typeof import('../../api/websocket')>()),
addListener: (fn: (e: Record<string, unknown>) => void) => {
listeners.add(fn);
},
removeListener: (fn: (e: Record<string, unknown>) => void) => {
listeners.delete(fn);
},
}));
const TRIP = 4;
const ME = 1;
const EMPTY: TripJournalLinkResponse = { link: null, can_link: true, can_unlink: false, can_edit_members: false };
const LINKED: TripJournalLinkResponse = {
link: {
journey_id: 9,
title: 'Japan',
subtitle: null,
cover_image: null,
owner_id: ME,
linked_by: ME,
created_at: 1,
my_role: 'owner',
members: [{ user_id: 2, role: 'editor', username: 'lena', avatar_url: null }],
},
can_link: true,
can_unlink: true,
can_edit_members: true,
};
const CANDIDATES: TripJournalCandidate[] = [
{ user_id: 2, username: 'lena', avatar_url: null, is_trip_owner: false, access: 'editor' },
{ user_id: 3, username: 'tom', avatar_url: null, is_trip_owner: false, access: null },
];
let gets = 0;
let answer: TripJournalLinkResponse = EMPTY;
const writes: { method: string; body: unknown }[] = [];
function routes({ writeStatus = 200 } = {}) {
server.use(
http.get('/api/trips/:tripId/journal', () => {
gets += 1;
return HttpResponse.json(answer);
}),
http.get('/api/trips/:tripId/journal/candidates', () => HttpResponse.json({ candidates: CANDIDATES })),
http.get('/api/journeys', () =>
HttpResponse.json({
journeys: [
{ id: 9, user_id: ME, title: 'Japan' },
{ id: 10, user_id: 99, title: 'Not mine' },
],
})
),
http.put('/api/trips/:tripId/journal', async ({ request }) => {
writes.push({ method: 'PUT', body: await request.json() });
return writeStatus === 200
? HttpResponse.json(LINKED)
: HttpResponse.json({ error: 'no' }, { status: writeStatus });
}),
http.patch('/api/trips/:tripId/journal/members', async ({ request }) => {
writes.push({ method: 'PATCH', body: await request.json() });
return HttpResponse.json(LINKED);
}),
http.delete('/api/trips/:tripId/journal', () => {
writes.push({ method: 'DELETE', body: null });
return HttpResponse.json(EMPTY);
})
);
}
function journeyAddon(enabled: boolean): void {
useAddonStore.setState({
loaded: true,
addons: enabled ? [{ id: 'journey', name: 'Journey', type: 'global', icon: 'BookOpen', enabled: true }] : [],
});
}
beforeEach(() => {
gets = 0;
answer = EMPTY;
writes.length = 0;
listeners.clear();
vi.clearAllMocks();
useTripJournalLinkStore.setState({ byTrip: {} });
seedStore(useAuthStore, { user: buildUser({ id: ME }) });
journeyAddon(true);
routes();
});
afterEach(() => _resetNetworkMode());
describe('useTripJournal', () => {
it('FE-TJL-HOOK-001: with the Journey addon off it asks nothing and there is no journal', async () => {
journeyAddon(false);
const { result } = renderHook(() => useTripJournal(TRIP));
expect(result.current.openJournalId).toBeNull();
expect(result.current.settled).toBe(true);
await new Promise((r) => setTimeout(r, 30));
expect(gets).toBe(0);
});
it('FE-TJL-HOOK-002: a linked journal open to this person is the one the tab opens', async () => {
answer = LINKED;
const { result } = renderHook(() => useTripJournal(TRIP));
expect(result.current.settled).toBe(false);
await waitFor(() => expect(result.current.openJournalId).toBe(9));
expect(result.current.settled).toBe(true);
});
it('FE-TJL-HOOK-003: a link that leaves this person out gives no tab', async () => {
answer = { ...LINKED, link: LINKED.link && { ...LINKED.link, my_role: null } };
const { result } = renderHook(() => useTripJournal(TRIP));
await waitFor(() => expect(result.current.link).not.toBeNull());
expect(result.current.openJournalId).toBeNull();
});
it('FE-TJL-HOOK-004: trip:journal:changed asks again', async () => {
const { result } = renderHook(() => useTripJournal(TRIP));
await waitFor(() => expect(gets).toBe(1));
answer = LINKED;
act(() => {
for (const fn of [...listeners]) fn({ type: 'trip:journal:changed' });
});
await waitFor(() => expect(result.current.openJournalId).toBe(9));
});
it('FE-TJL-HOOK-005: offline it asks nothing and is settled', () => {
setForcedOffline(true);
const { result } = renderHook(() => useTripJournal(TRIP));
expect(result.current.settled).toBe(true);
expect(gets).toBe(0);
});
it('FE-TJL-HOOK-006: without a trip id it asks nothing', async () => {
renderHook(() => useTripJournal(null));
await new Promise((r) => setTimeout(r, 30));
expect(gets).toBe(0);
});
it('FE-TJL-HOOK-007: the Settings section shows for someone who may link, and not for someone who may not', async () => {
const { result, rerender } = renderHook(() => useTripJournalSectionVisible(TRIP));
await waitFor(() => expect(result.current).toBe(true));
act(() => useTripJournalLinkStore.setState({ byTrip: { [TRIP]: { ...EMPTY, can_link: false } } }));
rerender();
expect(result.current).toBe(false);
});
});
describe('useTripJournalLink', () => {
it('FE-TJL-HOOK-008: picking offers only my journals and ticks everybody as a reader', async () => {
const { result } = renderHook(() => useTripJournalLink(TRIP));
await waitFor(() => expect(result.current.visible).toBe(true));
act(() => result.current.startPick());
await waitFor(() => expect(result.current.journals).toEqual([{ id: 9, title: 'Japan', subtitle: null }]));
act(() => result.current.pickJournal(9));
await waitFor(() => expect(result.current.candidates).toHaveLength(2));
expect(result.current.choice).toEqual({ 2: 'viewer', 3: 'viewer' });
// lena is in already; tom would be let in for the first time.
expect(result.current.newlyGranted.map((c) => c.username)).toEqual(['tom']);
expect(result.current.canSave).toBe(true);
});
it('FE-TJL-HOOK-009: saving a pick links with the chosen roles and goes back to the view', async () => {
const { result } = renderHook(() => useTripJournalLink(TRIP));
await waitFor(() => expect(result.current.visible).toBe(true));
act(() => result.current.startPick());
act(() => result.current.pickJournal(9));
await waitFor(() => expect(result.current.candidates).toHaveLength(2));
act(() => {
result.current.setRole(2, 'editor');
result.current.setRole(3, null);
});
expect(result.current.newlyGranted).toEqual([]);
await act(async () => {
await result.current.save();
});
expect(writes).toEqual([{ method: 'PUT', body: { journey_id: 9, members: [{ user_id: 2, role: 'editor' }] } }]);
expect(toast.success).toHaveBeenCalledWith('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);
});
});
@@ -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<number, TripJournalLinkRole | null>;
/** 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<OwnJournal[] | null>(null);
const [pickedId, setPickedId] = useState<number | null>(null);
const [candidates, setCandidates] = useState<TripJournalCandidate[] | null>(null);
const [choice, setChoice] = useState<JournalMemberChoice>({});
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<void>, 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,
};
}
+2
View File
@@ -20,6 +20,7 @@ export const TRIP_TAB_IDS = [
'finanzplan',
'dateien',
'collab',
'journal',
] as const
export type TripTabId = (typeof TRIP_TAB_IDS)[number]
@@ -40,6 +41,7 @@ export const TRIP_TAB_LABEL_KEYS: Record<TripTabId, string> = {
finanzplan: 'trip.tabs.budget',
dateien: 'trip.tabs.files',
collab: 'admin.addons.catalog.collab.name',
journal: 'trip.tabs.journal',
}
export const isTripTabId = (value: unknown): value is TripTabId =>
+8
View File
@@ -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;
+4
View File
@@ -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; }
@@ -7,7 +7,7 @@ import ContributorInviteDialog from '../../../components/Journey/ContributorInvi
import ConfirmDialog from '../../../components/shared/ConfirmDialog'
import { ProviderPicker } from '../../../components/Journey/JourneyDetailPageProviderPicker'
import { photoUrl, posterlessVideo } from '../../../pages/journeyDetail/JourneyDetailPage.helpers'
import { useJourneyDetail } from '../../../pages/journeyDetail/useJourneyDetail'
import { useJourneyDetail, type JourneyDetailOptions } from '../../../pages/journeyDetail/useJourneyDetail'
import { useJourneyStore } from '../../../store/journeyStore'
import { useAddonStore } from '../../../store/addonStore'
import DawarichIcon from '../../../components/shared/DawarichIcon'
@@ -32,8 +32,12 @@ import MJourneySettingsSheet from './MJourneySettingsSheet'
* Journey detail — integrated map with the horizontal 280px card timeline
* (Journey tab) and the 2-column photo gallery (Gallery tab). Cards and map
* markers stay in sync; tapping the centered card opens the entry sheet.
*
* A trip that links the journal shows this same screen in its own tab
* (`embedded`): the trip's back button and controls sit on top, so the screen
* drops its own back button and leaves the trip's URL alone.
*/
export default function MJourneyDetail() {
export default function MJourneyDetail({ journeyId, embedded = false }: JourneyDetailOptions = {}) {
const {
id, navigate, toast, t,
current, loading,
@@ -48,7 +52,7 @@ export default function MJourneyDetail() {
dismissSuggestion, restoreSuggestions, openAtEntryId,
loadJourney, updateEntry, deleteEntry, reorderEntries, uploadPhotos,
addPickedProviderPhotos, addEntryProviderPhotos,
} = useJourneyDetail()
} = useJourneyDetail({ journeyId, embedded })
// The dock's FAB is a sibling of this screen: on the Gallery it becomes the
// upload button, so it has to know which tab is open — and it stops knowing
@@ -175,7 +179,7 @@ export default function MJourneyDetail() {
const [params, setParams] = useSearchParams()
const [pendingUpload, setPendingUpload] = useState(false)
useEffect(() => {
if (params.get('create') !== 'photo') return
if (embedded || params.get('create') !== 'photo') return
setParams(prev => {
const next = new URLSearchParams(prev)
next.delete('create')
@@ -184,7 +188,7 @@ export default function MJourneyDetail() {
if (!canEditEntries) return
setView('gallery')
setPendingUpload(true)
}, [params, setParams, canEditEntries, setView])
}, [params, setParams, canEditEntries, setView, embedded])
useEffect(() => {
if (!pendingUpload || !providersReady) return
@@ -331,14 +335,18 @@ export default function MJourneyDetail() {
{/* Header: back — segment — upload / overflow menu */}
<div className="absolute inset-x-4 top-[var(--m-safe-top,12px)] z-10 flex items-center justify-between gap-2">
<button
type="button"
onClick={() => navigate('/journey')}
aria-label={t('journey.detail.backToJourney')}
className="flex h-[38px] w-[38px] flex-none items-center justify-center rounded-full bg-[color:var(--m-sheet)] text-m-ink shadow-[0_5px_14px_-6px_rgba(0,0,0,.3)]"
>
<ChevronLeft size={18} strokeWidth={2.2} />
</button>
{embedded ? (
<span className="w-[38px] flex-none" aria-hidden />
) : (
<button
type="button"
onClick={() => navigate('/journey')}
aria-label={t('journey.detail.backToJourney')}
className="flex h-[38px] w-[38px] flex-none items-center justify-center rounded-full bg-[color:var(--m-sheet)] text-m-ink shadow-[0_5px_14px_-6px_rgba(0,0,0,.3)]"
>
<ChevronLeft size={18} strokeWidth={2.2} />
</button>
)}
<div className="absolute left-1/2 top-0 flex -translate-x-1/2 rounded-full bg-[color:var(--m-sheet)] p-[3px] shadow-[0_5px_14px_-6px_rgba(0,0,0,.3)]">
<button
type="button"
@@ -1,4 +1,4 @@
import { ChevronRight, FileDown, Settings, Share2 } from 'lucide-react'
import { ChevronRight, FileDown, Settings, Settings2 } from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import MSheet from '../../../components/MSheet'
import type { MTripSheetsProps } from '../MTripShell'
@@ -14,6 +14,7 @@ const TILE_COLORS: Record<string, string> = {
finanzplan: '#E8A33D',
dateien: '#68686F',
collab: '#EC4899',
journal: '#C2410C',
}
/**
@@ -48,7 +49,7 @@ export default function MMehrSheet({ planner, shell }: MTripSheetsProps) {
}
const actions: { id: string; icon: LucideIcon; label: string; go: () => void }[] = [
{ id: 'members', icon: Share2, label: t('members.shareTrip'), go: () => shell.openSheet('members') },
{ id: 'members', icon: Settings2, label: t('trip.settings.title'), go: () => shell.openSheet('members') },
{ id: 'export', icon: FileDown, label: t('mobileTrip.export'), go: () => shell.openSheet('export') },
...(canEditTrip
? [{ id: 'tripedit', icon: Settings, label: t('dashboard.editTrip'), go: () => shell.openSheet('tripedit') }]
@@ -7,6 +7,7 @@ import MFilesTab from './MFilesTab'
import MCollabTab from './MCollabTab'
import MListsTab from './MListsTab'
import MRoadtripTab from '../roadtrip/MRoadtripTab'
import MJourneyDetail from '../../journey/MJourneyDetail'
import PluginFrame from '../../../../components/Plugins/PluginFrame'
/**
@@ -61,6 +62,14 @@ export default function MTripTabPanel({ planner, shell, tab }: MTripTabPanelProp
// switch between them must not lose it.
case 'roadtrip':
return <MRoadtripTab planner={planner} shell={shell} tab={tab} />
// The journal the trip links: the Journey addon's own phone screen, moved
// below the trip's floating top controls (see .m-embedded-journal).
case 'journal':
return planner.journalId == null ? null : (
<div className="m-embedded-journal absolute inset-0 overflow-hidden">
<MJourneyDetail journeyId={planner.journalId} embedded />
</div>
)
default:
return <TabScroller>{null}</TabScroller>
}
+37 -24
View File
@@ -18,7 +18,7 @@ import JourneyDayDawarich from '../components/Journey/JourneyDayDawarich'
import MobileEntryView from '../components/Journey/MobileEntryView'
import { useJourneyStore } from '../store/journeyStore'
import { computeJourneyLifecycle } from '../utils/journeyLifecycle'
import { useJourneyDetail } from './journeyDetail/useJourneyDetail'
import { useJourneyDetail, type JourneyDetailOptions } from './journeyDetail/useJourneyDetail'
import { createDraftJourneyEntry, pickGradient, groupByDate, formatDate, photoUrl, matchJourneyEntries } from './journeyDetail/JourneyDetailPage.helpers'
import { EntryCard, SkeletonCard, CheckinCard } from '../components/Journey/JourneyDetailPageEntryCard'
import { GalleryView } from '../components/Journey/JourneyDetailPageGalleryView'
@@ -39,7 +39,12 @@ export default function JourneyDetailPage() {
)
}
function JourneyDetailPageDesktop() {
/**
* The journal screen. The journal page renders it for the route's journal; a trip
* that links a journal renders the same screen inside its own tab (`embedded`),
* so the trip shows the journal with nothing of it built twice.
*/
export function JourneyDetailPageDesktop({ journeyId, embedded = false }: JourneyDetailOptions = {}) {
// Page = wiring container: load + live sync, view state, dialogs, the
// scroll-synced map and the map/trip-date derivations live in the hook.
const {
@@ -62,13 +67,17 @@ function JourneyDetailPageDesktop() {
feedEdge, scrollFeedTo,
loadJourney, updateEntry, deleteEntry, reorderEntries, uploadPhotos, deletePhoto,
addPickedProviderPhotos, addEntryProviderPhotos,
} = useJourneyDetail()
} = useJourneyDetail({ journeyId, embedded })
// Inside a trip the trip draws the bar and owns the height; the journal fills
// the box it is given and starts at its top.
const frame = embedded ? 'h-full bg-zinc-50 dark:bg-zinc-950' : 'min-h-screen bg-zinc-50 dark:bg-zinc-950'
const below = embedded ? undefined : { paddingTop: 'var(--nav-h, 0px)' }
if (loading || !current) {
return (
<div className="min-h-screen bg-zinc-50 dark:bg-zinc-950">
<Navbar />
<div style={{ paddingTop: 'var(--nav-h, 0px)' }} className="flex justify-center py-20">
<div className={frame}>
{!embedded && <Navbar />}
<div style={below} className="flex justify-center py-20">
<div className="w-6 h-6 border-2 border-zinc-300 border-t-zinc-900 rounded-full animate-spin" />
</div>
</div>
@@ -166,8 +175,8 @@ function JourneyDetailPageDesktop() {
) : null
return (
<div className="min-h-screen bg-zinc-50 dark:bg-zinc-950">
<Navbar />
<div className={frame}>
{!embedded && <Navbar />}
{/* Mobile combined map+timeline (Polarsteps-style) — renders as fullscreen overlay */}
{showMobileCombined && (
@@ -206,13 +215,15 @@ function JourneyDetailPageDesktop() {
className="fixed inset-x-0 z-30 flex items-start justify-between gap-2 px-4"
style={{ top: 'calc(var(--nav-h, 56px) + 12px)' }}
>
<button type="button"
onClick={() => navigate('/journey')}
aria-label={t('journey.detail.backToJourney')}
className={barButton}
>
<ArrowLeft size={16} />
</button>
{!embedded && (
<button type="button"
onClick={() => navigate('/journey')}
aria-label={t('journey.detail.backToJourney')}
className={barButton}
>
<ArrowLeft size={16} />
</button>
)}
<div className="flex-1 min-w-0 flex justify-center">
<div className="flex bg-surface-elevated backdrop-blur-lg border border-edge rounded-lg overflow-hidden shadow-lg">
@@ -270,7 +281,7 @@ function JourneyDetailPageDesktop() {
</div>
)}
<div style={{ paddingTop: 'var(--nav-h, 0px)' }} className={showMobileCombined ? 'hidden' : ''}>
<div style={below} className={showMobileCombined ? 'hidden' : embedded ? 'h-full' : ''}>
<div
className={
isMobile
@@ -310,13 +321,15 @@ function JourneyDetailPageDesktop() {
<div className="relative z-[3] flex items-center justify-between mb-5">
<div className="flex items-center gap-2">
<button type="button"
onClick={() => navigate('/journey')}
aria-label={t('journey.detail.backToJourney')}
className="w-[34px] h-[34px] rounded-full bg-white/15 backdrop-blur flex items-center justify-center hover:bg-white/25"
>
<ArrowLeft size={14} />
</button>
{!embedded && (
<button type="button"
onClick={() => navigate('/journey')}
aria-label={t('journey.detail.backToJourney')}
className="w-[34px] h-[34px] rounded-full bg-white/15 backdrop-blur flex items-center justify-center hover:bg-white/25"
>
<ArrowLeft size={14} />
</button>
)}
{/* Status badge — keep completed/upcoming/draft/archived, but drop live + synced-with-trips per UX trim */}
<div className="hidden md:flex items-center gap-2">
{lifecycle !== 'live' && lifecycle !== 'archived' && (
@@ -821,7 +834,7 @@ function JourneyDetailPageDesktop() {
{/* TREK Studio. It portals itself over the page, so this journey keeps
rendering underneath and shows through the panel's margin. */}
<Outlet />
{!embedded && <Outlet />}
</div>
)
}
+4 -4
View File
@@ -39,7 +39,7 @@ import { latestReservation } from './tripPlanner/transportEditorOpeners'
import {
ReservationsPanel, FileManager, CostsPanel, ExpenseModal, CollabPanel,
RoadtripSidebar, RoadtripCorridorPanel, RoadtripLimitsCard, RoadtripStopPopup, RoadtripStayModal, RoadtripTrackModal,
TourWorkspaceRail, TourPlannerToursRail, TransportModal,
TourWorkspaceRail, TourPlannerToursRail, TransportModal, TripJournalPanel,
} from './tripPlanner/plannerLazy'
import { LazyPanel } from './tripPlanner/LazyPanel'
import { ListsContainer } from './tripPlanner/ListsContainer'
@@ -70,7 +70,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
trip, days, places, assignments, packingItems, todoItems, categories, reservations, budgetItems, files,
selectedDayId, isLoading, tripActions, can, canUploadFiles,
pushUndo, undo, canUndo, lastActionLabel, handleUndo,
enabledAddons, collabFeatures, tripAccommodations, setTripAccommodations,
enabledAddons, collabFeatures, journalId, tripAccommodations, setTripAccommodations,
roadtripMode, toggleRoadtripMode, roadtripActive, roadtripRoutes, roadtripLineColors, roadtripMapLines, roadtripMapPlaces, collapsedRoadtripDays, toggleRoadtripDay, roadtripCorridor,
overviewActive, tripOverview, toggleOverview, overviewShown,
dawarichEnabled, dawarichTrailShown, toggleDawarichTrail, dawarichTrail, dawarichHiddenDates,
@@ -242,7 +242,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
return (
<div style={{ position: 'fixed', inset: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden', ...fontStyle }}>
<HelpAnchor id={helpId} />
<Navbar tripTitle={trip.title} tripId={tripId} showBack onBack={() => navigate('/dashboard')} onShare={() => setShowMembersModal(true)} />
<Navbar tripTitle={trip.title} tripId={tripId} showBack onBack={() => navigate('/dashboard')} onSettings={() => setShowMembersModal(true)} />
<div className="bg-surface-elevated border-b border-edge-faint" style={{
position: 'fixed', top: 'var(--nav-h)', insetInline: 0, zIndex: 40,
@@ -792,7 +792,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
</LazyPanel>
</div>
)}
{activeTab === 'journal' && <LazyPanel id="journal"><TripJournalPanel journeyId={journalId} /></LazyPanel>}
{activeTab.startsWith('plugin:') && (
<div style={{ position: 'absolute', top: 0, insetInline: 0, bottom: 'var(--bottom-nav-h)', overflow: 'hidden' }}>
@@ -29,9 +29,23 @@ const NO_TRACKS: JourneyTrack[] = []
* map/trip-date derivations. JourneyDetailPage stays a wiring container around
* its large two-pane JSX and many presentational sub-components.
* Behaviour is identical to the previous in-component logic.
*
* The journal can also show inside a trip that links it, as the same screen with
* the same dialogs. There the trip names the journal (`journeyId`) instead of the
* route, and `embedded` keeps the screen to its own box: no scroll lock on the
* page, no `?create=` handling on the trip's URL, and no trip out to the journal
* list when the journal goes away; the trip decides what shows then.
*/
export function useJourneyDetail() {
const { id } = useParams()
export interface JourneyDetailOptions {
/** The journal to show; the route's `:id` when left out. */
journeyId?: number
/** Shown inside another screen (a trip) rather than as the journal page. */
embedded?: boolean
}
export function useJourneyDetail({ journeyId, embedded = false }: JourneyDetailOptions = {}) {
const params = useParams()
const id = journeyId != null ? String(journeyId) : params.id
const navigate = useNavigate()
const toast = useToast()
const { t, locale } = useTranslation()
@@ -67,9 +81,10 @@ export function useJourneyDetail() {
const [showAddTrip, setShowAddTrip] = useState(false)
const [searchParams, setSearchParams] = useSearchParams()
// The bottom-nav "+" starts a new entry via ?create=entry.
// The bottom-nav "+" starts a new entry via ?create=entry. Not inside a trip:
// the trip's own `create` values live on that same URL.
useEffect(() => {
if (searchParams.get('create') === 'entry' && current && canEditEntries) {
if (!embedded && searchParams.get('create') === 'entry' && current && canEditEntries) {
setEditingEntry(createDraftJourneyEntry(current.id))
setSearchParams(p => { p.delete('create'); return p }, { replace: true })
}
@@ -119,11 +134,11 @@ export function useJourneyDetail() {
}, [current?.hide_skeletons])
useEffect(() => {
if (notFound) {
if (notFound && !embedded) {
toast.error(t('journey.notFound'))
navigate('/journey')
}
}, [notFound])
}, [notFound, embedded])
// WebSocket real-time updates
useEffect(() => {
@@ -374,9 +389,9 @@ export function useJourneyDetail() {
// the body must not scroll underneath it. Goes through the shared counter so a
// modal opening on top of it cannot hand the page back early (#1809).
useEffect(() => {
if (isMobile) return
if (isMobile || embedded) return
return lockBodyScroll()
}, [isMobile])
}, [isMobile, embedded])
// Map only shows real journal entries — skeletons are trip-derived
// suggestions, not something the user actually journaled at that spot.
@@ -549,7 +564,7 @@ export function useJourneyDetail() {
}, [])
return {
id, navigate, toast, t, locale,
id, navigate, toast, t, locale, embedded, notFound,
openStudio, prefetchStudio,
current, loading,
canEditEntries, canEditJourney, myRole,
@@ -0,0 +1,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 (
<div className="trip-embedded-journal absolute inset-0 overflow-y-auto">
<JourneyDetailPageDesktop journeyId={journeyId} embedded />
</div>
);
}
@@ -17,6 +17,8 @@ export const ExpenseModal = lazyWithRetry(() =>
import('../../components/Budget/CostsPanel').then(m => ({ default: m.ExpenseModal }))
)
export const CollabPanel = lazyWithRetry(() => import('../../components/Collab/CollabPanel'))
// The Journey addon's journal view, which only a trip with a linked journal opens.
export const TripJournalPanel = lazyWithRetry(() => import('./TripJournalPanel'))
export const RoadtripSidebar = lazyWithRetry(() => import('../../components/Roadtrip/RoadtripSidebar'))
export const RoadtripCorridorPanel = lazyWithRetry(() => import('../../components/Roadtrip/RoadtripCorridorPanel'))
export const RoadtripLimitsCard = lazyWithRetry(() => import('../../components/Roadtrip/RoadtripLimitsCard'))
@@ -24,6 +24,8 @@ import { isServiceStopType } from '../../components/Roadtrip/roadtripModel'
import { inspectorStay } from '../../components/Roadtrip/stayReading'
import { usePlaceSelection } from '../../hooks/usePlaceSelection'
import { useTourPlaceIds } from '../../hooks/useTourPlaceIds'
import { LinkedJournalIcon } from '../../components/Trips/LinkedJournalIcon'
import { useTripJournal } from '../../components/Trips/useTripJournalLink'
import { useAddonStore } from '../../store/addonStore'
import { usePlannerHistory } from '../../hooks/usePlannerHistory'
import { useIsTouch } from '../../hooks/useIsTouch'
@@ -198,6 +200,7 @@ export function useTripPlanner() {
// A trip-page plugin may replace core tabs while it's active (its manifest names
// them; 'plan' is never replaceable) and may pick where its own tab sits.
const journal = useTripJournal(Number.isFinite(tripId) ? tripId : null)
const replacedTabs = new Set(tripPagePlugins.flatMap(p => p.tripPage?.replaces ?? []))
const TRIP_TABS = [
{ id: 'plan', label: t(TRIP_TAB_LABEL_KEYS.plan), icon: Map },
@@ -211,6 +214,8 @@ export function useTripPlanner() {
...(enabledAddons.budget ? [{ id: 'finanzplan', label: t(TRIP_TAB_LABEL_KEYS.finanzplan), icon: Wallet }] : []),
...(enabledAddons.documents ? [{ id: 'dateien', label: t(TRIP_TAB_LABEL_KEYS.dateien), icon: FolderOpen }] : []),
...(enabledAddons.collab ? [{ id: 'collab', label: t(TRIP_TAB_LABEL_KEYS.collab), icon: Users }] : []),
// A journal from the Journey addon tied to this trip, for whoever the link lets in.
...(journal.openJournalId != null ? [{ id: 'journal', label: t(TRIP_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,
+106
View File
@@ -0,0 +1,106 @@
/**
* FE-TJL-REPO-001 to FE-TJL-REPO-006
*
* `tripJournalLinkRepo` is online-only on purpose (who a journal is open to is
* the server's answer, never a cached guess), so what is pinned is that it
* refuses before a request while offline, that every call reaches its route
* with the body the contract names, and that the picker only offers journals
* the person owns.
*/
import type { TripJournalLinkResponse } from '@trek/shared';
import { http, HttpResponse } from 'msw';
import { afterEach, describe, expect, it } from 'vitest';
import { server } from '../../tests/helpers/msw/server';
import { _resetNetworkMode, setForcedOffline } from '../sync/networkMode';
import { tripJournalLinkRepo, TripJournalOfflineError } from './tripJournalLinkRepo';
const NONE: TripJournalLinkResponse = { link: null, can_link: true, can_unlink: false, can_edit_members: false };
afterEach(() => _resetNetworkMode());
describe('tripJournalLinkRepo', () => {
it('FE-TJL-REPO-001: refuses every call while offline, before a request leaves', async () => {
let calls = 0;
server.use(
http.get('/api/trips/:tripId/journal', () => {
calls += 1;
return HttpResponse.json(NONE);
})
);
setForcedOffline(true);
await expect(tripJournalLinkRepo.get(4)).rejects.toBeInstanceOf(TripJournalOfflineError);
await expect(tripJournalLinkRepo.candidates(4, 1)).rejects.toBeInstanceOf(TripJournalOfflineError);
await expect(tripJournalLinkRepo.link(4, 1, [])).rejects.toBeInstanceOf(TripJournalOfflineError);
await expect(tripJournalLinkRepo.setMembers(4, [])).rejects.toBeInstanceOf(TripJournalOfflineError);
await expect(tripJournalLinkRepo.unlink(4)).rejects.toBeInstanceOf(TripJournalOfflineError);
await expect(tripJournalLinkRepo.ownJournals(1)).rejects.toBeInstanceOf(TripJournalOfflineError);
expect(calls).toBe(0);
});
it('FE-TJL-REPO-002: reads the trip link', async () => {
server.use(http.get('/api/trips/4/journal', () => HttpResponse.json(NONE)));
expect(await tripJournalLinkRepo.get(4)).toEqual(NONE);
});
it('FE-TJL-REPO-003: asks for the candidates of one journal and hands back the list', async () => {
let asked: string | null = null;
server.use(
http.get('/api/trips/4/journal/candidates', ({ request }) => {
asked = new URL(request.url).searchParams.get('journey_id');
return HttpResponse.json({
candidates: [{ user_id: 2, username: 'lena', avatar_url: null, is_trip_owner: false, access: null }],
});
})
);
const list = await tripJournalLinkRepo.candidates(4, 9);
expect(asked).toBe('9');
expect(list.map((c) => c.username)).toEqual(['lena']);
});
it('FE-TJL-REPO-004: links, sets members and unlinks with the bodies the contract names', async () => {
const bodies: unknown[] = [];
server.use(
http.put('/api/trips/4/journal', async ({ request }) => {
bodies.push(await request.json());
return HttpResponse.json(NONE);
}),
http.patch('/api/trips/4/journal/members', async ({ request }) => {
bodies.push(await request.json());
return HttpResponse.json(NONE);
}),
http.delete('/api/trips/4/journal', () => HttpResponse.json(NONE))
);
await tripJournalLinkRepo.link(4, 9, [{ user_id: 2, role: 'viewer' }]);
await tripJournalLinkRepo.setMembers(4, [{ user_id: 2, role: 'editor' }]);
expect(await tripJournalLinkRepo.unlink(4)).toEqual(NONE);
expect(bodies).toEqual([
{ journey_id: 9, members: [{ user_id: 2, role: 'viewer' }] },
{ members: [{ user_id: 2, role: 'editor' }] },
]);
});
it('FE-TJL-REPO-005: offers only the journals the person owns', async () => {
server.use(
http.get('/api/journeys', () =>
HttpResponse.json({
journeys: [
{ id: 1, user_id: 7, title: 'Mine', subtitle: 'Japan' },
{ id: 2, user_id: 8, title: 'Theirs' },
{ id: 3, user_id: 7, title: 'Also mine' },
],
})
)
);
expect(await tripJournalLinkRepo.ownJournals(7)).toEqual([
{ id: 1, title: 'Mine', subtitle: 'Japan' },
{ id: 3, title: 'Also mine', subtitle: null },
]);
});
it('FE-TJL-REPO-006: the offline error says what it needs', () => {
const err = new TripJournalOfflineError();
expect(err.name).toBe('TripJournalOfflineError');
expect(err.message).toMatch(/connection/);
});
});
+70
View File
@@ -0,0 +1,70 @@
import type { TripJournalCandidate, TripJournalLinkMemberRequest, TripJournalLinkResponse } from '@trek/shared';
import { tripJournalApi } from '../api/tripJournal';
import { isEffectivelyOffline } from '../sync/networkMode';
/** A journal the person owns, as the link picker lists it. */
export interface OwnJournal {
id: number;
title: string;
subtitle: string | null;
}
/**
* The journal a trip links, and who it is open to.
*
* Online-only with no Dexie table, for the reason docSyncRepo gives: every
* answer is the server's (who may see the link, who would be let in), and the
* journal it opens is online-only too, as the Journey addon is. Writing it
* offline would mean granting access nobody could check. The store in front of
* this keeps the last answer for the session.
*/
export class TripJournalOfflineError extends Error {
constructor() {
super('Linking a journal needs a connection');
this.name = 'TripJournalOfflineError';
}
}
function requireOnline(): void {
if (isEffectivelyOffline()) throw new TripJournalOfflineError();
}
export const tripJournalLinkRepo = {
async get(tripId: number): Promise<TripJournalLinkResponse> {
requireOnline();
return tripJournalApi.get(tripId);
},
async candidates(tripId: number, journeyId: number): Promise<TripJournalCandidate[]> {
requireOnline();
return (await tripJournalApi.candidates(tripId, journeyId)).candidates;
},
async link(
tripId: number,
journeyId: number,
members: TripJournalLinkMemberRequest[]
): Promise<TripJournalLinkResponse> {
requireOnline();
return tripJournalApi.link(tripId, journeyId, members);
},
async setMembers(tripId: number, members: TripJournalLinkMemberRequest[]): Promise<TripJournalLinkResponse> {
requireOnline();
return tripJournalApi.setMembers(tripId, members);
},
async unlink(tripId: number): Promise<TripJournalLinkResponse> {
requireOnline();
return tripJournalApi.unlink(tripId);
},
/** The journals `userId` owns, newest first, for the picker. Those they only contribute to are not theirs to link. */
async ownJournals(userId: number): Promise<OwnJournal[]> {
requireOnline();
const { journeys } = await tripJournalApi.journals();
return journeys
.filter((j) => j.user_id === userId)
.map((j) => ({ id: j.id, title: j.title, subtitle: j.subtitle ?? null }));
},
};
@@ -0,0 +1,81 @@
/**
* FE-TJL-STORE-001 to FE-TJL-STORE-005
*
* The store keeps the server's answer per trip so the Journal tab and the
* Settings dialog read the same one: a write replaces it with what the server
* answered, a 404/403 means there is no journal for this person, and any other
* failure leaves the last answer standing rather than flashing the tab away.
*/
import type { TripJournalLinkResponse } from '@trek/shared';
import { http, HttpResponse } from 'msw';
import { beforeEach, describe, expect, it } from 'vitest';
import { server } from '../../tests/helpers/msw/server';
import { useTripJournalLinkStore } from './tripJournalLinkStore';
const LINKED: TripJournalLinkResponse = {
link: {
journey_id: 9,
title: 'Japan',
subtitle: null,
cover_image: null,
owner_id: 1,
linked_by: 1,
created_at: 1,
my_role: 'owner',
members: [],
},
can_link: true,
can_unlink: true,
can_edit_members: true,
};
const EMPTY: TripJournalLinkResponse = { link: null, can_link: true, can_unlink: false, can_edit_members: false };
beforeEach(() => useTripJournalLinkStore.setState({ byTrip: {} }));
describe('tripJournalLinkStore', () => {
it('FE-TJL-STORE-001: refresh keeps the answer under the trip', async () => {
server.use(http.get('/api/trips/4/journal', () => HttpResponse.json(LINKED)));
await useTripJournalLinkStore.getState().refresh(4);
expect(useTripJournalLinkStore.getState().byTrip['4']).toEqual(LINKED);
});
it('FE-TJL-STORE-002: a 404 (addon off, trip out of reach) is no journal at all', async () => {
server.use(http.get('/api/trips/4/journal', () => HttpResponse.json({ error: 'gone' }, { status: 404 })));
await useTripJournalLinkStore.getState().refresh(4);
expect(useTripJournalLinkStore.getState().byTrip['4']).toEqual({
link: null,
can_link: false,
can_unlink: false,
can_edit_members: false,
});
});
it('FE-TJL-STORE-003: any other failure leaves the last answer standing', async () => {
useTripJournalLinkStore.setState({ byTrip: { 4: LINKED } });
server.use(http.get('/api/trips/4/journal', () => HttpResponse.json({ error: 'boom' }, { status: 500 })));
await useTripJournalLinkStore.getState().refresh(4);
expect(useTripJournalLinkStore.getState().byTrip['4']).toEqual(LINKED);
});
it('FE-TJL-STORE-004: link, setMembers and unlink each keep what the server answered', async () => {
server.use(
http.put('/api/trips/4/journal', () => HttpResponse.json(LINKED)),
http.patch('/api/trips/4/journal/members', () => HttpResponse.json({ ...LINKED, can_unlink: false })),
http.delete('/api/trips/4/journal', () => HttpResponse.json(EMPTY))
);
const s = useTripJournalLinkStore.getState();
await s.link(4, 9, []);
expect(useTripJournalLinkStore.getState().byTrip['4']).toEqual(LINKED);
await s.setMembers(4, []);
expect(useTripJournalLinkStore.getState().byTrip['4']?.can_unlink).toBe(false);
await s.unlink(4);
expect(useTripJournalLinkStore.getState().byTrip['4']).toEqual(EMPTY);
});
it('FE-TJL-STORE-005: a failed write rejects for its caller and keeps the last answer', async () => {
useTripJournalLinkStore.setState({ byTrip: { 4: EMPTY } });
server.use(http.put('/api/trips/4/journal', () => HttpResponse.json({ error: 'no' }, { status: 403 })));
await expect(useTripJournalLinkStore.getState().link(4, 9, [])).rejects.toBeTruthy();
expect(useTripJournalLinkStore.getState().byTrip['4']).toEqual(EMPTY);
});
});
+49
View File
@@ -0,0 +1,49 @@
import type { TripJournalLinkMemberRequest, TripJournalLinkResponse } from '@trek/shared';
import { create } from 'zustand';
import { tripJournalLinkRepo } from '../repo/tripJournalLinkRepo';
/**
* The journal each trip links, as this person may see it, remembered for the
* session so the trip's Journal tab and its Settings dialog render the last
* answer at once. Both read it from here, so the tab appears and goes the moment
* the dialog links or unlinks, without a second request.
*/
interface TripJournalLinkState {
/** Per trip id, the server's answer. Missing until asked. */
byTrip: Record<string, TripJournalLinkResponse>;
refresh: (tripId: number) => Promise<void>;
link: (tripId: number, journeyId: number, members: TripJournalLinkMemberRequest[]) => Promise<void>;
setMembers: (tripId: number, members: TripJournalLinkMemberRequest[]) => Promise<void>;
unlink: (tripId: number) => Promise<void>;
}
/** The answer for a trip someone cannot reach, or with the Journey addon off: no journal. */
const NONE: TripJournalLinkResponse = { link: null, can_link: false, can_unlink: false, can_edit_members: false };
export const useTripJournalLinkStore = create<TripJournalLinkState>((set) => {
const keep = (tripId: number, answer: TripJournalLinkResponse) =>
set((s) => ({ byTrip: { ...s.byTrip, [String(tripId)]: answer } }));
return {
byTrip: {},
refresh: async (tripId) => {
try {
keep(tripId, await tripJournalLinkRepo.get(tripId));
} catch (err) {
// 404 is the Journey addon switched off or a trip this person no longer
// reaches: both a real "no journal". Anything else, offline included, is
// no answer at all, and the last one stands.
const status = (err as { response?: { status?: number } })?.response?.status;
if (status === 404 || status === 403) keep(tripId, NONE);
}
},
// The writes answer with the link as it now stands, so the tab and the
// dialog move together; a failure is the caller's to report.
link: async (tripId, journeyId, members) =>
keep(tripId, await tripJournalLinkRepo.link(tripId, journeyId, members)),
setMembers: async (tripId, members) => keep(tripId, await tripJournalLinkRepo.setMembers(tripId, members)),
unlink: async (tripId) => keep(tripId, await tripJournalLinkRepo.unlink(tripId)),
};
});
-1
View File
@@ -1,7 +1,6 @@
import type { TranslationStrings } from '../types';
const members: TranslationStrings = {
'members.shareTrip': 'مشاركة الرحلة',
'members.inviteUser': 'دعوة مستخدم',
'members.selectUser': 'اختر مستخدمًا…',
'members.invite': 'دعوة',
-1
View File
@@ -2,7 +2,6 @@ import type { TranslationStrings } from '../types';
const nav: TranslationStrings = {
'nav.trip': 'الرحلة',
'nav.share': 'مشاركة',
'nav.settings': 'الإعدادات',
'nav.admin': 'الإدارة',
'nav.logout': 'تسجيل الخروج',
+39
View File
@@ -119,5 +119,44 @@ const trip: TranslationStrings = {
'سيؤدي ذلك أيضًا إلى حذف الإقامة المحجوزة في "{name}" والحجز "{booking}" وأي مصروف مرتبط به.',
'trip.confirm.deletePlaceBookedSame':
'سيؤدي ذلك أيضًا إلى حذف الإقامة المحجوزة في "{name}" وحجزها وأي مصروف مرتبط به.',
'trip.settings.title': 'إعدادات الرحلة',
'trip.settings.people': 'الأشخاص',
'trip.settings.peopleHint': 'من المشاركون في هذه الرحلة، والضيوف بلا حساب.',
'trip.settings.sharing': 'المشاركة',
'trip.settings.sharingHint': 'رابط للقراءة فقط لأي شخص، ورابط دعوة للانضمام.',
'trip.tabs.journal': 'اليوميات',
'trip.journal.title': 'اليوميات',
'trip.journal.hint': 'اربط إحدى يومياتك بهذه الرحلة. من تمنحه الوصول يفتحها هنا مباشرة في تبويب اليوميات، بشكل حي.',
'trip.journal.link': 'ربط يوميات',
'trip.journal.linkSubmit': 'ربط اليوميات',
'trip.journal.change': 'تغيير اليوميات',
'trip.journal.editMembers': 'تعديل الوصول',
'trip.journal.unlink': 'فك الربط',
'trip.journal.unlinkTitle': 'فك ربط هذه اليوميات؟',
'trip.journal.unlinkMessage':
'يختفي تبويب اليوميات من هذه الرحلة. يفقد الأعضاء الذين كانوا يفتحون اليوميات عبر هذه الرحلة فقط وصولهم؛ أما من أضيفوا في اليوميات نفسها فيحتفظون به.',
'trip.journal.linkedBadge': 'مرتبطة',
'trip.journal.yourRole.owner': 'يومياتك',
'trip.journal.yourRole.editor': 'يمكنك التعديل',
'trip.journal.yourRole.viewer': 'يمكنك القراءة',
'trip.journal.onlyYou': 'لا يملك أحد غيرك من هذه الرحلة وصولًا بعد.',
'trip.journal.noJournals': 'ليست لديك يوميات بعد. أنشئ واحدة أولًا في Journey.',
'trip.journal.pickPlaceholder': 'اختر يوميات',
'trip.journal.noMembers': 'لا يوجد أحد غيرك في هذه الرحلة بعد.',
'trip.journal.members': 'وصول أعضاء الرحلة',
'trip.journal.allRead': 'الجميع يقرأ',
'trip.journal.allNone': 'لا أحد',
'trip.journal.roleNone': 'بلا وصول',
'trip.journal.roleViewer': 'قراءة',
'trip.journal.roleEditor': 'تعديل',
'trip.journal.hasAccess': 'لديه وصول',
'trip.journal.warnNew':
'سيحصل {names} على وصول إلى هذه اليوميات لأول مرة، ومن ثم سيراها أيضًا في قائمة يومياته الخاصة.',
'trip.journal.errorOffline': 'يتطلب ربط اليوميات اتصالًا بالإنترنت.',
'trip.journal.errorForbidden': 'غير مسموح لك بذلك.',
'trip.journal.errorGeneric': 'لم ينجح ذلك. حاول مرة أخرى.',
'trip.journal.linked': 'تم ربط اليوميات',
'trip.journal.membersSaved': 'تم حفظ الوصول',
'trip.journal.unlinked': 'تم فك ربط اليوميات',
};
export default trip;
-1
View File
@@ -1,7 +1,6 @@
import type { TranslationStrings } from '../types';
const members: TranslationStrings = {
'members.shareTrip': 'Səyahəti paylaş',
'members.inviteUser': 'İstifadəçi dəvət et',
'members.selectUser': 'İstifadəçi seçin…',
'members.invite': 'Dəvət et',
-1
View File
@@ -2,7 +2,6 @@ import type { TranslationStrings } from '../types';
const nav: TranslationStrings = {
'nav.trip': 'Səyahət',
'nav.share': 'Paylaş',
'nav.settings': 'Tənzimləmələr',
'nav.admin': 'Admin',
'nav.logout': 'Çıxış et',
+40
View File
@@ -110,6 +110,46 @@ const trip: TranslationStrings = {
'Bu, həmçinin “{name}” məkanında rezervasiya edilmiş qalmanı, “{booking}” rezervasiyasını və onunla əlaqəli bütün xərcləri silir.',
'trip.confirm.deletePlaceBookedSame':
'Bu, həmçinin “{name}” məkanında rezervasiya edilmiş qalmanı, onun rezervasiyasını və əlaqəli bütün xərcləri silir.',
'trip.settings.title': 'Səyahət parametrləri',
'trip.settings.people': 'İnsanlar',
'trip.settings.peopleHint': 'Bu səyahətdə kimlərin olduğu və hesabı olmayan qonaqlar.',
'trip.settings.sharing': 'Paylaşma',
'trip.settings.sharingHint': 'Hər kəs üçün yalnız oxunan keçid və qoşulmaq üçün dəvət keçidi.',
'trip.tabs.journal': 'Gündəlik',
'trip.journal.title': 'Gündəlik',
'trip.journal.hint':
'Gündəliklərinizdən birini bu səyahətə bağlayın. Giriş verdiyiniz şəxslər onu burada, Gündəlik bölməsində canlı açır.',
'trip.journal.link': 'Gündəlik bağla',
'trip.journal.linkSubmit': 'Gündəliyi bağla',
'trip.journal.change': 'Gündəliyi dəyiş',
'trip.journal.editMembers': 'Girişi redaktə et',
'trip.journal.unlink': 'Ayır',
'trip.journal.unlinkTitle': 'Bu gündəlik ayrılsın?',
'trip.journal.unlinkMessage':
'Gündəlik bölməsi bu səyahətdən yox olur. Gündəliyi yalnız bu səyahət vasitəsilə aça bilən üzvlər girişi itirir; gündəliyin özündə əlavə olunanlar onu saxlayır.',
'trip.journal.linkedBadge': 'Bağlıdır',
'trip.journal.yourRole.owner': 'Sizin gündəliyiniz',
'trip.journal.yourRole.editor': 'Redaktə edə bilərsiniz',
'trip.journal.yourRole.viewer': 'Oxuya bilərsiniz',
'trip.journal.onlyYou': 'Bu səyahətdən hələ heç kimin girişi yoxdur.',
'trip.journal.noJournals': 'Hələ gündəliyiniz yoxdur. Əvvəlcə Səyahət gündəliyində yaradın.',
'trip.journal.pickPlaceholder': 'Gündəlik seçin',
'trip.journal.noMembers': 'Bu səyahətdə hələ başqa heç kim yoxdur.',
'trip.journal.members': 'Səyahət üzvləri üçün giriş',
'trip.journal.allRead': 'Hamı oxuya bilər',
'trip.journal.allNone': 'Heç kim',
'trip.journal.roleNone': 'Giriş yoxdur',
'trip.journal.roleViewer': 'Oxumaq',
'trip.journal.roleEditor': 'Redaktə',
'trip.journal.hasAccess': 'Girişi var',
'trip.journal.warnNew':
'{names} bu gündəliyə ilk dəfə giriş əldə edəcək və bundan sonra onu öz gündəlik siyahısında da görəcək.',
'trip.journal.errorOffline': 'Gündəliyi bağlamaq üçün bağlantı lazımdır.',
'trip.journal.errorForbidden': 'Bunu etməyə icazəniz yoxdur.',
'trip.journal.errorGeneric': 'Alınmadı. Yenidən cəhd edin.',
'trip.journal.linked': 'Gündəlik bağlandı',
'trip.journal.membersSaved': 'Giriş yadda saxlanıldı',
'trip.journal.unlinked': 'Gündəlik ayrıldı',
};
export default trip;
-1
View File
@@ -1,7 +1,6 @@
import type { TranslationStrings } from '../types';
const members: TranslationStrings = {
'members.shareTrip': 'Compartilhar viagem',
'members.inviteUser': 'Convidar usuário',
'members.selectUser': 'Selecionar usuário…',
'members.invite': 'Convidar',
-1
View File
@@ -2,7 +2,6 @@ import type { TranslationStrings } from '../types';
const nav: TranslationStrings = {
'nav.trip': 'Viagem',
'nav.share': 'Compartilhar',
'nav.settings': 'Configurações',
'nav.admin': 'Admin',
'nav.logout': 'Sair',
+40
View File
@@ -99,5 +99,45 @@ const trip: TranslationStrings = {
'Isso também exclui a estadia reservada em “{name}”, a reserva “{booking}” e qualquer despesa vinculada a ela.',
'trip.confirm.deletePlaceBookedSame':
'Isso também exclui a estadia reservada em “{name}”, a reserva dela e qualquer despesa vinculada.',
'trip.settings.title': 'Configurações da viagem',
'trip.settings.people': 'Pessoas',
'trip.settings.peopleHint': 'Quem está nesta viagem e os convidados sem conta.',
'trip.settings.sharing': 'Compartilhar',
'trip.settings.sharingHint': 'Um link somente leitura para qualquer pessoa e um link de convite para entrar.',
'trip.tabs.journal': 'Diário',
'trip.journal.title': 'Diário',
'trip.journal.hint':
'Vincule um dos seus diários a esta viagem. Quem tiver acesso abre ele aqui mesmo na aba Diário, ao vivo.',
'trip.journal.link': 'Vincular um diário',
'trip.journal.linkSubmit': 'Vincular diário',
'trip.journal.change': 'Trocar diário',
'trip.journal.editMembers': 'Editar acesso',
'trip.journal.unlink': 'Desvincular',
'trip.journal.unlinkTitle': 'Desvincular este diário?',
'trip.journal.unlinkMessage':
'A aba Diário some desta viagem. Membros que só conseguiam abrir o diário por esta viagem perdem o acesso; quem foi adicionado no próprio diário continua com ele.',
'trip.journal.linkedBadge': 'Vinculado',
'trip.journal.yourRole.owner': 'Seu diário',
'trip.journal.yourRole.editor': 'Você pode editar',
'trip.journal.yourRole.viewer': 'Você pode ler',
'trip.journal.onlyYou': 'Ninguém mais desta viagem tem acesso ainda.',
'trip.journal.noJournals': 'Você ainda não tem nenhum diário. Crie um primeiro em Jornada.',
'trip.journal.pickPlaceholder': 'Escolha um diário',
'trip.journal.noMembers': 'Ainda não há mais ninguém nesta viagem.',
'trip.journal.members': 'Acesso para membros da viagem',
'trip.journal.allRead': 'Todos podem ler',
'trip.journal.allNone': 'Ninguém',
'trip.journal.roleNone': 'Sem acesso',
'trip.journal.roleViewer': 'Ler',
'trip.journal.roleEditor': 'Editar',
'trip.journal.hasAccess': 'Tem acesso',
'trip.journal.warnNew':
'{names} vai ganhar acesso a este diário pela primeira vez e a partir de então também vê-lo na própria lista de diários.',
'trip.journal.errorOffline': 'Para vincular um diário é preciso conexão.',
'trip.journal.errorForbidden': 'Você não tem permissão para isso.',
'trip.journal.errorGeneric': 'Não deu certo. Tente novamente.',
'trip.journal.linked': 'Diário vinculado',
'trip.journal.membersSaved': 'Acesso salvo',
'trip.journal.unlinked': 'Diário desvinculado',
};
export default trip;
-1
View File
@@ -1,7 +1,6 @@
import type { TranslationStrings } from '../types';
const members: TranslationStrings = {
'members.shareTrip': 'Comparteix el viatge',
'members.inviteUser': 'Convida un usuari',
'members.selectUser': 'Selecciona un usuari…',
'members.invite': 'Convida',
-1
View File
@@ -2,7 +2,6 @@ import type { TranslationStrings } from '../types';
const nav: TranslationStrings = {
'nav.trip': 'Viatge',
'nav.share': 'Comparteix',
'nav.settings': 'Configuració',
'nav.admin': 'Administració',
'nav.logout': 'Tanca la sessió',
+40
View File
@@ -99,5 +99,45 @@ const trip: TranslationStrings = {
'També s’eliminen l’estada reservada a «{name}», la reserva «{booking}» i qualsevol despesa vinculada.',
'trip.confirm.deletePlaceBookedSame':
'També s’eliminen l’estada reservada a «{name}», la seva reserva i qualsevol despesa vinculada.',
'trip.settings.title': 'Configuració del viatge',
'trip.settings.people': 'Persones',
'trip.settings.peopleHint': 'Qui és en aquest viatge i els convidats sense compte.',
'trip.settings.sharing': 'Compartir',
'trip.settings.sharingHint': "Un enllaç de només lectura per a tothom i un enllaç d'invitació per unir-se.",
'trip.tabs.journal': 'Diari',
'trip.journal.title': 'Diari',
'trip.journal.hint':
"Vincula un dels teus diaris a aquest viatge. Qui hi tingui accés l'obre aquí mateix a la pestanya Diari, en directe.",
'trip.journal.link': 'Vincula un diari',
'trip.journal.linkSubmit': 'Vincula el diari',
'trip.journal.change': 'Canvia el diari',
'trip.journal.editMembers': "Edita l'accés",
'trip.journal.unlink': 'Desvincula',
'trip.journal.unlinkTitle': 'Vols desvincular aquest diari?',
'trip.journal.unlinkMessage':
"La pestanya Diari desapareix d'aquest viatge. Els membres que només podien obrir el diari a través d'aquest viatge en perden l'accés; les persones afegides al mateix diari el conserven.",
'trip.journal.linkedBadge': 'Vinculat',
'trip.journal.yourRole.owner': 'El teu diari',
'trip.journal.yourRole.editor': 'Pots editar',
'trip.journal.yourRole.viewer': 'Pots llegir',
'trip.journal.onlyYou': "Ningú més d'aquest viatge hi té accés encara.",
'trip.journal.noJournals': 'Encara no tens cap diari. Crea-ne un primer a Travesia.',
'trip.journal.pickPlaceholder': 'Tria un diari',
'trip.journal.noMembers': 'Encara no hi ha ningú més en aquest viatge.',
'trip.journal.members': 'Accés per als membres del viatge',
'trip.journal.allRead': 'Tothom pot llegir',
'trip.journal.allNone': 'Ningú',
'trip.journal.roleNone': 'Sense accés',
'trip.journal.roleViewer': 'Llegir',
'trip.journal.roleEditor': 'Editar',
'trip.journal.hasAccess': 'Té accés',
'trip.journal.warnNew':
"{names} tindrà accés a aquest diari per primera vegada i a partir d'ara també el veurà a la seva pròpia llista de diaris.",
'trip.journal.errorOffline': 'Per vincular un diari cal connexió.',
'trip.journal.errorForbidden': 'No tens permís per fer això.',
'trip.journal.errorGeneric': 'No ha funcionat. Torna-ho a provar.',
'trip.journal.linked': 'Diari vinculat',
'trip.journal.membersSaved': 'Accés desat',
'trip.journal.unlinked': 'Diari desvinculat',
};
export default trip;
-1
View File
@@ -1,7 +1,6 @@
import type { TranslationStrings } from '../types';
const members: TranslationStrings = {
'members.shareTrip': 'Sdílet cestu',
'members.inviteUser': 'Pozvat uživatele',
'members.selectUser': 'Vyberte uživatele…',
'members.invite': 'Pozvat',
-1
View File
@@ -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',
+40
View File
@@ -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;
-1
View File
@@ -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',
-1
View File
@@ -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',
+40
View File
@@ -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;
-1
View File
@@ -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',
-1
View File
@@ -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',
+40
View File
@@ -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;
-1
View File
@@ -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',
-1
View File
@@ -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',
+40
View File
@@ -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;
-1
View File
@@ -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',
-1
View File
@@ -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',
+40
View File
@@ -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;
-1
View File
@@ -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',
-1
View File
@@ -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',
+40
View File
@@ -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;
-1
View File
@@ -1,7 +1,6 @@
import type { TranslationStrings } from '../types';
const members: TranslationStrings = {
'members.shareTrip': 'Κοινή Χρήση Ταξιδιού',
'members.inviteUser': 'Πρόσκληση Χρήστη',
'members.selectUser': 'Επιλέξτε χρήστη…',
'members.invite': 'Πρόσκληση',
-1
View File
@@ -2,7 +2,6 @@ import type { TranslationStrings } from '../types';
const nav: TranslationStrings = {
'nav.trip': 'Ταξίδι',
'nav.share': 'Διαμοιρασμός',
'nav.settings': 'Ρυθμίσεις',
'nav.admin': 'Διαχείριση',
'nav.logout': 'Αποσύνδεση',
+41
View File
@@ -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;
-1
View File
@@ -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',
-1
View File
@@ -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',
+40
View File
@@ -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;
-1
View File
@@ -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',
-1
View File
@@ -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',
+40
View File
@@ -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;
-1
View File
@@ -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',
-1
View File
@@ -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',
+40
View File
@@ -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;
-1
View File
@@ -1,7 +1,6 @@
import type { TranslationStrings } from '../types';
const members: TranslationStrings = {
'members.shareTrip': '旅行を共有',
'members.inviteUser': 'ユーザーを招待',
'members.selectUser': 'ユーザーを選択…',
'members.invite': '招待',
-1
View File
@@ -2,7 +2,6 @@ import type { TranslationStrings } from '../types';
const nav: TranslationStrings = {
'nav.trip': '旅行',
'nav.share': '共有',
'nav.settings': '設定',
'nav.admin': '管理',
'nav.logout': 'ログアウト',
+40
View File
@@ -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;
-1
View File
@@ -1,7 +1,6 @@
import type { TranslationStrings } from '../types';
const members: TranslationStrings = {
'members.shareTrip': '여행 공유',
'members.inviteUser': '사용자 초대',
'members.selectUser': '사용자 선택…',
'members.invite': '초대',
-1
View File
@@ -2,7 +2,6 @@ import type { TranslationStrings } from '../types';
const nav: TranslationStrings = {
'nav.trip': '여행',
'nav.share': '공유',
'nav.settings': '설정',
'nav.admin': '관리자',
'nav.logout': '로그아웃',
+39
View File
@@ -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;
-1
View File
@@ -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',
-1
View File
@@ -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',
+40
View File
@@ -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;
-1
View File
@@ -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ś',
-1
View File
@@ -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',
+40
View File
@@ -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;
-1
View File
@@ -1,7 +1,6 @@
import type { TranslationStrings } from '../types';
const members: TranslationStrings = {
'members.shareTrip': 'Поделиться поездкой',
'members.inviteUser': 'Пригласить пользователя',
'members.selectUser': 'Выберите пользователя…',
'members.invite': 'Пригласить',
-1
View File
@@ -2,7 +2,6 @@ import type { TranslationStrings } from '../types';
const nav: TranslationStrings = {
'nav.trip': 'Поездка',
'nav.share': 'Поделиться',
'nav.settings': 'Настройки',
'nav.admin': 'Администрирование',
'nav.logout': 'Выйти',
+40
View File
@@ -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;
-1
View File
@@ -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ť',
-1
View File
@@ -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',
+40
View File
@@ -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;
-1
View File
@@ -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',
-1
View File
@@ -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',
+40
View File
@@ -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;
-1
View File
@@ -1,7 +1,6 @@
import type { TranslationStrings } from '../types';
const members: TranslationStrings = {
'members.shareTrip': 'แบ่งปันการเดินทาง',
'members.inviteUser': 'เชิญผู้ใช้',
'members.selectUser': 'เลือกผู้ใช้...',
'members.invite': 'เชิญ',
-1
View File
@@ -2,7 +2,6 @@ import type { TranslationStrings } from '../types';
const nav: TranslationStrings = {
'nav.trip': 'การเดินทาง',
'nav.share': 'แบ่งปัน',
'nav.settings': 'การตั้งค่า',
'nav.admin': 'ผู้ดูแลระบบ',
'nav.logout': 'ออกจากระบบ',
+39
View File
@@ -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;
-1
View File
@@ -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',
-1
View File
@@ -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',
+40
View File
@@ -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;
-1
View File
@@ -1,7 +1,6 @@
import type { TranslationStrings } from '../types';
const members: TranslationStrings = {
'members.shareTrip': 'Поділитися поїздкою',
'members.inviteUser': 'Запросити користувача',
'members.selectUser': 'Оберіть користувача…',
'members.invite': 'Запросити',
-1
View File
@@ -2,7 +2,6 @@ import type { TranslationStrings } from '../types';
const nav: TranslationStrings = {
'nav.trip': 'Поїздка',
'nav.share': 'Поділитися',
'nav.settings': 'Налаштування',
'nav.admin': 'Адмін',
'nav.logout': 'Вийти',
+40
View File
@@ -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;

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