mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-24 07:53:09 +00:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1487ad97f1 | |||
| fc27b254bb | |||
| 658536c217 |
@@ -1300,7 +1300,7 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => p.locator('.st-head-close'),
|
||||
target: p => p.locator('.st-back'),
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -26,5 +26,5 @@
|
||||
"src/pages/atlas/useAtlas.ts": 1211,
|
||||
"src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx": 3518,
|
||||
"src/pages/tripPlanner/useTripPlanner.test.tsx": 3270,
|
||||
"src/styles/studio.css": 1936
|
||||
"src/styles/studio.css": 2009
|
||||
}
|
||||
|
||||
@@ -195,11 +195,7 @@ function useElementAdders(page: BookPageSetup) {
|
||||
const els = gridElements(grid, defaultGridBox(page, single))
|
||||
// The page's own cap, as a duplicate keeps it: frames that would make the book unsaveable are not added.
|
||||
const on = useStudioStore.getState().doc?.spreads[active]
|
||||
if (!on) return
|
||||
if (on.elements.length + els.length > MAX_SPREAD_ELEMENTS) {
|
||||
useStudioStore.getState().refuseFull()
|
||||
return
|
||||
}
|
||||
if (!on || on.elements.length + els.length > MAX_SPREAD_ELEMENTS) return
|
||||
commit(d => ({
|
||||
...d,
|
||||
spreads: d.spreads.map((sp, i) => (i !== active ? sp : { ...sp, elements: [...sp.elements, ...els] })),
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { fireEvent, render, screen } from '../../../tests/helpers/render';
|
||||
import { useStudioStore } from '../../store/studioStore';
|
||||
import { StudioHeader } from './StudioHeader';
|
||||
import type { Studio } from './StudioShell';
|
||||
import type { StudioAssistant } from './useStudioAssistant';
|
||||
|
||||
/**
|
||||
* FE-STUDIO-HEAD-001 to FE-STUDIO-HEAD-007: the Studio's head band, with the
|
||||
* hook's answer stubbed to what the band reads.
|
||||
*/
|
||||
|
||||
function studio(over: Partial<Studio> = {}): Studio {
|
||||
return {
|
||||
t: (key: string) => key,
|
||||
journey: { title: 'Japan 2026' },
|
||||
coverUrl: '/uploads/cover.jpg',
|
||||
saveState: { status: 'idle' },
|
||||
peers: [],
|
||||
canUndo: true,
|
||||
canRedo: false,
|
||||
canEdit: true,
|
||||
activeSpread: 0,
|
||||
doc: { spreads: [] },
|
||||
preset: 'square-210',
|
||||
page: { pageWidth: 210, pageHeight: 210, bleed: 3, safe: 5 },
|
||||
canRelayoutSpread: true,
|
||||
undo: vi.fn(),
|
||||
redo: vi.fn(),
|
||||
close: vi.fn(),
|
||||
setPreset: vi.fn(),
|
||||
setPageSize: vi.fn(),
|
||||
setPageEdge: vi.fn(),
|
||||
relayoutCurrentSpread: vi.fn(),
|
||||
relayoutBook: vi.fn(),
|
||||
loadDoc: vi.fn(),
|
||||
acceptTheirs: vi.fn(),
|
||||
keepMine: vi.fn(),
|
||||
saveNow: vi.fn(),
|
||||
...over,
|
||||
} as unknown as Studio;
|
||||
}
|
||||
|
||||
const noAssistant = { available: false } as unknown as StudioAssistant;
|
||||
|
||||
function head(s = studio(), onExport = vi.fn()) {
|
||||
render(<StudioHeader s={s} onExport={onExport} assistant={noAssistant} />);
|
||||
return { s, onExport };
|
||||
}
|
||||
|
||||
describe('StudioHeader', () => {
|
||||
it("FE-STUDIO-HEAD-001: names the journey under the Studio's mark, with its cover as the tile", () => {
|
||||
head();
|
||||
expect(screen.getByRole('heading', { name: 'Japan 2026' })).toBeInTheDocument();
|
||||
expect(screen.getByText('journey.studio.beta')).toBeInTheDocument();
|
||||
expect(document.querySelector('.st-head-tile img')?.getAttribute('src')).toBe('/uploads/cover.jpg');
|
||||
});
|
||||
|
||||
it('FE-STUDIO-HEAD-002: the round button closes the Studio, export hands over', () => {
|
||||
const { s, onExport } = head();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'journey.studio.backToJourney' }));
|
||||
expect(s.close).toHaveBeenCalled();
|
||||
fireEvent.click(screen.getByRole('button', { name: /journey.studio.export/ }));
|
||||
expect(onExport).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-STUDIO-HEAD-003: undo runs, redo waits for something to redo, paste for something copied', () => {
|
||||
const { s } = head();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'journey.studio.undo' }));
|
||||
expect(s.undo).toHaveBeenCalled();
|
||||
expect(screen.getByRole('button', { name: 'journey.studio.redo' })).toBeDisabled();
|
||||
expect(screen.getByRole('button', { name: 'journey.studio.paste' })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('FE-STUDIO-HEAD-004: the format chip names the format and opens the list, a pick sets it', () => {
|
||||
const { s } = head();
|
||||
const chip = screen.getByRole('button', { name: 'journey.studio.format' });
|
||||
expect(chip).toHaveAttribute('aria-expanded', 'false');
|
||||
fireEvent.click(chip);
|
||||
const options = screen.getAllByRole('option');
|
||||
expect(options.length).toBeGreaterThan(1);
|
||||
fireEvent.click(options[options.length - 1]);
|
||||
expect(s.setPreset).toHaveBeenCalled();
|
||||
expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-STUDIO-HEAD-005: a custom format reads its size, and Escape or a press outside closes the list', () => {
|
||||
head(
|
||||
studio({ preset: 'custom', page: { pageWidth: 180.4, pageHeight: 240, bleed: 3, safe: 5 } } as Partial<Studio>)
|
||||
);
|
||||
const chip = screen.getByRole('button', { name: 'journey.studio.format' });
|
||||
expect(chip).toHaveTextContent('180 × 240 mm');
|
||||
fireEvent.click(chip);
|
||||
fireEvent.keyDown(document, { key: 'Escape' });
|
||||
expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
|
||||
fireEvent.click(chip);
|
||||
fireEvent.pointerDown(document.body);
|
||||
expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-STUDIO-HEAD-006: auto layout offers this spread and the whole book', () => {
|
||||
const { s } = head();
|
||||
fireEvent.click(screen.getByRole('button', { name: /journey.studio.autoLayout/ }));
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: /journey.studio.relayoutSpread\b/ }));
|
||||
expect(s.relayoutCurrentSpread).toHaveBeenCalled();
|
||||
fireEvent.click(screen.getByRole('button', { name: /journey.studio.autoLayout/ }));
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: /journey.studio.relayoutBook\b/ }));
|
||||
expect(s.relayoutBook).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-STUDIO-HEAD-007: with something copied, paste puts it on the page on screen', () => {
|
||||
const paste = vi.fn();
|
||||
useStudioStore.setState({ clipboard: { elements: [] } as never, paste } as never);
|
||||
head(studio({ activeSpread: 2 } as Partial<Studio>));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'journey.studio.paste' }));
|
||||
expect(paste).toHaveBeenCalledWith(2);
|
||||
});
|
||||
});
|
||||
@@ -1,379 +0,0 @@
|
||||
import { Check, ChevronDown, ClipboardPaste, Download, Redo2, Sparkles, Undo2, X } from 'lucide-react';
|
||||
import { useEffect, useRef, useState, type RefObject } from 'react';
|
||||
import { useStudioStore } from '../../store/studioStore';
|
||||
import '../../styles/studio-head.css';
|
||||
import HelpButton from '../Help/HelpButton';
|
||||
import { Tooltip } from '../shared/Tooltip';
|
||||
import { PAGE_PRESET_ORDER, PAGE_PRESETS } from './pagePresets';
|
||||
import { PeerBadges } from './PeerBadges';
|
||||
import { SaveIndicator } from './SaveIndicator';
|
||||
import { StudioAssistantToggle } from './StudioAssistantPanel';
|
||||
import type { Studio } from './StudioShell';
|
||||
import { StudioWordmark } from './StudioWordmark';
|
||||
import { TrimField } from './TrimField';
|
||||
import type { StudioAssistant } from './useStudioAssistant';
|
||||
|
||||
/**
|
||||
* The Studio's head band, in the shape every TREK dialog has: a raised tile on
|
||||
* the left, a small line above the title, the title, a line under it, and the
|
||||
* round close button at the far end.
|
||||
*
|
||||
* The tile is the journey's cover and the title its name, so it is always clear
|
||||
* whose book this is. What the book is printed as and whether it is saved sit
|
||||
* under the title, because they describe it. The tools on the right are grouped
|
||||
* by what they do: who else is here, editing (undo, redo, paste), making
|
||||
* (auto layout, the assistant), and getting the book out (help, export). How
|
||||
* the page is looked at, the book view, lives with the zoom under the page.
|
||||
*/
|
||||
export function StudioHeader({
|
||||
s,
|
||||
onExport,
|
||||
assistant,
|
||||
}: {
|
||||
s: Studio;
|
||||
onExport: () => void;
|
||||
assistant: StudioAssistant;
|
||||
}) {
|
||||
return (
|
||||
<header className="st-bar st-head">
|
||||
<span className="st-head-tile">{s.coverUrl ? <img src={s.coverUrl} alt="" /> : null}</span>
|
||||
|
||||
<div className="st-head-text">
|
||||
<div className="st-head-eyebrow">
|
||||
<StudioWordmark style={{ height: 17 }} />
|
||||
<span className="st-beta">{s.t('journey.studio.beta')}</span>
|
||||
</div>
|
||||
<h1 className="st-head-title">{s.journey?.title || s.t('journey.title')}</h1>
|
||||
<div className="st-head-sub">
|
||||
<FormatPicker s={s} />
|
||||
<SaveIndicator
|
||||
state={s.saveState}
|
||||
t={s.t}
|
||||
onAcceptTheirs={(current) => s.loadDoc(s.acceptTheirs(current))}
|
||||
onKeepMine={s.keepMine}
|
||||
onRetry={() => {
|
||||
void s.saveNow();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="st-head-tools">
|
||||
<PeerBadges peers={s.peers} t={s.t} />
|
||||
|
||||
<div className="st-head-group">
|
||||
<IconTool label={s.t('journey.studio.undo')} disabled={!s.canUndo} onClick={s.undo}>
|
||||
<Undo2 size={15} />
|
||||
</IconTool>
|
||||
<IconTool label={s.t('journey.studio.redo')} disabled={!s.canRedo} onClick={s.redo}>
|
||||
<Redo2 size={15} />
|
||||
</IconTool>
|
||||
<PasteButton s={s} />
|
||||
</div>
|
||||
|
||||
<div className="st-head-group">
|
||||
<AutoLayoutButton s={s} />
|
||||
{/* The book's assistant; only where it can run for this user. */}
|
||||
<StudioAssistantToggle assistant={assistant} />
|
||||
</div>
|
||||
|
||||
{/* Studio has no navbar, so the help center's button rides on its own bar. */}
|
||||
<HelpButton className="st-tool is-icon" />
|
||||
<button
|
||||
type="button"
|
||||
className="st-tool is-primary"
|
||||
onClick={onExport}
|
||||
disabled={!s.doc}
|
||||
title={s.t('journey.studio.export')}
|
||||
>
|
||||
<Download size={14} />
|
||||
<span className="st-tool-label">{s.t('journey.studio.export')}</span>
|
||||
</button>
|
||||
|
||||
<Tooltip label={s.t('journey.studio.backToJourney')}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={s.close}
|
||||
className="st-head-close"
|
||||
aria-label={s.t('journey.studio.backToJourney')}
|
||||
>
|
||||
<X size={16} strokeWidth={2.2} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function IconTool({
|
||||
label,
|
||||
disabled,
|
||||
onClick,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
onClick: () => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Tooltip label={label}>
|
||||
<button type="button" className="st-tool is-icon" disabled={disabled} onClick={onClick} aria-label={label}>
|
||||
{children}
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes a popover on a pointer press outside it and on Escape. Escape is
|
||||
* swallowed, or the shell's own handler would close Studio itself.
|
||||
*/
|
||||
function useDismiss(box: RefObject<HTMLElement | null>, open: boolean, setOpen: (open: boolean) => void) {
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onDown = (e: PointerEvent) => {
|
||||
if (!box.current?.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.stopPropagation();
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('pointerdown', onDown);
|
||||
document.addEventListener('keydown', onKey, true);
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', onDown);
|
||||
document.removeEventListener('keydown', onKey, true);
|
||||
};
|
||||
}, [box, open, setOpen]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Paste what was copied onto the page on screen (#2316). Beside undo and redo
|
||||
* because it is the same kind of thing, and there so the keyboard shortcut is
|
||||
* not the only way to find it.
|
||||
*/
|
||||
function PasteButton({ s }: { s: Studio }) {
|
||||
const clipboard = useStudioStore((st) => st.clipboard);
|
||||
const paste = useStudioStore((st) => st.paste);
|
||||
const label = s.t('journey.studio.paste');
|
||||
return (
|
||||
<Tooltip label={clipboard ? label : s.t('journey.studio.pasteEmpty')}>
|
||||
<button
|
||||
type="button"
|
||||
className="st-tool is-icon"
|
||||
disabled={!clipboard || !s.canEdit}
|
||||
onClick={() => paste(s.activeSpread)}
|
||||
aria-label={label}
|
||||
>
|
||||
<ClipboardPaste size={15} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lay out again: this spread, or the whole book.
|
||||
*
|
||||
* A split button rather than two: they are the same action at two scopes, and
|
||||
* the spread is the one you want nine times out of ten. Rebuilding the whole
|
||||
* book is behind the chevron, where it cannot be hit by accident, and both are
|
||||
* ordinary undo steps.
|
||||
*/
|
||||
function AutoLayoutButton({ s }: { s: Studio }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const box = useRef<HTMLDivElement>(null);
|
||||
useDismiss(box, open, setOpen);
|
||||
|
||||
return (
|
||||
<div className="st-picker" ref={box}>
|
||||
<button
|
||||
type="button"
|
||||
className="st-tool"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
title={s.t('journey.studio.autoLayout')}
|
||||
>
|
||||
<Sparkles size={14} />
|
||||
<span className="st-tool-label">{s.t('journey.studio.autoLayout')}</span>
|
||||
<ChevronDown size={13} style={{ opacity: 0.5 }} />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="st-menu" role="menu">
|
||||
<button
|
||||
type="button"
|
||||
className="st-menu-item"
|
||||
role="menuitem"
|
||||
disabled={!s.canRelayoutSpread}
|
||||
onClick={() => {
|
||||
s.relayoutCurrentSpread();
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<span className="st-menu-text">
|
||||
<span className="st-menu-name">{s.t('journey.studio.relayoutSpread')}</span>
|
||||
<span className="st-menu-dim">
|
||||
{s.t(s.canRelayoutSpread ? 'journey.studio.relayoutSpreadHint' : 'journey.studio.relayoutSpreadNone')}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="st-menu-item"
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
s.relayoutBook();
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<span className="st-menu-text">
|
||||
<span className="st-menu-name">{s.t('journey.studio.relayoutBook')}</span>
|
||||
<span className="st-menu-dim">{s.t('journey.studio.relayoutBookHint')}</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The format, as a chip under the book's name: it says what the book is
|
||||
* printed as, and opening it changes that.
|
||||
*
|
||||
* A native <select> was the first draft and it was wrong: the browser draws the
|
||||
* list in its own chrome, so it ignores the theme entirely and drops a list that
|
||||
* runs off the bottom of the screen. This is a small popover instead, themed,
|
||||
* anchored under the chip, and it shows each format's proportions rather than
|
||||
* only naming them.
|
||||
*/
|
||||
function FormatPicker({ s }: { s: Studio }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const box = useRef<HTMLDivElement>(null);
|
||||
useDismiss(box, open, setOpen);
|
||||
|
||||
const active = PAGE_PRESETS[s.preset] ?? PAGE_PRESETS['square-210'];
|
||||
const width = s.preset === 'custom' ? s.page.pageWidth : active.pageWidthMm;
|
||||
const height = s.preset === 'custom' ? s.page.pageHeight : active.pageHeightMm;
|
||||
|
||||
return (
|
||||
<div className="st-picker" ref={box}>
|
||||
<button
|
||||
type="button"
|
||||
className="st-head-chip"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
aria-label={s.t('journey.studio.format')}
|
||||
>
|
||||
<span
|
||||
className="st-head-chip-shape"
|
||||
style={
|
||||
width >= height
|
||||
? { width: 12, height: Math.max(6, Math.round((12 * height) / width)) }
|
||||
: { height: 12, width: Math.max(6, Math.round((12 * width) / height)) }
|
||||
}
|
||||
/>
|
||||
<span>
|
||||
{s.preset === 'custom'
|
||||
? `${Math.round(s.page.pageWidth)} × ${Math.round(s.page.pageHeight)} mm`
|
||||
: s.t(active.labelKey)}
|
||||
</span>
|
||||
<ChevronDown size={12} style={{ opacity: 0.55 }} />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="st-menu st-head-menu" role="listbox">
|
||||
{PAGE_PRESET_ORDER.map((id) => {
|
||||
const p = PAGE_PRESETS[id];
|
||||
const on = id === s.preset;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={id}
|
||||
role="option"
|
||||
aria-selected={on}
|
||||
className={`st-menu-item ${on ? 'is-active' : ''}`}
|
||||
onClick={() => {
|
||||
s.setPreset(id);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<span className="st-menu-tile">
|
||||
<span
|
||||
className="st-menu-shape"
|
||||
style={
|
||||
p.pageWidthMm >= p.pageHeightMm
|
||||
? { width: 24, height: Math.round((24 * p.pageHeightMm) / p.pageWidthMm) }
|
||||
: { height: 24, width: Math.round((24 * p.pageWidthMm) / p.pageHeightMm) }
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
<span className="st-menu-text">
|
||||
<span className="st-menu-name">{s.t(p.labelKey)}</span>
|
||||
<span className="st-menu-dim">
|
||||
{p.pageWidthMm} × {p.pageHeightMm} mm
|
||||
</span>
|
||||
</span>
|
||||
{on && <Check size={14} />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
{/*
|
||||
The two fields live under the list rather than behind a "custom"
|
||||
entry that opens a second popover: picking the custom row and then
|
||||
typing into the fields it reveals is two steps for one decision, and
|
||||
typing into them *is* picking custom; the hook switches the preset
|
||||
for you.
|
||||
*/}
|
||||
<div className="st-menu-sep" />
|
||||
<div className="st-menu-custom">
|
||||
<TrimField
|
||||
label={s.t('journey.studio.width')}
|
||||
value={s.page.pageWidth}
|
||||
onCommit={(v) => s.setPageSize('w', v)}
|
||||
/>
|
||||
<span className="st-menu-times">×</span>
|
||||
<TrimField
|
||||
label={s.t('journey.studio.height')}
|
||||
value={s.page.pageHeight}
|
||||
onCommit={(v) => s.setPageSize('h', v)}
|
||||
/>
|
||||
<span className="st-menu-unit">mm</span>
|
||||
</div>
|
||||
|
||||
{/*
|
||||
What the press takes off, and what it must not print into. Under the
|
||||
trim size because they answer the same question, how big is this book
|
||||
really, and because a vendor states all three in one breath.
|
||||
*/}
|
||||
<div className="st-menu-custom">
|
||||
<TrimField
|
||||
label={s.t('journey.studio.bleed')}
|
||||
value={s.page.bleed}
|
||||
min={0}
|
||||
max={20}
|
||||
step={0.5}
|
||||
onCommit={(v) => s.setPageEdge('bleed', v)}
|
||||
/>
|
||||
<span className="st-menu-times" />
|
||||
<TrimField
|
||||
label={s.t('journey.studio.safeArea')}
|
||||
value={s.page.safe}
|
||||
min={0}
|
||||
max={40}
|
||||
step={0.5}
|
||||
onCommit={(v) => s.setPageEdge('safe', v)}
|
||||
/>
|
||||
<span className="st-menu-unit">mm</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { useStudioStore } from '../../store/studioStore';
|
||||
import { useToast } from '../shared/Toast';
|
||||
|
||||
/**
|
||||
* What the Studio says about a click it could not carry out. Today one thing:
|
||||
* the page already holds as many elements as a page can, so a sticker, a frame,
|
||||
* a paste or a duplicate was not added. Every path that refuses counts it in the
|
||||
* store, and this says it once per count rather than each path in its own words.
|
||||
*/
|
||||
export function StudioNotices() {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const refused = useStudioStore((s) => s.refusedFull);
|
||||
const seen = useRef(refused);
|
||||
useEffect(() => {
|
||||
if (refused === seen.current) return;
|
||||
seen.current = refused;
|
||||
toast.warning(t('journey.studio.pageFull'));
|
||||
}, [refused, toast, t]);
|
||||
return null;
|
||||
}
|
||||
@@ -1,8 +1,12 @@
|
||||
import { useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useNavigate } from 'react-router'
|
||||
import { BookOpen, Download, Maximize2, Minus, Plus } from 'lucide-react'
|
||||
import {
|
||||
ArrowLeft, BookOpen, Check, ChevronDown, ClipboardPaste, Download, Maximize2, Minus, Plus,
|
||||
Redo2, Sparkles, Undo2,
|
||||
} from 'lucide-react'
|
||||
import { useJourneyStudio } from '../../pages/journeyStudio/useJourneyStudio'
|
||||
import { PAGE_PRESET_ORDER, PAGE_PRESETS } from './pagePresets'
|
||||
import { foliosOf } from './bookSheets'
|
||||
import { StudioSidebar } from './StudioSidebar'
|
||||
import type { RailSection } from './sidebar/railSections'
|
||||
@@ -11,16 +15,21 @@ import { useAuthStore } from '../../store/authStore'
|
||||
import { StudioCanvas } from './StudioCanvas'
|
||||
import { StudioInspector } from './StudioInspector'
|
||||
import { StudioPaletteScope } from './paint/StudioPalette'
|
||||
import { StudioWordmark } from './StudioWordmark'
|
||||
import { SaveIndicator } from './SaveIndicator'
|
||||
import { downloadSpread } from './spreadFile'
|
||||
import { StudioExport } from './StudioExport'
|
||||
import { StudioAssistantColumn, StudioPageStage } from './StudioAssistantPanel'
|
||||
import { useStudioAssistant } from './useStudioAssistant'
|
||||
import { StudioHeader } from './StudioHeader'
|
||||
import { StudioNotices } from './StudioNotices'
|
||||
import { PeerBadges } from './PeerBadges'
|
||||
import { TrimField } from './TrimField'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
import { useStudioStore } from '../../store/studioStore'
|
||||
import { StudioAssistantColumn, StudioAssistantToggle, StudioPageStage } from './StudioAssistantPanel'
|
||||
import { useStudioAssistant, type StudioAssistant } from './useStudioAssistant'
|
||||
import '../../styles/dashboard.css'
|
||||
import '../../styles/studio.css'
|
||||
import './bookFontFaces'
|
||||
import { useWarmBookFaces } from './bookFontWarm'
|
||||
import HelpButton from '../Help/HelpButton'
|
||||
|
||||
/**
|
||||
* The Studio shell: top bar, page rail, workbench, inspector.
|
||||
@@ -84,8 +93,13 @@ export default function StudioShell() {
|
||||
aria-modal="true"
|
||||
aria-label={s.t('journey.studio.title')}
|
||||
>
|
||||
<StudioHeader s={s} onExport={() => setExporting(true)} assistant={assistant} />
|
||||
<StudioNotices />
|
||||
<StudioBar
|
||||
s={s}
|
||||
bookView={bookView}
|
||||
setBookView={setBookView}
|
||||
onExport={() => setExporting(true)}
|
||||
assistant={assistant}
|
||||
/>
|
||||
{/* The journey gallery's picker, inside Studio's own layer (#2271). */}
|
||||
{s.providerPick && s.journey && (
|
||||
<ProviderPicker
|
||||
@@ -118,7 +132,7 @@ export default function StudioShell() {
|
||||
section={railSection}
|
||||
onSection={setRailSection}
|
||||
/>
|
||||
<Workbench s={s} bookView={bookView} setBookView={setBookView} />
|
||||
<Workbench s={s} bookView={bookView} />
|
||||
<StudioPaletteScope spreadIndex={s.activeSpread} t={s.t}>
|
||||
<StudioInspector
|
||||
spreadIndex={s.activeSpread}
|
||||
@@ -150,9 +164,339 @@ export default function StudioShell() {
|
||||
)
|
||||
}
|
||||
|
||||
export type Studio = ReturnType<typeof useJourneyStudio>
|
||||
type Studio = ReturnType<typeof useJourneyStudio>
|
||||
|
||||
function Workbench({ s, bookView, setBookView }: { s: Studio; bookView: boolean; setBookView: (on: boolean) => void }) {
|
||||
function StudioBar({
|
||||
s, bookView, setBookView, onExport, assistant,
|
||||
}: {
|
||||
s: Studio; bookView: boolean; setBookView: (v: boolean) => void; onExport: () => void
|
||||
assistant: StudioAssistant
|
||||
}) {
|
||||
return (
|
||||
<div className="st-bar">
|
||||
<button type="button" onClick={s.close} className="st-back" title={s.t('journey.studio.backToJourney')}>
|
||||
<ArrowLeft size={16} />
|
||||
{s.coverUrl
|
||||
? <img src={s.coverUrl} alt="" className="st-cover" />
|
||||
: <span className="st-cover" />}
|
||||
<span className="st-back-label">{s.journey?.title || s.t('journey.title')}</span>
|
||||
</button>
|
||||
|
||||
<div className="st-sep" />
|
||||
|
||||
{/*
|
||||
The lockup, not a name field. The book is the journey's, and it was
|
||||
already titled after it — a second place to type the same name is a
|
||||
field whose only job is to be left alone. What the bar is short of is
|
||||
somewhere to say what this thing *is*.
|
||||
*/}
|
||||
<div className="st-brand">
|
||||
<StudioWordmark style={{ height: 26 }} />
|
||||
<span className="st-beta">{s.t('journey.studio.beta')}</span>
|
||||
</div>
|
||||
|
||||
<PeerBadges peers={s.peers} t={s.t} />
|
||||
|
||||
<SaveIndicator
|
||||
state={s.saveState}
|
||||
t={s.t}
|
||||
onAcceptTheirs={current => s.loadDoc(s.acceptTheirs(current))}
|
||||
onKeepMine={s.keepMine}
|
||||
onRetry={() => { void s.saveNow() }}
|
||||
/>
|
||||
|
||||
<div className="st-bar-group">
|
||||
<button type="button"
|
||||
className={`st-tool ${bookView ? 'is-on' : ''}`}
|
||||
onClick={() => setBookView(!bookView)}
|
||||
title={s.t('journey.studio.bookView')}
|
||||
>
|
||||
<BookOpen size={14} />
|
||||
<span className="st-tool-label">{s.t('journey.studio.bookView')}</span>
|
||||
</button>
|
||||
|
||||
<div className="st-sep" />
|
||||
|
||||
<FormatPicker s={s} />
|
||||
|
||||
<div className="st-sep" />
|
||||
|
||||
<button type="button"
|
||||
className="st-tool is-icon"
|
||||
disabled={!s.canUndo}
|
||||
onClick={s.undo}
|
||||
title={s.t('journey.studio.undo')}
|
||||
aria-label={s.t('journey.studio.undo')}
|
||||
>
|
||||
<Undo2 size={15} />
|
||||
</button>
|
||||
<button type="button"
|
||||
className="st-tool is-icon"
|
||||
disabled={!s.canRedo}
|
||||
onClick={s.redo}
|
||||
title={s.t('journey.studio.redo')}
|
||||
aria-label={s.t('journey.studio.redo')}
|
||||
>
|
||||
<Redo2 size={15} />
|
||||
</button>
|
||||
<PasteButton s={s} />
|
||||
|
||||
<div className="st-sep" />
|
||||
|
||||
<AutoLayoutButton s={s} />
|
||||
{/* The book's assistant, as a column beside the page; only where it can run for this user. */}
|
||||
<StudioAssistantToggle assistant={assistant} />
|
||||
<button type="button"
|
||||
className="st-tool is-primary"
|
||||
onClick={onExport}
|
||||
disabled={!s.doc}
|
||||
title={s.t('journey.studio.export')}
|
||||
>
|
||||
<Download size={14} />
|
||||
<span className="st-tool-label">{s.t('journey.studio.export')}</span>
|
||||
</button>
|
||||
{/* Studio has no navbar, so the help center's button rides on its own bar. */}
|
||||
<HelpButton className="st-tool is-icon" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Lay out again — this spread, or the whole book.
|
||||
*
|
||||
* A split button rather than two: they are the same action at two scopes, and
|
||||
* the spread is the one you want nine times out of ten. Rebuilding the whole
|
||||
* book is behind the chevron, where it cannot be hit by accident, and both are
|
||||
* ordinary undo steps.
|
||||
*/
|
||||
/**
|
||||
* Paste what was copied onto the page on screen (#2316). Beside undo and redo
|
||||
* because it is the same kind of thing, and there so the keyboard shortcut is
|
||||
* not the only way to find it.
|
||||
*/
|
||||
function PasteButton({ s }: { s: Studio }) {
|
||||
const clipboard = useStudioStore(st => st.clipboard)
|
||||
const paste = useStudioStore(st => st.paste)
|
||||
const label = s.t('journey.studio.paste')
|
||||
return (
|
||||
<Tooltip label={clipboard ? label : s.t('journey.studio.pasteEmpty')}>
|
||||
<button type="button"
|
||||
className="st-tool is-icon"
|
||||
disabled={!clipboard || !s.canEdit}
|
||||
onClick={() => paste(s.activeSpread)}
|
||||
aria-label={label}
|
||||
>
|
||||
<ClipboardPaste size={15} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
function AutoLayoutButton({ s }: { s: Studio }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const box = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const onDown = (e: PointerEvent) => {
|
||||
if (!box.current?.contains(e.target as Node)) setOpen(false)
|
||||
}
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') { e.stopPropagation(); setOpen(false) }
|
||||
}
|
||||
document.addEventListener('pointerdown', onDown)
|
||||
document.addEventListener('keydown', onKey, true)
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', onDown)
|
||||
document.removeEventListener('keydown', onKey, true)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
return (
|
||||
<div className="st-picker" ref={box}>
|
||||
<button type="button"
|
||||
className="st-tool"
|
||||
onClick={() => setOpen(o => !o)}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
title={s.t('journey.studio.autoLayout')}
|
||||
>
|
||||
<Sparkles size={14} />
|
||||
<span className="st-tool-label">{s.t('journey.studio.autoLayout')}</span>
|
||||
<ChevronDown size={13} style={{ opacity: .5 }} />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="st-menu" role="menu">
|
||||
<button type="button"
|
||||
className="st-menu-item"
|
||||
role="menuitem"
|
||||
disabled={!s.canRelayoutSpread}
|
||||
onClick={() => { s.relayoutCurrentSpread(); setOpen(false) }}
|
||||
>
|
||||
<span className="st-menu-text">
|
||||
<span className="st-menu-name">{s.t('journey.studio.relayoutSpread')}</span>
|
||||
<span className="st-menu-dim">
|
||||
{s.t(s.canRelayoutSpread ? 'journey.studio.relayoutSpreadHint' : 'journey.studio.relayoutSpreadNone')}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
<button type="button"
|
||||
className="st-menu-item"
|
||||
role="menuitem"
|
||||
onClick={() => { s.relayoutBook(); setOpen(false) }}
|
||||
>
|
||||
<span className="st-menu-text">
|
||||
<span className="st-menu-name">{s.t('journey.studio.relayoutBook')}</span>
|
||||
<span className="st-menu-dim">{s.t('journey.studio.relayoutBookHint')}</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The format picker.
|
||||
*
|
||||
* A native <select> was the first draft and it was wrong: the browser draws the
|
||||
* list in its own chrome, so it ignores the theme entirely and drops a list that
|
||||
* runs off the bottom of the screen. This is a small popover instead — themed,
|
||||
* anchored under the bar, and it shows each format's proportions rather than
|
||||
* only naming them.
|
||||
*/
|
||||
function FormatPicker({ s }: { s: Studio }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const box = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const onDown = (e: PointerEvent) => {
|
||||
if (!box.current?.contains(e.target as Node)) setOpen(false)
|
||||
}
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
// Swallow it, or the shell's Escape handler would close Studio itself.
|
||||
if (e.key === 'Escape') { e.stopPropagation(); setOpen(false) }
|
||||
}
|
||||
document.addEventListener('pointerdown', onDown)
|
||||
document.addEventListener('keydown', onKey, true)
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', onDown)
|
||||
document.removeEventListener('keydown', onKey, true)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const active = PAGE_PRESETS[s.preset] ?? PAGE_PRESETS['square-210']
|
||||
|
||||
return (
|
||||
<div className="st-picker" ref={box}>
|
||||
<button type="button"
|
||||
className="st-tool"
|
||||
onClick={() => setOpen(o => !o)}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
aria-label={s.t('journey.studio.format')}
|
||||
>
|
||||
<span className="st-tool-label">
|
||||
{s.preset === 'custom'
|
||||
? `${Math.round(s.page.pageWidth)} × ${Math.round(s.page.pageHeight)} mm`
|
||||
: s.t(active.labelKey)}
|
||||
</span>
|
||||
<ChevronDown size={13} style={{ opacity: .5 }} />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="st-menu" role="listbox">
|
||||
{PAGE_PRESET_ORDER.map(id => {
|
||||
const p = PAGE_PRESETS[id]
|
||||
const on = id === s.preset
|
||||
return (
|
||||
<button type="button"
|
||||
key={id}
|
||||
role="option"
|
||||
aria-selected={on}
|
||||
className={`st-menu-item ${on ? 'is-active' : ''}`}
|
||||
onClick={() => { s.setPreset(id); setOpen(false) }}
|
||||
>
|
||||
<span className="st-menu-tile">
|
||||
<span
|
||||
className="st-menu-shape"
|
||||
style={
|
||||
p.pageWidthMm >= p.pageHeightMm
|
||||
? { width: 24, height: Math.round(24 * p.pageHeightMm / p.pageWidthMm) }
|
||||
: { height: 24, width: Math.round(24 * p.pageWidthMm / p.pageHeightMm) }
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
<span className="st-menu-text">
|
||||
<span className="st-menu-name">{s.t(p.labelKey)}</span>
|
||||
<span className="st-menu-dim">{p.pageWidthMm} × {p.pageHeightMm} mm</span>
|
||||
</span>
|
||||
{on && <Check size={14} />}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
|
||||
{/*
|
||||
The two fields live under the list rather than behind a "custom"
|
||||
entry that opens a second popover: picking the custom row and then
|
||||
typing into the fields it reveals is two steps for one decision, and
|
||||
typing into them *is* picking custom — the hook switches the preset
|
||||
for you.
|
||||
*/}
|
||||
<div className="st-menu-sep" />
|
||||
<div className="st-menu-custom">
|
||||
<TrimField
|
||||
label={s.t('journey.studio.width')}
|
||||
value={s.page.pageWidth}
|
||||
onCommit={v => s.setPageSize('w', v)}
|
||||
/>
|
||||
<span className="st-menu-times">×</span>
|
||||
<TrimField
|
||||
label={s.t('journey.studio.height')}
|
||||
value={s.page.pageHeight}
|
||||
onCommit={v => s.setPageSize('h', v)}
|
||||
/>
|
||||
<span className="st-menu-unit">mm</span>
|
||||
</div>
|
||||
|
||||
{/*
|
||||
What the press takes off, and what it must not print into.
|
||||
|
||||
Under the trim size because they answer the same question — how big
|
||||
is this book, really — and because a vendor states all three in one
|
||||
breath. They were fixed at 3 and 5, which is what most photo-book
|
||||
printers ask for and is useless to the one that asks for 5 and 10.
|
||||
*/}
|
||||
<div className="st-menu-custom">
|
||||
<TrimField
|
||||
label={s.t('journey.studio.bleed')}
|
||||
value={s.page.bleed}
|
||||
min={0}
|
||||
max={20}
|
||||
step={0.5}
|
||||
onCommit={v => s.setPageEdge('bleed', v)}
|
||||
/>
|
||||
<span className="st-menu-times" />
|
||||
<TrimField
|
||||
label={s.t('journey.studio.safeArea')}
|
||||
value={s.page.safe}
|
||||
min={0}
|
||||
max={40}
|
||||
step={0.5}
|
||||
onCommit={v => s.setPageEdge('safe', v)}
|
||||
/>
|
||||
<span className="st-menu-unit">mm</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Workbench({ s, bookView }: { s: Studio; bookView: boolean }) {
|
||||
return (
|
||||
<div className="st-work-wrap">
|
||||
<div className="st-work" ref={s.workRef}>
|
||||
@@ -194,16 +538,6 @@ function Workbench({ s, bookView, setBookView }: { s: Studio; bookView: boolean;
|
||||
<button type="button" onClick={s.zoomToFit} aria-label={s.t('journey.studio.zoomFit')} title={s.t('journey.studio.zoomFit')}>
|
||||
<Maximize2 size={14} />
|
||||
</button>
|
||||
{/* How the page is looked at, so with the zoom rather than in the head band. */}
|
||||
<button type="button"
|
||||
onClick={() => setBookView(!bookView)}
|
||||
aria-pressed={bookView}
|
||||
aria-label={s.t('journey.studio.bookView')}
|
||||
title={s.t('journey.studio.bookView')}
|
||||
className={bookView ? 'is-on' : undefined}
|
||||
>
|
||||
<BookOpen size={14} />
|
||||
</button>
|
||||
{/*
|
||||
The page, as a file.
|
||||
|
||||
|
||||
@@ -99,11 +99,7 @@ export function StudioTextStyles({
|
||||
const place = (lockup: BookLockup) => {
|
||||
const els = placeLockup(lockup, { page, spread, words, gallery: source.photos, newId: bookElementId });
|
||||
// The page's own cap, as a duplicate keeps it: a set that would make the book unsaveable is not placed.
|
||||
if (!els.length || !spread) return;
|
||||
if (spread.elements.length + els.length > MAX_SPREAD_ELEMENTS) {
|
||||
useStudioStore.getState().refuseFull();
|
||||
return;
|
||||
}
|
||||
if (!els.length || !spread || spread.elements.length + els.length > MAX_SPREAD_ELEMENTS) return;
|
||||
commit((d) => ({
|
||||
...d,
|
||||
spreads: d.spreads.map((sp, i) => (i === active ? addBookLockup(sp, els, bookElementId('g')) : sp)),
|
||||
|
||||
@@ -25,15 +25,15 @@ export default function JoinTripPage() {
|
||||
className="bg-surface-hover"
|
||||
style={{ width: 52, height: 52, borderRadius: '50%', display: 'flex', alignItems: 'center', justifyContent: 'center', margin: '0 auto 16px' }}
|
||||
>
|
||||
{state === 'invalid'
|
||||
{state === 'invalid' || state === 'removed'
|
||||
? <X size={24} className="text-content-faint" />
|
||||
: <Users size={24} className="text-accent" />}
|
||||
</div>
|
||||
|
||||
{state === 'invalid' ? (
|
||||
{state === 'invalid' || state === 'removed' ? (
|
||||
<>
|
||||
<h1 style={{ fontSize: 18, fontWeight: 700, marginBottom: 8 }}>{t('trip.invite.invalidTitle')}</h1>
|
||||
<p className="text-content-secondary" style={{ fontSize: 14, marginBottom: 20 }}>{t('trip.invite.invalid')}</p>
|
||||
<h1 style={{ fontSize: 18, fontWeight: 700, marginBottom: 8 }}>{state === 'removed' ? t('trip.invite.removedTitle') : t('trip.invite.invalidTitle')}</h1>
|
||||
<p className="text-content-secondary" style={{ fontSize: 14, marginBottom: 20 }}>{state === 'removed' ? t('trip.invite.removed') : t('trip.invite.invalid')}</p>
|
||||
<button type="button"
|
||||
onClick={goToDashboard}
|
||||
className="bg-surface-hover text-content"
|
||||
|
||||
@@ -89,6 +89,31 @@ describe('useJoinTrip', () => {
|
||||
expect(navigate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// GHSA-wr4c-964p-39jg: somebody another member took off the trip is told so,
|
||||
// not offered a join that fails.
|
||||
it('FE-PAGE-JOIN-012: a removed user sees the removal instead of the join prompt', async () => {
|
||||
vi.mocked(tripInviteApi.preview).mockRejectedValue({ response: { status: 403, data: { error: 'removed', removed: true } } });
|
||||
const { result } = renderHook(() => useJoinTrip());
|
||||
await waitFor(() => expect(result.current.state).toBe('removed'));
|
||||
});
|
||||
|
||||
it('FE-PAGE-JOIN-013: a removal refused at accept also shows the removal', async () => {
|
||||
vi.mocked(tripInviteApi.accept).mockRejectedValue({ response: { status: 403, data: { error: 'removed', removed: true } } });
|
||||
const { result } = renderHook(() => useJoinTrip());
|
||||
await waitFor(() => expect(result.current.state).toBe('ready'));
|
||||
|
||||
act(() => result.current.accept());
|
||||
await waitFor(() => expect(result.current.state).toBe('removed'));
|
||||
expect(navigate).not.toHaveBeenCalled();
|
||||
expect(addToast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-PAGE-JOIN-014: any other 403 at preview stays an unavailable invite', async () => {
|
||||
vi.mocked(tripInviteApi.preview).mockRejectedValue({ response: { status: 403, data: { error: 'MFA required' } } });
|
||||
const { result } = renderHook(() => useJoinTrip());
|
||||
await waitFor(() => expect(result.current.state).toBe('invalid'));
|
||||
});
|
||||
|
||||
// A rate limit or a 500 is not an expired invite: saying so burns a link that
|
||||
// still works, and the invalid screen offers no way back.
|
||||
it('FE-PAGE-JOIN-010: a server failure reports itself and leaves the invite retryable', async () => {
|
||||
|
||||
@@ -5,7 +5,13 @@ import { useTranslation } from '../../i18n'
|
||||
import { getApiErrorMessage } from '../../types'
|
||||
import { useToast } from '../../components/shared/Toast'
|
||||
|
||||
export type JoinTripState = 'loading' | 'ready' | 'joining' | 'invalid'
|
||||
export type JoinTripState = 'loading' | 'ready' | 'joining' | 'invalid' | 'removed'
|
||||
|
||||
/** A 403 that says somebody took this user off the trip: the link cannot bring them back. */
|
||||
function isRemovedRefusal(err: unknown): boolean {
|
||||
const response = (err as { response?: { status?: number; data?: { removed?: unknown } } }).response
|
||||
return response?.status === 403 && response.data?.removed === true
|
||||
}
|
||||
|
||||
/**
|
||||
* State + effects behind JoinTripPage (#1143): resolve the invite token to a
|
||||
@@ -26,7 +32,7 @@ export function useJoinTrip() {
|
||||
if (!token) { setState('invalid'); return }
|
||||
tripInviteApi.preview(token)
|
||||
.then((data: { title: string }) => { if (!cancelled) { setTitle(data.title); setState('ready') } })
|
||||
.catch(() => { if (!cancelled) setState('invalid') })
|
||||
.catch((err: unknown) => { if (!cancelled) setState(isRemovedRefusal(err) ? 'removed' : 'invalid') })
|
||||
return () => { cancelled = true }
|
||||
}, [token])
|
||||
|
||||
@@ -43,6 +49,10 @@ export function useJoinTrip() {
|
||||
setState('invalid')
|
||||
return
|
||||
}
|
||||
if (isRemovedRefusal(err)) {
|
||||
setState('removed')
|
||||
return
|
||||
}
|
||||
setState('ready')
|
||||
toast.error(getApiErrorMessage(err, t('common.error')))
|
||||
})
|
||||
|
||||
@@ -54,7 +54,6 @@ interface Host {
|
||||
styleClip: BookCopiedStyle | null;
|
||||
commit: (fn: (doc: BookDocument) => BookDocument) => void;
|
||||
apply: (fn: (doc: BookDocument) => BookDocument) => void;
|
||||
refuseFull: () => void;
|
||||
}
|
||||
|
||||
type Set = (partial: Partial<Host> | ((s: Host) => Partial<Host>)) => void;
|
||||
@@ -371,11 +370,7 @@ function panelActions(
|
||||
if (!doc) return;
|
||||
const chosen = spread.elements.filter((e) => ids.includes(e.id));
|
||||
// The page's own cap: copies that would make the book unsaveable are not made.
|
||||
if (!chosen.length) return;
|
||||
if (spread.elements.length + chosen.length > MAX_SPREAD_ELEMENTS) {
|
||||
get().refuseFull();
|
||||
return;
|
||||
}
|
||||
if (!chosen.length || spread.elements.length + chosen.length > MAX_SPREAD_ELEMENTS) return;
|
||||
const copies = chosen.map(
|
||||
(e) =>
|
||||
({
|
||||
|
||||
@@ -35,14 +35,6 @@ interface StudioState extends StudioLayerActions {
|
||||
gestureBase: BookDocument | null
|
||||
/** Elements copied for pasting onto any page of the book (#2316). Kept across page switches. */
|
||||
clipboard: StudioClipboard | null
|
||||
/**
|
||||
* Counts the times something was not added because the page already holds
|
||||
* MAX_SPREAD_ELEMENTS. The Studio says so once per count (StudioNotices), so
|
||||
* a full page no longer swallows a click without a word.
|
||||
*/
|
||||
refusedFull: number
|
||||
/** Notes that an addition was refused because the page is full. */
|
||||
refuseFull: () => void
|
||||
/**
|
||||
* The page a chat card points at, outlined in the page rail (journey-studio.md
|
||||
* 4.2). Not part of the book or its history; opening a book clears it.
|
||||
@@ -155,8 +147,6 @@ export const useStudioStore = create<StudioState>((set, get) => ({
|
||||
future: [],
|
||||
gestureBase: null,
|
||||
clipboard: null,
|
||||
refusedFull: 0,
|
||||
refuseFull: () => set(st => ({ refusedFull: st.refusedFull + 1 })),
|
||||
hintSpread: null,
|
||||
readOnly: false,
|
||||
|
||||
@@ -238,11 +228,7 @@ export const useStudioStore = create<StudioState>((set, get) => ({
|
||||
addElement: (spreadIndex, el) => {
|
||||
// The page's own cap, as paste and duplicate keep it: what would make the book unsaveable is not added.
|
||||
const target = get().doc?.spreads[spreadIndex]
|
||||
if (!target) return false
|
||||
if (target.elements.length >= MAX_SPREAD_ELEMENTS) {
|
||||
get().refuseFull()
|
||||
return false
|
||||
}
|
||||
if (!target || target.elements.length >= MAX_SPREAD_ELEMENTS) return false
|
||||
get().commit(doc =>
|
||||
replaceSpread(doc, spreadIndex, sp => ({ ...sp, elements: [...sp.elements, el] })))
|
||||
/*
|
||||
@@ -282,10 +268,6 @@ export const useStudioStore = create<StudioState>((set, get) => ({
|
||||
const room = Math.max(0, MAX_SPREAD_ELEMENTS - target.elements.length)
|
||||
const source = doc.spreads.find(sp => sp.id === clipboard.fromSpreadId)
|
||||
// A copied group arrives as a group of its own, never as part of one already on the page.
|
||||
if (room === 0) {
|
||||
get().refuseFull()
|
||||
return
|
||||
}
|
||||
const copies = regroupCopies(pastedElements(clipboard, target, doc.page).slice(0, room), source, bookElementId)
|
||||
if (!copies.elements.length) return
|
||||
get().commit(d => replaceSpread(d, spreadIndex, sp => withCopies(sp, copies)))
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
inspector keep all their room. */
|
||||
|
||||
/* The toggle is the bar's newest tool, so it gives up its label first: from 1360
|
||||
to 1519 it is an icon while the others keep theirs, and the head band fits
|
||||
where it did before. */
|
||||
to 1519 it is an icon while the others keep theirs, and the bar fits where it
|
||||
did before. The journey's name gives way too rather than push the bar past
|
||||
the window. */
|
||||
@media (max-width: 1519px) {
|
||||
.trek-dash .st-assistant-toggle .st-tool-label {
|
||||
display: none;
|
||||
@@ -14,6 +15,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
.trek-dash .st-bar .st-back {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.trek-dash .st-bar .st-back > svg {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.trek-dash .st-assistant-notice {
|
||||
margin: 8px 12px 0;
|
||||
}
|
||||
|
||||
@@ -1,173 +0,0 @@
|
||||
/* ----------------- head band (StudioHeader.tsx) ----------------- *
|
||||
* The shape every TREK dialog head has: a tinted band, a raised tile, a small
|
||||
* line above the title and a round close button at the end. The band keeps the
|
||||
* glass of the old bar under its tint, so the journey still shows through.
|
||||
*/
|
||||
.trek-dash .st-head {
|
||||
/* Its own stacking context, above the rails: the panels below carry z-index 1,
|
||||
and without this the format menu would open behind them. */
|
||||
position: relative;
|
||||
z-index: 5;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
padding-inline: 14px 16px;
|
||||
background:
|
||||
linear-gradient(
|
||||
color-mix(in srgb, var(--accent) 7%, transparent),
|
||||
color-mix(in srgb, var(--accent) 7%, transparent)
|
||||
),
|
||||
var(--glass-bg);
|
||||
border-bottom: 1px solid var(--glass-border);
|
||||
backdrop-filter: var(--glass-blur);
|
||||
-webkit-backdrop-filter: var(--glass-blur);
|
||||
}
|
||||
|
||||
/* The journey's cover, so it is always clear whose book this is. */
|
||||
.trek-dash .st-head-tile {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
flex: none;
|
||||
overflow: hidden;
|
||||
border-radius: 13px;
|
||||
background: var(--surface);
|
||||
box-shadow: var(--sh-sm);
|
||||
}
|
||||
.trek-dash .st-head-tile img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.trek-dash .st-head-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
/* The wordmark and the beta mark: what this screen is, quietly, above its name. */
|
||||
.trek-dash .st-head-eyebrow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
height: 17px;
|
||||
color: var(--ink-2);
|
||||
}
|
||||
.trek-dash .st-head-eyebrow svg {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.trek-dash .st-head-title {
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--ink);
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.trek-dash .st-head-sub {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* The format, as a chip that opens the format menu. */
|
||||
.trek-dash .st-head-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 22px;
|
||||
padding-inline: 7px 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--surface);
|
||||
box-shadow: var(--sh-xs);
|
||||
color: var(--ink-2);
|
||||
font-size: 11.5px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
transition:
|
||||
color 0.16s,
|
||||
box-shadow 0.16s;
|
||||
}
|
||||
.trek-dash .st-head-chip:hover {
|
||||
color: var(--ink);
|
||||
box-shadow: var(--sh-sm);
|
||||
}
|
||||
.trek-dash .st-head-chip:focus {
|
||||
outline: none;
|
||||
}
|
||||
.trek-dash .st-head-chip:focus-visible {
|
||||
box-shadow:
|
||||
0 0 0 2px var(--accent-soft),
|
||||
0 0 0 3px var(--accent);
|
||||
}
|
||||
.trek-dash .st-head-chip-shape {
|
||||
flex: none;
|
||||
border: 1.5px solid currentColor;
|
||||
border-radius: 2px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
/* The chip sits on the left, so its menu opens towards the page, not off the window. */
|
||||
.trek-dash .st-head-menu {
|
||||
inset-inline-start: 0;
|
||||
inset-inline-end: auto;
|
||||
}
|
||||
|
||||
.trek-dash .st-head-tools {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: none;
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
|
||||
/* Tools that belong together share one raised group, like a segmented control. */
|
||||
.trek-dash .st-head-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 3px;
|
||||
border-radius: 12px;
|
||||
background: var(--surface);
|
||||
box-shadow: var(--sh-xs);
|
||||
}
|
||||
|
||||
.trek-dash .st-head-close {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
flex: none;
|
||||
margin-inline-start: 2px;
|
||||
border-radius: 999px;
|
||||
background: var(--surface);
|
||||
box-shadow: var(--sh-sm);
|
||||
color: var(--ink-3);
|
||||
transition: color 0.16s;
|
||||
}
|
||||
.trek-dash .st-head-close:hover {
|
||||
color: var(--ink);
|
||||
}
|
||||
.trek-dash .st-head-close:focus {
|
||||
outline: none;
|
||||
}
|
||||
.trek-dash .st-head-close:focus-visible {
|
||||
box-shadow:
|
||||
0 0 0 2px var(--accent-soft),
|
||||
0 0 0 3px var(--accent);
|
||||
}
|
||||
|
||||
/* The book view moved to the zoom, where the page's view lives; on reads as pressed. */
|
||||
.trek-dash .st-zoom button.is-on {
|
||||
background: color-mix(in oklch, var(--accent) 14%, transparent);
|
||||
color: var(--ink);
|
||||
}
|
||||
@@ -25,7 +25,7 @@
|
||||
z-index: 9999;
|
||||
inset: 16px;
|
||||
display: grid;
|
||||
grid-template-rows: 72px minmax(0, 1fr);
|
||||
grid-template-rows: 56px minmax(0, 1fr);
|
||||
border-radius: var(--r-xl);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--glass-border);
|
||||
@@ -88,7 +88,66 @@
|
||||
.st-exit { animation-name: st-fade-out; }
|
||||
}
|
||||
|
||||
/* ----------------- tools (head band and elsewhere) ----------------- */
|
||||
/* ----------------- top bar ----------------- */
|
||||
.trek-dash .st-bar {
|
||||
/* Its own stacking context, above the rails: the panels below carry z-index 1,
|
||||
and without this the format menu would open behind them. */
|
||||
position: relative;
|
||||
z-index: 5;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding-block: 0;
|
||||
padding-inline: 10px 18px;
|
||||
min-width: 0;
|
||||
background: var(--glass-bg);
|
||||
border-bottom: 1px solid var(--glass-border);
|
||||
backdrop-filter: var(--glass-blur);
|
||||
-webkit-backdrop-filter: var(--glass-blur);
|
||||
}
|
||||
|
||||
.trek-dash .st-back {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 36px;
|
||||
padding: 0 12px 0 8px;
|
||||
border-radius: 999px;
|
||||
color: var(--ink-2);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
letter-spacing: -0.01em;
|
||||
max-width: 240px;
|
||||
transition: background .18s, color .18s;
|
||||
}
|
||||
.trek-dash .st-back:hover { background: var(--accent-soft); color: var(--ink); }
|
||||
.trek-dash .st-back .st-back-label {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* The journey's own cover, so you never lose track of whose book this is. */
|
||||
.trek-dash .st-cover {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 7px;
|
||||
object-fit: cover;
|
||||
flex: none;
|
||||
border: 1px solid var(--line-2);
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
.trek-dash .st-bar-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
flex: none;
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
/* The undo/redo pair belongs together; everything else in the group breathes. */
|
||||
.trek-dash .st-bar-group .st-tool.is-icon + .st-tool.is-icon { margin-inline-start: -5px; }
|
||||
|
||||
.trek-dash .st-tool {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -1447,6 +1506,20 @@
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
/* ----------------- brand ----------------- *
|
||||
* The lockup takes the bar's ink through `currentColor`, so it is dark on a
|
||||
* light theme and light on a dark one from one copy of the artwork.
|
||||
*/
|
||||
.trek-dash .st-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
flex: 0 0 auto;
|
||||
color: var(--ink);
|
||||
min-width: 0;
|
||||
}
|
||||
.trek-dash .st-brand svg { flex: none; }
|
||||
|
||||
/* Small, quiet and set apart — a beta mark states a fact about the feature; it
|
||||
is not a call to action and should not read as one. */
|
||||
.trek-dash .st-beta {
|
||||
|
||||
@@ -44,26 +44,10 @@ const VECTOR_CACHE = 'gl-map-offline'
|
||||
*/
|
||||
const GLYPH_RANGES = ['0-255', '256-511', '512-767', '768-1023', '1024-1279', '1280-1535', '1536-1791', '8192-8447', '8448-8703']
|
||||
|
||||
interface StyleSource {
|
||||
type?: string
|
||||
url?: string
|
||||
tiles?: string[]
|
||||
minzoom?: number
|
||||
maxzoom?: number
|
||||
}
|
||||
|
||||
/** One tiled source of the style, with the zooms it actually serves. */
|
||||
interface TileSource {
|
||||
template: string
|
||||
minZoom: number
|
||||
maxZoom: number
|
||||
vector: boolean
|
||||
}
|
||||
|
||||
interface StyleDoc {
|
||||
sprite?: string
|
||||
glyphs?: string
|
||||
sources?: Record<string, StyleSource>
|
||||
sources?: Record<string, { url?: string; tiles?: string[] }>
|
||||
layers?: { layout?: { 'text-font'?: string[] } }[]
|
||||
}
|
||||
|
||||
@@ -116,67 +100,28 @@ async function openVectorCache(): Promise<Cache | null> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tile URLs covering the box, vector sources first and low zoom first within
|
||||
* each, so a truncated run is still useful. A source is only asked for the
|
||||
* zooms it serves: OpenFreeMap's shaded relief stops at z6 and answers 404
|
||||
* above it.
|
||||
*/
|
||||
function enumerateTileUrls(bbox: TileBbox, sources: TileSource[]): string[] {
|
||||
const out: string[] = []
|
||||
for (const source of sources) {
|
||||
for (let z = Math.max(source.minZoom, VECTOR_MIN_ZOOM); z <= Math.min(source.maxZoom, VECTOR_MAX_ZOOM); z++) {
|
||||
// Same screen-widened rectangle as the raster side: the opening fitBounds
|
||||
// view shows more than the places extent on the non-limiting axis (#2180).
|
||||
const { minX, maxX, minY, maxY } = tileRange(bbox, z)
|
||||
for (let x = minX; x <= maxX; x++) {
|
||||
for (let y = minY; y <= maxY; y++) {
|
||||
out.push(source.template.replace('{z}', String(z)).replace('{x}', String(x)).replace('{y}', String(y)))
|
||||
if (out.length >= MAX_VECTOR_TILES) return out
|
||||
}
|
||||
/** Tile coordinates covering the box, low zoom first so a truncated run is still useful. */
|
||||
function enumerateVectorTiles(bbox: TileBbox, minZoom: number, maxZoom: number): [number, number, number][] {
|
||||
const out: [number, number, number][] = []
|
||||
for (let z = minZoom; z <= maxZoom; z++) {
|
||||
// Same screen-widened rectangle as the raster side: the opening fitBounds
|
||||
// view shows more than the places extent on the non-limiting axis (#2180).
|
||||
const { minX, maxX, minY, maxY } = tileRange(bbox, z)
|
||||
for (let x = minX; x <= maxX; x++) {
|
||||
for (let y = minY; y <= maxY; y++) {
|
||||
out.push([z, x, y])
|
||||
if (out.length >= MAX_VECTOR_TILES) return out
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* The style's tiled sources, vector first. A source given as a TileJSON
|
||||
* reference gets its template and zooms from there: the tile path carries a
|
||||
* planet version, and asking without that segment answers 200 with an empty
|
||||
* body, so it cannot be guessed. A source without tiles (GeoJSON) is skipped.
|
||||
*/
|
||||
async function readTileSources(style: StyleDoc, cache: Cache | null): Promise<TileSource[]> {
|
||||
const out: TileSource[] = []
|
||||
for (const source of Object.values(style.sources ?? {})) {
|
||||
let { tiles, minzoom, maxzoom } = source
|
||||
if (!tiles?.[0] && source.url) {
|
||||
await warm(cache, source.url)
|
||||
const tileJson = await fetchJson<{ tiles?: string[]; minzoom?: number; maxzoom?: number }>(source.url)
|
||||
tiles = tileJson?.tiles
|
||||
minzoom ??= tileJson?.minzoom
|
||||
maxzoom ??= tileJson?.maxzoom
|
||||
}
|
||||
if (!tiles?.[0]) continue
|
||||
out.push({
|
||||
template: tiles[0],
|
||||
minZoom: minzoom ?? VECTOR_MIN_ZOOM,
|
||||
maxZoom: maxzoom ?? VECTOR_MAX_ZOOM,
|
||||
vector: source.type !== 'raster' && source.type !== 'raster-dem',
|
||||
})
|
||||
}
|
||||
return out.sort((a, b) => Number(b.vector) - Number(a.vector))
|
||||
}
|
||||
|
||||
/**
|
||||
* The device-wide half: the style, its TileJSON, the sprite and the glyphs.
|
||||
* Returns the vector tile URL template, or null when the style could not be read.
|
||||
* Returns the tile URL template, or null when the style could not be read.
|
||||
*/
|
||||
export async function prefetchStyleAssets(styleUrl: string): Promise<string | null> {
|
||||
return (await prefetchStyle(styleUrl))?.[0]?.template ?? null
|
||||
}
|
||||
|
||||
async function prefetchStyle(styleUrl: string): Promise<TileSource[] | null> {
|
||||
const cache = await openVectorCache()
|
||||
const style = await fetchJson<StyleDoc>(styleUrl)
|
||||
if (!style) return null
|
||||
@@ -203,7 +148,19 @@ async function prefetchStyle(styleUrl: string): Promise<TileSource[] | null> {
|
||||
)
|
||||
}
|
||||
|
||||
return readTileSources(style, cache)
|
||||
// The vector source is a TileJSON reference, and the tile path it hands back
|
||||
// carries a planet version. Asking without that segment answers 200 with an
|
||||
// empty body, so the template has to come from the TileJSON rather than be
|
||||
// guessed.
|
||||
for (const source of Object.values(style.sources ?? {})) {
|
||||
if (source.tiles?.[0]) return source.tiles[0]
|
||||
if (source.url) {
|
||||
await warm(cache, source.url)
|
||||
const tileJson = await fetchJson<{ tiles?: string[] }>(source.url)
|
||||
if (tileJson?.tiles?.[0]) return tileJson.tiles[0]
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export interface VectorPrefetchResult {
|
||||
@@ -227,21 +184,25 @@ export async function prefetchVectorForPlaces(
|
||||
const bbox = computeBbox(places)
|
||||
if (!bbox) return { tiles: 0, template: null }
|
||||
|
||||
const sources = await prefetchStyle(styleUrl)
|
||||
const template = sources?.[0]?.template ?? null
|
||||
if (!sources?.length || isCancelled()) return { tiles: 0, template }
|
||||
const template = await prefetchStyleAssets(styleUrl)
|
||||
if (!template || isCancelled()) return { tiles: 0, template }
|
||||
|
||||
const cache = await openVectorCache()
|
||||
const urls = enumerateTileUrls(bbox, sources)
|
||||
const coords = enumerateVectorTiles(bbox, VECTOR_MIN_ZOOM, VECTOR_MAX_ZOOM)
|
||||
|
||||
let cursor = 0
|
||||
let fetched = 0
|
||||
|
||||
async function worker(): Promise<void> {
|
||||
while (cursor < urls.length) {
|
||||
while (cursor < coords.length) {
|
||||
if (isCancelled() || !navigator.onLine) return
|
||||
const url = urls[cursor++]
|
||||
if (!url) return
|
||||
const next = coords[cursor++]
|
||||
if (!next) return
|
||||
const [z, x, y] = next
|
||||
const url = template!
|
||||
.replace('{z}', String(z))
|
||||
.replace('{x}', String(x))
|
||||
.replace('{y}', String(y))
|
||||
if (await warm(cache, url)) fetched++
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,11 +149,7 @@ async function syncTrip(tripId: number): Promise<void> {
|
||||
// first sync is in. Ahead of the writes and rethrowing anything but a 404, it
|
||||
// threw the whole downloaded bundle away for that trip while the run still
|
||||
// reported it stored, so Settings said "N trips ready" over an empty database.
|
||||
// Asked only while the addon is on, like the tours below: with it off, every
|
||||
// trip of a dashboard load spent a request on a 404.
|
||||
if (useAddonStore.getState().isEnabled('roadtrip')) {
|
||||
await roadtripPreferencesRepo.read(tripId).catch(() => { /* optional cache */ })
|
||||
}
|
||||
await roadtripPreferencesRepo.read(tripId).catch(() => { /* optional addon, optional cache */ })
|
||||
|
||||
// Tours, the same way: optional, and never fatal. Without them every Tour
|
||||
// reads as a plain place offline. Asked only while the addon is on, so a
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
import { act, render } from '@testing-library/react';
|
||||
import { MAX_SPREAD_ELEMENTS, type BookDocument, type BookElement, type BookSpread } from '@trek/shared';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { StudioNotices } from '../../../src/components/Studio/StudioNotices';
|
||||
import { useStudioStore } from '../../../src/store/studioStore';
|
||||
|
||||
/**
|
||||
* FE-STUDIO-FULL-001 to FE-STUDIO-FULL-004: a page that already holds
|
||||
* MAX_SPREAD_ELEMENTS takes nothing more, and the Studio says so instead of
|
||||
* swallowing the click.
|
||||
*/
|
||||
|
||||
const el = (id: string): BookElement =>
|
||||
({
|
||||
id,
|
||||
kind: 'shape',
|
||||
frame: { x: 0, y: 0, w: 10, h: 10 },
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
locked: false,
|
||||
shape: 'rect',
|
||||
fill: '#000000',
|
||||
gradient: 'none',
|
||||
stroke: null,
|
||||
strokeWidth: 0,
|
||||
strokeStyle: 'solid',
|
||||
radius: 0,
|
||||
}) as BookElement;
|
||||
|
||||
const spread = (id: string, elements: BookElement[]): BookSpread => ({
|
||||
id,
|
||||
role: 'inner',
|
||||
background: null,
|
||||
elements,
|
||||
parked: [],
|
||||
entryId: null,
|
||||
});
|
||||
|
||||
const book = (...spreads: BookSpread[]): BookDocument =>
|
||||
({
|
||||
version: 1,
|
||||
title: 'T',
|
||||
page: { preset: 'square-210', pageWidth: 210, pageHeight: 210, bleed: 3, safe: 5 },
|
||||
spreads,
|
||||
}) as BookDocument;
|
||||
|
||||
const full = () => Array.from({ length: MAX_SPREAD_ELEMENTS }, (_, i) => el(`f${i}`));
|
||||
const store = () => useStudioStore.getState();
|
||||
|
||||
beforeEach(() => {
|
||||
store().load(book(spread('full', full()), spread('room', [el('a')])));
|
||||
useStudioStore.setState({ refusedFull: 0, clipboard: null });
|
||||
});
|
||||
|
||||
describe('a full page', () => {
|
||||
it('FE-STUDIO-FULL-001: refuses one more element and counts the refusal', () => {
|
||||
expect(store().addElement(0, el('extra'))).toBe(false);
|
||||
expect(store().doc!.spreads[0].elements).toHaveLength(MAX_SPREAD_ELEMENTS);
|
||||
expect(store().refusedFull).toBe(1);
|
||||
expect(store().addElement(1, el('fits'))).toBe(true);
|
||||
expect(store().refusedFull).toBe(1);
|
||||
});
|
||||
|
||||
it('FE-STUDIO-FULL-002: a paste onto it is refused and counted', () => {
|
||||
useStudioStore.setState({ clipboard: { fromSpreadId: 'room', elements: [el('a')] } });
|
||||
store().paste(0);
|
||||
expect(store().doc!.spreads[0].elements).toHaveLength(MAX_SPREAD_ELEMENTS);
|
||||
expect(store().refusedFull).toBe(1);
|
||||
});
|
||||
|
||||
it('FE-STUDIO-FULL-003: a duplicate on it is refused and counted', () => {
|
||||
store().duplicate(0, ['f0']);
|
||||
expect(store().doc!.spreads[0].elements).toHaveLength(MAX_SPREAD_ELEMENTS);
|
||||
expect(store().refusedFull).toBe(1);
|
||||
});
|
||||
|
||||
it('FE-STUDIO-FULL-004: the Studio says it once per refusal', () => {
|
||||
const addToast = vi.fn();
|
||||
window.__addToast = addToast;
|
||||
render(<StudioNotices />);
|
||||
expect(addToast).not.toHaveBeenCalled();
|
||||
act(() => store().refuseFull());
|
||||
expect(addToast).toHaveBeenCalledTimes(1);
|
||||
expect(addToast.mock.calls[0][1]).toBe('warning');
|
||||
act(() => store().refuseFull());
|
||||
expect(addToast).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -201,46 +201,6 @@ describe('prefetchVectorForPlaces', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// OpenFreeMap's liberty style lists its shaded relief (raster, up to z6) before the
|
||||
// vector planet. Taking the first source used to prefetch that relief at z0 to z14,
|
||||
// hundreds of 404s, and never the vector tiles the map needs offline.
|
||||
describe('a style with a raster source ahead of the vector one', () => {
|
||||
const places = [buildPlace({ lat: 52.52, lng: 13.405 })];
|
||||
const RELIEF = 'https://tiles.openfreemap.org/natural_earth/ne2sr/{z}/{x}/{y}.png';
|
||||
const LIBERTY = {
|
||||
sources: {
|
||||
ne2_shaded: { type: 'raster', tiles: [RELIEF], maxzoom: 6 },
|
||||
openmaptiles: { type: 'vector', url: 'https://tiles.openfreemap.org/planet' },
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn((url: string, init?: { mode?: string }) => {
|
||||
requested.push({ url, mode: init?.mode });
|
||||
if (url === STYLE_URL) return Promise.resolve({ ok: true, json: () => Promise.resolve(LIBERTY), clone: () => ({}) });
|
||||
return Promise.resolve(respond(url));
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('FE-SYNC-GLPF-015: the template is the vector source, not the first one listed', async () => {
|
||||
await expect(prefetchStyleAssets(STYLE_URL)).resolves.toBe(TILE_TEMPLATE);
|
||||
});
|
||||
|
||||
it('FE-SYNC-GLPF-016: downloads the vector tiles and the relief only at the zooms it serves', async () => {
|
||||
const result = await prefetchVectorForPlaces(places, STYLE_URL);
|
||||
expect(result.template).toBe(TILE_TEMPLATE);
|
||||
const urls = requested.map(r => r.url);
|
||||
const vectorZooms = new Set(urls.filter(u => u.includes('/planet/')).map(u => Number(u.split('/planet/')[1]!.split('/')[1])));
|
||||
expect(Math.max(...vectorZooms)).toBe(14);
|
||||
const reliefZooms = urls.filter(u => u.includes('/ne2sr/')).map(u => Number(u.split('/ne2sr/')[1]!.split('/')[0]));
|
||||
expect(reliefZooms.length).toBeGreaterThan(0);
|
||||
expect(Math.max(...reliefZooms)).toBe(6);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearVectorCache', () => {
|
||||
it('FE-SYNC-GLPF-012: drops the offline vector basemap', async () => {
|
||||
await clearVectorCache();
|
||||
|
||||
@@ -139,6 +139,7 @@
|
||||
"TripInviteTokens": "trip-invite",
|
||||
"TripJournalLinkMembers": "journey",
|
||||
"TripJournalLinks": "journey",
|
||||
"TripMemberRemovals": "trip-membership",
|
||||
"TripMembers": "trip-membership",
|
||||
"TripPhotos": "photos",
|
||||
"Trips": "trips",
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core';
|
||||
import { TripMemberRemovalsRepository } from '../repositories/TripMemberRemovals.repository';
|
||||
import { DbTimestampType } from '../types';
|
||||
import { Trips } from './Trips.entity';
|
||||
import { Users } from './Users.entity';
|
||||
|
||||
export class TripMemberRemovals {
|
||||
[EntityRepositoryType]?: TripMemberRemovalsRepository;
|
||||
id!: number & Opt;
|
||||
trip!: Ref<Trips>;
|
||||
trip_id!: number;
|
||||
user!: Ref<Users>;
|
||||
user_id!: number;
|
||||
removed_at!: string & Opt;
|
||||
}
|
||||
|
||||
export const TripMemberRemovalsSchema = defineEntity({
|
||||
class: TripMemberRemovals,
|
||||
repository: () => TripMemberRemovalsRepository,
|
||||
uniques: [{ properties: ['trip', 'user'] }],
|
||||
properties: {
|
||||
id: p.integer().primary(),
|
||||
trip: () => p.manyToOne(Trips).ref().deleteRule('cascade').hidden(),
|
||||
trip_id: p.integer().persist(false),
|
||||
user: () => p.manyToOne(Users).ref().deleteRule('cascade').hidden().index('idx_trip_member_removals_user'),
|
||||
user_id: p.integer().persist(false).index('idx_trip_member_removals_user'),
|
||||
removed_at: p.type(DbTimestampType).defaultRaw(`CURRENT_TIMESTAMP`),
|
||||
},
|
||||
});
|
||||
@@ -35,6 +35,7 @@ import { TripDocumentLinks } from './TripDocumentLinks.entity';
|
||||
import { TripFiles } from './TripFiles.entity';
|
||||
import { TripInviteTokens } from './TripInviteTokens.entity';
|
||||
import { TripJournalLinks } from './TripJournalLinks.entity';
|
||||
import { TripMemberRemovals } from './TripMemberRemovals.entity';
|
||||
import { TripMembers } from './TripMembers.entity';
|
||||
import { TripPhotos } from './TripPhotos.entity';
|
||||
import { Users } from './Users.entity';
|
||||
@@ -90,6 +91,7 @@ export class Trips {
|
||||
trip_files_collection = new Collection<TripFiles>(this);
|
||||
trip_invite_tokens_collection = new Collection<TripInviteTokens>(this);
|
||||
trip_journal_links_collection = new Collection<TripJournalLinks>(this);
|
||||
trip_member_removals_collection = new Collection<TripMemberRemovals>(this);
|
||||
trip_members_collection = new Collection<TripMembers>(this);
|
||||
trip_photos_collection = new Collection<TripPhotos>(this);
|
||||
}
|
||||
@@ -154,6 +156,7 @@ export const TripsSchema = defineEntity({
|
||||
trip_files_collection: () => p.oneToMany(TripFiles).mappedBy('trip').hidden(),
|
||||
trip_invite_tokens_collection: () => p.oneToMany(TripInviteTokens).mappedBy('trip').hidden(),
|
||||
trip_journal_links_collection: () => p.oneToMany(TripJournalLinks).mappedBy('trip').hidden(),
|
||||
trip_member_removals_collection: () => p.oneToMany(TripMemberRemovals).mappedBy('trip').hidden(),
|
||||
trip_members_collection: () => p.oneToMany(TripMembers).mappedBy('trip').hidden(),
|
||||
trip_photos_collection: () => p.oneToMany(TripPhotos).mappedBy('trip').hidden(),
|
||||
},
|
||||
|
||||
@@ -71,6 +71,7 @@ import { TripFiles } from './TripFiles.entity';
|
||||
import { TripInviteTokens } from './TripInviteTokens.entity';
|
||||
import { TripJournalLinkMembers } from './TripJournalLinkMembers.entity';
|
||||
import { TripJournalLinks } from './TripJournalLinks.entity';
|
||||
import { TripMemberRemovals } from './TripMemberRemovals.entity';
|
||||
import { TripMembers } from './TripMembers.entity';
|
||||
import { TripPhotos } from './TripPhotos.entity';
|
||||
import { Trips } from './Trips.entity';
|
||||
@@ -210,6 +211,7 @@ export class Users {
|
||||
trip_invite_tokens_collection = new Collection<TripInviteTokens>(this);
|
||||
trip_journal_link_members_collection = new Collection<TripJournalLinkMembers>(this);
|
||||
trip_journal_links_collection = new Collection<TripJournalLinks>(this);
|
||||
trip_member_removals_collection = new Collection<TripMemberRemovals>(this);
|
||||
trip_members_collection = new Collection<TripMembers>(this);
|
||||
trip_members_collection1 = new Collection<TripMembers>(this);
|
||||
trip_photos_collection = new Collection<TripPhotos>(this);
|
||||
@@ -367,6 +369,7 @@ export const UsersSchema = defineEntity({
|
||||
trip_invite_tokens_collection: () => p.oneToMany(TripInviteTokens).mappedBy('createdByRef').hidden(),
|
||||
trip_journal_link_members_collection: () => p.oneToMany(TripJournalLinkMembers).mappedBy('user').hidden(),
|
||||
trip_journal_links_collection: () => p.oneToMany(TripJournalLinks).mappedBy('linkedByRef').hidden(),
|
||||
trip_member_removals_collection: () => p.oneToMany(TripMemberRemovals).mappedBy('user').hidden(),
|
||||
trip_members_collection: () => p.oneToMany(TripMembers).mappedBy('user').hidden(),
|
||||
trip_members_collection1: () => p.oneToMany(TripMembers).mappedBy('invitedByRef').hidden(),
|
||||
trip_photos_collection: () => p.oneToMany(TripPhotos).mappedBy('user').hidden(),
|
||||
|
||||
@@ -138,6 +138,7 @@ import { TripFilesSchema } from './TripFiles.entity';
|
||||
import { TripInviteTokensSchema } from './TripInviteTokens.entity';
|
||||
import { TripJournalLinkMembersSchema } from './TripJournalLinkMembers.entity';
|
||||
import { TripJournalLinksSchema } from './TripJournalLinks.entity';
|
||||
import { TripMemberRemovalsSchema } from './TripMemberRemovals.entity';
|
||||
import { TripMembersSchema } from './TripMembers.entity';
|
||||
import { TripPhotosSchema } from './TripPhotos.entity';
|
||||
import { TripsSchema } from './Trips.entity';
|
||||
@@ -300,6 +301,7 @@ export const ALL_ENTITIES: readonly EntitySchema[] = [
|
||||
TripInviteTokensSchema,
|
||||
TripJournalLinkMembersSchema,
|
||||
TripJournalLinksSchema,
|
||||
TripMemberRemovalsSchema,
|
||||
TripMembersSchema,
|
||||
TripPhotosSchema,
|
||||
TripsSchema,
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
// Generated by scripts/generate-db-types.ts from the migrated schema. Do not edit by hand:
|
||||
// change the migration, then run `npm run gen:db-types`.
|
||||
import type { InsertOptional } from './columns';
|
||||
|
||||
export interface TripMemberRemovalsTable {
|
||||
id: InsertOptional<number>;
|
||||
trip_id: number;
|
||||
user_id: number;
|
||||
removed_at: InsertOptional<string>;
|
||||
}
|
||||
@@ -144,6 +144,7 @@ import type { TripFilesTable } from './TripFiles.table';
|
||||
import type { TripInviteTokensTable } from './TripInviteTokens.table';
|
||||
import type { TripJournalLinkMembersTable } from './TripJournalLinkMembers.table';
|
||||
import type { TripJournalLinksTable } from './TripJournalLinks.table';
|
||||
import type { TripMemberRemovalsTable } from './TripMemberRemovals.table';
|
||||
import type { TripMembersTable } from './TripMembers.table';
|
||||
import type { TripPhotosTable } from './TripPhotos.table';
|
||||
import type { TripsTable } from './Trips.table';
|
||||
@@ -312,6 +313,7 @@ export interface DB {
|
||||
trip_invite_tokens: TripInviteTokensTable;
|
||||
trip_journal_link_members: TripJournalLinkMembersTable;
|
||||
trip_journal_links: TripJournalLinksTable;
|
||||
trip_member_removals: TripMemberRemovalsTable;
|
||||
trip_members: TripMembersTable;
|
||||
trip_photos: TripPhotosTable;
|
||||
trips: TripsTable;
|
||||
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
import { Migration } from '@mikro-orm/migrations';
|
||||
|
||||
/**
|
||||
* Somebody another member took off a trip cannot come back on their own
|
||||
* through the trip's invite link (GHSA-wr4c-964p-39jg). Removing a member
|
||||
* deletes their `trip_members` row, and the invite link checked nothing but
|
||||
* its own token, so the old link let them straight back in.
|
||||
*
|
||||
* `trip_member_removals` keeps one row per trip and person who was removed by
|
||||
* someone else. The invite link refuses them while the row exists; a member
|
||||
* manager adding them back by name clears it. Leaving a trip yourself leaves
|
||||
* no row, so whoever left can still rejoin through the link. The rows go with
|
||||
* the trip and with the account.
|
||||
*/
|
||||
export class Migration20200101046290_a_removed_member_cannot_rejoin_by_invite_link extends Migration {
|
||||
override name = 'Migration20200101046290_a_removed_member_cannot_rejoin_by_invite_link';
|
||||
|
||||
override up(): void {
|
||||
this.addSql(`
|
||||
CREATE TABLE IF NOT EXISTS trip_member_removals (
|
||||
id INTEGER PRIMARY KEY,
|
||||
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
removed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(trip_id, user_id)
|
||||
)
|
||||
`);
|
||||
this.addSql('CREATE INDEX IF NOT EXISTS idx_trip_member_removals_user ON trip_member_removals(user_id)');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import type { TripMemberRemovals } from '../entities/TripMemberRemovals.entity';
|
||||
import { TrekRepository } from './_shared/trek-repository';
|
||||
|
||||
export class TripMemberRemovalsRepository extends TrekRepository<TripMemberRemovals> {}
|
||||
@@ -267,6 +267,45 @@ export class TripMembersRepository extends TrekRepository<TripMembers> {
|
||||
await this.qb('m').delete().where({ trip: trip_id }).andWhere('m.user_id = ?', [user_id]).execute('run');
|
||||
}
|
||||
|
||||
/**
|
||||
* Notes that somebody else took this user off the trip, so the trip's invite
|
||||
* link stops letting them back in (GHSA-wr4c-964p-39jg). A second removal
|
||||
* keeps the first note.
|
||||
*/
|
||||
async recordRemoval(tripId: number, userId: number): Promise<void> {
|
||||
await this.kysely<Pick<DB, 'trip_member_removals'>>()
|
||||
.insertInto('trip_member_removals')
|
||||
.values({ trip_id: tripId, user_id: userId })
|
||||
.onConflict((oc) => oc.columns(['trip_id', 'user_id']).doNothing())
|
||||
.execute();
|
||||
}
|
||||
|
||||
/**
|
||||
* Lifts the note once a member manager adds the user back.
|
||||
*
|
||||
* @txStandalone it runs after the membership insert and only lifts a refusal;
|
||||
* when it fails, the user is a member anyway and the invite link keeps
|
||||
* refusing them, which is the safe side.
|
||||
*/
|
||||
async clearRemoval(tripId: number, userId: number): Promise<void> {
|
||||
await this.kysely<Pick<DB, 'trip_member_removals'>>()
|
||||
.deleteFrom('trip_member_removals')
|
||||
.where('trip_id', '=', tripId)
|
||||
.where('user_id', '=', userId)
|
||||
.execute();
|
||||
}
|
||||
|
||||
/** Whether somebody else took this user off the trip and nobody added them back since. */
|
||||
async wasRemoved(tripId: number, userId: number): Promise<boolean> {
|
||||
const row = await this.kysely<Pick<DB, 'trip_member_removals'>>()
|
||||
.selectFrom('trip_member_removals')
|
||||
.select('id')
|
||||
.where('trip_id', '=', tripId)
|
||||
.where('user_id', '=', userId)
|
||||
.executeTakeFirst();
|
||||
return !!row;
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes someone who left the trip off its journal link, so coming back later
|
||||
* does not quietly reopen the journal. Their access already ended with the
|
||||
|
||||
@@ -476,9 +476,7 @@ async function carryOn(
|
||||
const { text, calls, images } = answer;
|
||||
turn.history.push({ role: 'assistant', text, toolCalls: calls, providerState: step.providerState });
|
||||
for (const part of step.parts) {
|
||||
// A web search is the provider's own record, kept in the provider state; an answer to it would
|
||||
// name a call the provider never made, and it refuses the next step over that.
|
||||
if (part.type !== 'tool-call' || part.toolName === ASSISTANT_WEB_SEARCH_STEP) continue;
|
||||
if (part.type !== 'tool-call') continue;
|
||||
const seen = images.get(part.toolCallId);
|
||||
turn.history.push({
|
||||
role: 'tool',
|
||||
|
||||
@@ -347,8 +347,8 @@ export class PlacesMcp {
|
||||
name: 'search_place',
|
||||
description: 'Search for a real-world place by name or address. Returns results with osm_id (and google_place_id/google_ftid if configured). Use these IDs when calling create_place so the app can display opening hours, ratings, and map links. Pass locationBias whenever the trip has a destination: a bare name like "Central Station" or "Museum of Modern Art" otherwise resolves wherever the provider guesses, which is regularly the wrong continent. Searches the TREK index and OpenStreetMap, or Google Places alone with provider "google" and whenever the admin switch "Search with Google only" is on: if the instance has a plugin providing its own search index, call search_places_via_plugins as well, and note that ratings can only come from there.',
|
||||
inputSchema: {
|
||||
query: z.string().min(1).max(500).describe('One place name or address. Several places in one query match none of them well; search each on its own, the calls can run side by side'),
|
||||
locationBias: mapsSearchRequestSchema.shape.locationBias.describe('Centre the search on a coordinate: { lat, lng, radius? } with radius in metres (default 50000). Use where the place should be, such as a stop already planned in that neighbourhood, not the middle of the city: the TREK index ranks by distance from it, OpenStreetMap orders its results by it and Google searches a circle around it'),
|
||||
query: z.string().min(1).max(500).describe('Place name or address to search for'),
|
||||
locationBias: mapsSearchRequestSchema.shape.locationBias.describe('Centre the search on a coordinate: { lat, lng, radius? } with radius in metres (default 50000). Only the Google provider honours it; the OpenStreetMap fallback ignores it'),
|
||||
lang: z.string().max(35).optional().describe('BCP 47 language for the result names, e.g. "de" or "ja". Defaults to English'),
|
||||
provider: mapsSearchRequestSchema.shape.provider.describe('"google" sends this one search to Google Places alone instead of the TREK index and OpenStreetMap, the same as the "search Google instead" link under the results in the app. Ignored unless Google holds the keyed slot: an instance without a Google key, or one whose admin picked Amap or OpenStreetMap as the places provider, answers from the TREK index and OpenStreetMap as usual'),
|
||||
},
|
||||
|
||||
@@ -102,9 +102,9 @@ export class TripInviteController {
|
||||
}
|
||||
|
||||
@Get(':token')
|
||||
async preview(@Param('token') token: string, @Req() req: Request) {
|
||||
async preview(@CurrentUser() user: User, @Param('token') token: string, @Req() req: Request) {
|
||||
await this.limit(req, 30);
|
||||
const resolved = await this.invites.resolve(token);
|
||||
const resolved = await this.invites.resolveFor(token, user.id);
|
||||
if (!resolved) throw new HttpException({ error: 'Invalid or expired invite link' }, 404);
|
||||
return { trip_id: resolved.trip_id, title: resolved.title };
|
||||
}
|
||||
@@ -113,7 +113,7 @@ export class TripInviteController {
|
||||
@HttpCode(200)
|
||||
async accept(@CurrentUser() user: User, @Param('token') token: string, @Req() req: Request) {
|
||||
await this.limit(req, 20);
|
||||
const resolved = await this.invites.resolve(token);
|
||||
const resolved = await this.invites.resolveFor(token, user.id);
|
||||
if (!resolved) throw new HttpException({ error: 'Invalid or expired invite link' }, 404);
|
||||
const result = await this.invites.join(resolved.trip_id, user.id);
|
||||
await this.audit.writeAudit({
|
||||
|
||||
@@ -2,6 +2,7 @@ import { TripInviteTokens } from '../../db/entities/TripInviteTokens.entity';
|
||||
import type { TripInviteTokensRepository } from '../../db/repositories/TripInviteTokens.repository';
|
||||
import type { TripAccess } from '../../db/repositories/Trips.repository';
|
||||
import type { User } from '../../types';
|
||||
import { forbidden } from '../common/domain-error';
|
||||
import { UnitOfWork } from '../database/unit-of-work';
|
||||
import { PermissionsService } from '../permissions/permissions.service';
|
||||
import { TripAccessService } from '../trip-membership/trip-access.service';
|
||||
@@ -120,6 +121,19 @@ export class TripInviteService {
|
||||
return { trip_id: row.trip_id, title: row.title };
|
||||
}
|
||||
|
||||
/**
|
||||
* `resolve` for the user who opened the link. Somebody another member took
|
||||
* off the trip is refused with a 403, so the link cannot undo a removal
|
||||
* (GHSA-wr4c-964p-39jg); a member manager adding them back lifts it.
|
||||
*/
|
||||
async resolveFor(token: string, userId: number): Promise<{ trip_id: number; title: string } | null> {
|
||||
const resolved = await this.resolve(token);
|
||||
if (resolved && (await this.membership.wasRemovedFrom(resolved.trip_id, userId))) {
|
||||
throw forbidden('You were removed from this trip. Ask its owner to add you again.', { removed: true });
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/** Join the resolved trip as the current (authenticated, non-guest) user.
|
||||
* invited_by is null — they joined via a link, not a personal invite. */
|
||||
async join(tripId: number, userId: number) {
|
||||
|
||||
@@ -46,7 +46,7 @@ export class TripMemberWritesService {
|
||||
|
||||
/** Takes somebody off the trip, the writer themselves included when they leave. */
|
||||
async removeMember(tripId: number, targetUserId: number, writer: TripWriter): Promise<void> {
|
||||
await this.roster.removeMember(tripId, targetUserId);
|
||||
await this.roster.removeMember(tripId, targetUserId, writer.userId);
|
||||
writer.events.emit('member:removed', { userId: targetUserId });
|
||||
}
|
||||
|
||||
|
||||
@@ -192,6 +192,8 @@ export class TripMembersService {
|
||||
// `Number('1e1')` coerce to a real integer where SQLite's own text
|
||||
// affinity never converts a hex or exponent literal).
|
||||
await this.tripMembersRepo.addMember(Number(tripId), target.id, invitedByUserId);
|
||||
// Added back by name: the invite link may let them in again (GHSA-wr4c-964p-39jg).
|
||||
await this.tripMembersRepo.clearRemoval(Number(tripId), target.id);
|
||||
|
||||
const tripTitle = await this.tripsRepo.getTitle(tripId);
|
||||
|
||||
@@ -209,12 +211,16 @@ export class TripMembersService {
|
||||
* .removeMember`'s own `toRowId` parse (added alongside this narrowing,
|
||||
* the route's one remaining raw-string caller).
|
||||
*/
|
||||
async removeMember(tripId: number, targetUserId: number): Promise<void> {
|
||||
async removeMember(tripId: number, targetUserId: number, removedBy: number): Promise<void> {
|
||||
// A linked journal stops being open to someone who left; coming back later
|
||||
// must not quietly reopen it.
|
||||
await this.uow.transactional(async () => {
|
||||
const wasMember = await this.tripMembersRepo.exists(tripId, targetUserId);
|
||||
await this.tripMembersRepo.remove(tripId, targetUserId);
|
||||
await this.tripMembersRepo.leaveJournalLink(tripId, targetUserId);
|
||||
// Taken off by somebody else, they must not walk back in through the
|
||||
// trip's invite link (GHSA-wr4c-964p-39jg). Leaving yourself leaves no note.
|
||||
if (wasMember && removedBy !== targetUserId) await this.tripMembersRepo.recordRemoval(tripId, targetUserId);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -72,6 +72,17 @@ export class TripMembershipService {
|
||||
const existing = await this.tripMembers.exists(tripId, userId);
|
||||
if (existing) return { joined: false, tripId };
|
||||
await this.tripMembers.addMember(tripId, userId, invitedBy);
|
||||
// Added by a person (a trip-bound invite, a plugin acting for a member
|
||||
// manager): the removal note from GHSA-wr4c-964p-39jg no longer applies.
|
||||
if (invitedBy !== null) await this.tripMembers.clearRemoval(tripId, userId);
|
||||
return { joined: true, tripId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether somebody else took this user off the trip and nobody added them
|
||||
* back since. The invite link refuses such a user (GHSA-wr4c-964p-39jg).
|
||||
*/
|
||||
async wasRemovedFrom(tripId: number, userId: number): Promise<boolean> {
|
||||
return this.tripMembers.wasRemoved(tripId, userId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -224,7 +224,7 @@ export class TripsRpc {
|
||||
// Ownership transfer is a separate, deliberate action.
|
||||
const ownerId = await this.em.getRepository(Trips).getOwnerId(tripId); // RP6 — TripsRepository.getOwnerId
|
||||
if (ownerId !== null && ownerId === targetUserId) throw new ForbiddenResource('cannot remove the trip owner');
|
||||
await this.roster.removeMember(tripId, targetUserId);
|
||||
await this.roster.removeMember(tripId, targetUserId, actor);
|
||||
this.realtime.broadcast(tripId, 'member:removed', { userId: targetUserId });
|
||||
return { removed: true };
|
||||
}
|
||||
|
||||
@@ -40,6 +40,8 @@ let checkPermission: MockInstance;
|
||||
// The join itself is stubbed out of the container (TRIP-JOIN-* cover what it
|
||||
// writes); these cases assert the invite route's own decision to call it.
|
||||
const joinTripAsMember = vi.fn();
|
||||
// Whether another member took the user off the trip (GHSA-wr4c-964p-39jg).
|
||||
const wasRemovedFrom = vi.fn();
|
||||
|
||||
// The audit domain is DI-native now: writeAudit runs for real against the temp
|
||||
// db's audit_log table; only the file logger is silenced.
|
||||
@@ -62,7 +64,7 @@ describe('Trip invite-link e2e (real auth guard + temp SQLite)', () => {
|
||||
imports: [await TestUnitOfWorkModule.forRoot(db), await createTestMikroOrmModule(db), TripInviteModule],
|
||||
})
|
||||
.overrideProvider(TripMembershipService)
|
||||
.useValue({ joinTripAsMember })
|
||||
.useValue({ joinTripAsMember, wasRemovedFrom })
|
||||
.compile();
|
||||
const nest = moduleRef.createNestApplication();
|
||||
nest.use(cookieParser());
|
||||
@@ -95,6 +97,8 @@ describe('Trip invite-link e2e (real auth guard + temp SQLite)', () => {
|
||||
// budget.e2e.test.ts's identical comment.
|
||||
checkPermission.mockReturnValue(true);
|
||||
joinTripAsMember.mockReset();
|
||||
wasRemovedFrom.mockReset();
|
||||
wasRemovedFrom.mockResolvedValue(false);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -216,6 +220,29 @@ describe('Trip invite-link e2e (real auth guard + temp SQLite)', () => {
|
||||
expect(joinTripAsMember).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('preview 403 for a user another member removed from the trip, saying so', async () => {
|
||||
await seedTrip(9, 'Rome 2026');
|
||||
await seedToken(9, 'tok');
|
||||
wasRemovedFrom.mockResolvedValue(true);
|
||||
const res = await request(server).get('/api/trip-invites/tok').set('Cookie', sessionCookie(2));
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body).toEqual({
|
||||
error: 'You were removed from this trip. Ask its owner to add you again.',
|
||||
removed: true,
|
||||
});
|
||||
expect(wasRemovedFrom).toHaveBeenCalledWith(9, 2);
|
||||
});
|
||||
|
||||
it('accept 403 for a removed user and joins nobody (GHSA-wr4c-964p-39jg)', async () => {
|
||||
await seedTrip(9, 'Rome 2026');
|
||||
await seedToken(9, 'tok');
|
||||
wasRemovedFrom.mockResolvedValue(true);
|
||||
const res = await request(server).post('/api/trip-invites/tok/accept').set('Cookie', sessionCookie(2));
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.removed).toBe(true);
|
||||
expect(joinTripAsMember).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('401 to accept without a session', async () => {
|
||||
expect((await request(server).post('/api/trip-invites/tok/accept')).status).toBe(401);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import { buildApp } from '../../src/bootstrap';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import type { FactoryOrm } from '../helpers/factories/context';
|
||||
import { makeTrip } from '../helpers/factories/trips';
|
||||
import { makeUser } from '../helpers/factories/users';
|
||||
import { MikroORM } from '@mikro-orm/core';
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
|
||||
import request from 'supertest';
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
|
||||
/**
|
||||
* A member another member took off a trip cannot walk back in through the
|
||||
* trip's invite link (GHSA-wr4c-964p-39jg, TIR-*). Through the real routes:
|
||||
* the link, the roster's remove and add, and the join that reads both.
|
||||
*/
|
||||
|
||||
let app: INestApplication;
|
||||
let orm: FactoryOrm;
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await buildApp();
|
||||
await app.init();
|
||||
orm = app.get(MikroORM);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app?.close();
|
||||
});
|
||||
|
||||
const http = () => request(app.getHttpServer());
|
||||
|
||||
let scenes = 0;
|
||||
|
||||
/** A trip with an invite link, and Ben, who joined through it. */
|
||||
async function scene() {
|
||||
const n = ++scenes;
|
||||
const { user: owner } = await makeUser(orm, { username: `tir-owner-${n}` });
|
||||
const { user: ben } = await makeUser(orm, { username: `tir-ben-${n}` });
|
||||
const trip = await makeTrip(orm, owner.id);
|
||||
const created = await http().post(`/api/trips/${trip.id}/invite-link`).set('Cookie', authCookie(owner.id)).send({});
|
||||
expect(created.status).toBeLessThan(300);
|
||||
const token = created.body.token as string;
|
||||
const joined = await http().post(`/api/trip-invites/${token}/accept`).set('Cookie', authCookie(ben.id));
|
||||
expect(joined.body).toEqual({ trip_id: trip.id, joined: true });
|
||||
return { owner, ben, trip, token };
|
||||
}
|
||||
|
||||
const accept = (token: string, userId: number) =>
|
||||
http().post(`/api/trip-invites/${token}/accept`).set('Cookie', authCookie(userId));
|
||||
const preview = (token: string, userId: number) =>
|
||||
http().get(`/api/trip-invites/${token}`).set('Cookie', authCookie(userId));
|
||||
const remove = (tripId: number, userId: number, byUserId: number) =>
|
||||
http().delete(`/api/trips/${tripId}/members/${userId}`).set('Cookie', authCookie(byUserId));
|
||||
const canOpen = async (tripId: number, userId: number) =>
|
||||
(await http().get(`/api/trips/${tripId}`).set('Cookie', authCookie(userId))).status === 200;
|
||||
|
||||
describe('a removed member and the invite link', () => {
|
||||
it('TIR-001: removed by the owner, the old link refuses them and says why, and they stay out', async () => {
|
||||
const s = await scene();
|
||||
expect((await remove(s.trip.id, s.ben.id, s.owner.id)).status).toBe(200);
|
||||
|
||||
const seen = await preview(s.token, s.ben.id);
|
||||
expect(seen.status).toBe(403);
|
||||
expect(seen.body).toEqual({
|
||||
error: 'You were removed from this trip. Ask its owner to add you again.',
|
||||
removed: true,
|
||||
});
|
||||
|
||||
const rejoin = await accept(s.token, s.ben.id);
|
||||
expect(rejoin.status).toBe(403);
|
||||
expect(rejoin.body.removed).toBe(true);
|
||||
expect(await canOpen(s.trip.id, s.ben.id)).toBe(false);
|
||||
});
|
||||
|
||||
it('TIR-002: the link still works for everybody else', async () => {
|
||||
const s = await scene();
|
||||
await remove(s.trip.id, s.ben.id, s.owner.id);
|
||||
const { user: cara } = await makeUser(orm, { username: `tir-cara-${scenes}` });
|
||||
expect((await preview(s.token, cara.id)).status).toBe(200);
|
||||
expect((await accept(s.token, cara.id)).body).toEqual({ trip_id: s.trip.id, joined: true });
|
||||
});
|
||||
|
||||
it('TIR-003: whoever left on their own may come back through the link', async () => {
|
||||
const s = await scene();
|
||||
expect((await remove(s.trip.id, s.ben.id, s.ben.id)).status).toBe(200);
|
||||
expect(await canOpen(s.trip.id, s.ben.id)).toBe(false);
|
||||
expect((await accept(s.token, s.ben.id)).body).toEqual({ trip_id: s.trip.id, joined: true });
|
||||
expect(await canOpen(s.trip.id, s.ben.id)).toBe(true);
|
||||
});
|
||||
|
||||
it('TIR-004: added back by name, then leaving on their own, they may use the link again', async () => {
|
||||
const s = await scene();
|
||||
await remove(s.trip.id, s.ben.id, s.owner.id);
|
||||
const added = await http()
|
||||
.post(`/api/trips/${s.trip.id}/members`)
|
||||
.set('Cookie', authCookie(s.owner.id))
|
||||
.send({ identifier: s.ben.username });
|
||||
expect(added.status).toBe(201);
|
||||
expect((await preview(s.token, s.ben.id)).status).toBe(200);
|
||||
|
||||
await remove(s.trip.id, s.ben.id, s.ben.id);
|
||||
expect((await accept(s.token, s.ben.id)).body).toEqual({ trip_id: s.trip.id, joined: true });
|
||||
});
|
||||
|
||||
it('TIR-005: removing somebody who was never a member leaves no note against them', async () => {
|
||||
const s = await scene();
|
||||
const { user: dana } = await makeUser(orm, { username: `tir-dana-${scenes}` });
|
||||
await remove(s.trip.id, dana.id, s.owner.id);
|
||||
expect((await accept(s.token, dana.id)).body).toEqual({ trip_id: s.trip.id, joined: true });
|
||||
});
|
||||
|
||||
it('TIR-006: a regenerated link refuses the removed member too', async () => {
|
||||
const s = await scene();
|
||||
await remove(s.trip.id, s.ben.id, s.owner.id);
|
||||
const rotated = await http()
|
||||
.post(`/api/trips/${s.trip.id}/invite-link`)
|
||||
.set('Cookie', authCookie(s.owner.id))
|
||||
.send({});
|
||||
expect((await accept(rotated.body.token as string, s.ben.id)).status).toBe(403);
|
||||
});
|
||||
});
|
||||
@@ -55,14 +55,15 @@ describe('ALL_ENTITIES', () => {
|
||||
* + assistant_threads, assistant_messages, assistant_tool_calls and assistant_usage,
|
||||
* the trip assistant's conversations, the changes it proposed and what it used
|
||||
* + trek_photo_signals, what a photo's pixels say, for picking photos for a book
|
||||
* + journey_book_revisions, the books a whole-book design replaced, for its undo)
|
||||
* + journey_book_revisions, the books a whole-book design replaced, for its undo
|
||||
* + trip_member_removals, who another member took off a trip, so its invite link refuses them)
|
||||
* is registered, and no two share a class name (a silent duplicate would
|
||||
* shadow one of them in `orm.getMetadata()`, exactly the kind of drift
|
||||
* `entity-schema-parity.test.ts` can't see — it iterates the metadata map,
|
||||
* which de-duplicates by class name).
|
||||
*/
|
||||
it('ENT-003: exactly 160 entities, every class name unique', () => {
|
||||
expect(ALL_ENTITIES.length).toBe(160);
|
||||
it('ENT-003: exactly 161 entities, every class name unique', () => {
|
||||
expect(ALL_ENTITIES.length).toBe(161);
|
||||
const names = ALL_ENTITIES.map((s) => s.meta.className);
|
||||
expect(new Set(names).size).toBe(names.length);
|
||||
});
|
||||
|
||||
@@ -56,7 +56,8 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
// + trek_photo_signals: id, photo_id, algo (a measurement always names its photo and how it was made).
|
||||
// - assistant_threads.trip_id: a thread may be about a journey instead, so it is nullable now.
|
||||
// + journey_book_revisions: id, journey_id, version, document (a revision is only ever written whole).
|
||||
const KNOWN_UNINITIALISED_NOT_NULL_SCALARS = 572;
|
||||
// + trip_member_removals: id, trip_id, user_id (a removal note always names its trip and its person).
|
||||
const KNOWN_UNINITIALISED_NOT_NULL_SCALARS = 575;
|
||||
|
||||
describe('entity class fields never carry a NaN initialiser (task-4-review-shape.md, Important 1)', () => {
|
||||
it('CLASSFIELD-001: new X() has no own property whose value is NaN, across all 133 entities', () => {
|
||||
|
||||
@@ -96,6 +96,7 @@ const UNNUMBERED = [
|
||||
'Migration20200101046260_a_book_keeps_its_last_revisions',
|
||||
'Migration20200101046270_a_photo_knows_its_colours_and_calm_areas',
|
||||
'Migration20200101046280_an_assistant_conversation_can_be_about_no_one_trip',
|
||||
'Migration20200101046290_a_removed_member_cannot_rejoin_by_invite_link',
|
||||
];
|
||||
// The last step the positional runner ever had; planLegacyBaseline refuses a
|
||||
// schema_version past it.
|
||||
|
||||
@@ -492,3 +492,35 @@ describe('TripMembersRepository.clearInvitedBy (Plan 4 Task 1, UC4)', () => {
|
||||
expect((await memberRow(trip.id, member.id))?.invited_by).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('TripMembersRepository removal notes (GHSA-wr4c-964p-39jg)', () => {
|
||||
it('TMEMREPO-031: a recorded removal is found until it is cleared, and only for that trip and person', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: removed } = createUser(testDb);
|
||||
const { user: other } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
const otherTrip = createTrip(testDb, owner.id);
|
||||
|
||||
expect(await tripMembers.wasRemoved(trip.id, removed.id)).toBe(false);
|
||||
await tripMembers.recordRemoval(trip.id, removed.id);
|
||||
expect(await tripMembers.wasRemoved(trip.id, removed.id)).toBe(true);
|
||||
expect(await tripMembers.wasRemoved(otherTrip.id, removed.id)).toBe(false);
|
||||
expect(await tripMembers.wasRemoved(trip.id, other.id)).toBe(false);
|
||||
|
||||
await tripMembers.clearRemoval(trip.id, removed.id);
|
||||
expect(await tripMembers.wasRemoved(trip.id, removed.id)).toBe(false);
|
||||
});
|
||||
|
||||
it('TMEMREPO-032: recording the same removal twice keeps one note, and clearing a missing note is a no-op', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: removed } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
|
||||
await tripMembers.clearRemoval(trip.id, removed.id);
|
||||
await tripMembers.recordRemoval(trip.id, removed.id);
|
||||
await tripMembers.recordRemoval(trip.id, removed.id);
|
||||
expect(await tripMembers.wasRemoved(trip.id, removed.id)).toBe(true);
|
||||
await tripMembers.clearRemoval(trip.id, removed.id);
|
||||
expect(await tripMembers.wasRemoved(trip.id, removed.id)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -858,22 +858,4 @@ describe('runAgentLoop', () => {
|
||||
await runAgentLoop(unable.deps, unable.input, unable.writer, new AbortController().signal);
|
||||
expect(unable.requests[0]?.webSearch).toBe(false);
|
||||
});
|
||||
|
||||
it('ASTAGT-041: a step that searched the web and called a read answers only the read, never the search', async () => {
|
||||
const t = setup(
|
||||
[
|
||||
[
|
||||
{ type: 'web-search', query: 'Mount Takao trails' },
|
||||
{ type: 'tool-call', call: { id: 'call_1', name: 'list_places', args: { tripId: 1 } } },
|
||||
{ type: 'finish', reason: 'tool-calls' },
|
||||
],
|
||||
[{ type: 'text-delta', delta: 'Trail 1 up, trail 6 down.' }],
|
||||
],
|
||||
{ webSearch: true },
|
||||
true,
|
||||
);
|
||||
await runAgentLoop(t.deps, t.input, t.writer, new AbortController().signal);
|
||||
const answers = t.requests[1].messages.filter((m) => m.role === 'tool');
|
||||
expect(answers.map((m) => (m as { toolCallId: string }).toolCallId)).toEqual(['call_1']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -70,7 +70,7 @@ describe('someone who left the trip keeps their part of an expense (#2604)', ()
|
||||
],
|
||||
member_ids: [alice.id, bob.id, carol.id],
|
||||
});
|
||||
await membersSvc.removeMember(trip.id, bob.id);
|
||||
await membersSvc.removeMember(trip.id, bob.id, trip.user_id);
|
||||
|
||||
// What the expense form sends back: the ids it was seeded with.
|
||||
const saved = await budget.updateBudgetItem(item.id, trip.id, {
|
||||
@@ -101,7 +101,7 @@ describe('someone who left the trip keeps their part of an expense (#2604)', ()
|
||||
{ user_id: carol.id, amount: 30 },
|
||||
],
|
||||
});
|
||||
await membersSvc.removeMember(trip.id, bob.id);
|
||||
await membersSvc.removeMember(trip.id, bob.id, trip.user_id);
|
||||
|
||||
const saved = await budget.updateBudgetItem(item.id, trip.id, {
|
||||
payers: [{ user_id: bob.id, amount: 90 }],
|
||||
@@ -150,7 +150,7 @@ describe('someone who left the trip keeps their part of an expense (#2604)', ()
|
||||
payers: [{ user_id: alice.id, amount: 30 }],
|
||||
member_ids: [alice.id, bob.id, carol.id],
|
||||
});
|
||||
await membersSvc.removeMember(trip.id, bob.id);
|
||||
await membersSvc.removeMember(trip.id, bob.id, trip.user_id);
|
||||
|
||||
const saved = await budget.updateBudgetItem(item.id, trip.id, {
|
||||
payers: [{ user_id: alice.id, amount: 30 }],
|
||||
@@ -170,7 +170,7 @@ describe('someone who left the trip keeps their part of an expense (#2604)', ()
|
||||
payers: [{ user_id: alice.id, amount: 50 }],
|
||||
member_ids: [alice.id, bob.id],
|
||||
});
|
||||
await membersSvc.removeMember(trip.id, bob.id);
|
||||
await membersSvc.removeMember(trip.id, bob.id, trip.user_id);
|
||||
|
||||
const saved = await budget.updateBudgetItem(item.id, trip.id, {
|
||||
payers: [{ user_id: bob.id, amount: 50 }],
|
||||
@@ -190,7 +190,7 @@ describe('someone who left the trip keeps their part of an expense (#2604)', ()
|
||||
});
|
||||
await budget.toggleMemberPaid(item.id, trip.id, carol.id, true);
|
||||
await budget.toggleMemberPaid(item.id, trip.id, bob.id, true);
|
||||
await membersSvc.removeMember(trip.id, bob.id);
|
||||
await membersSvc.removeMember(trip.id, bob.id, trip.user_id);
|
||||
|
||||
await budget.updateBudgetItem(item.id, trip.id, {
|
||||
name: 'Hotel Central',
|
||||
@@ -213,7 +213,7 @@ describe('someone who left the trip keeps their part of an expense (#2604)', ()
|
||||
],
|
||||
member_ids: [alice.id, bob.id],
|
||||
});
|
||||
await membersSvc.removeMember(trip.id, bob.id);
|
||||
await membersSvc.removeMember(trip.id, bob.id, trip.user_id);
|
||||
|
||||
const payers = await budget.setItemPayers(item.id, trip.id, [
|
||||
{ user_id: alice.id, amount: 20 },
|
||||
@@ -228,7 +228,7 @@ describe('someone who left the trip keeps their part of an expense (#2604)', ()
|
||||
|
||||
it('BUDGET-SVC-DB-094: a new expense still takes only people on the trip', async () => {
|
||||
const { alice, bob, trip } = await tripWithFormerMember();
|
||||
await membersSvc.removeMember(trip.id, bob.id);
|
||||
await membersSvc.removeMember(trip.id, bob.id, trip.user_id);
|
||||
|
||||
const item = (await budget.createBudgetItem(trip.id, {
|
||||
name: 'Lunch',
|
||||
|
||||
@@ -51,7 +51,7 @@ function writesFor(s: TripMembersService) {
|
||||
addMember: vi.fn((tripId: string, identifier: string, ownerId: number, w: TripWriter) =>
|
||||
s.addMember(tripId, identifier, ownerId, w.userId),
|
||||
),
|
||||
removeMember: vi.fn((tripId: number, userId: number, _w: TripWriter) => s.removeMember(tripId, userId)),
|
||||
removeMember: vi.fn((tripId: number, userId: number, _w: TripWriter) => s.removeMember(tripId, userId, _w.userId)),
|
||||
createGuest: vi.fn((tripId: string, name: string, w: TripWriter) => s.createGuest(tripId, name, w.userId)),
|
||||
renameGuest: vi.fn((tripId: string, guestId: number, name: string, _w: TripWriter) =>
|
||||
s.renameGuest(tripId, guestId, name),
|
||||
@@ -174,7 +174,7 @@ describe('TripMembersController', () => {
|
||||
expect(await tc(s).removeMember(user, '9', '1', 'sock')).toEqual({ success: true });
|
||||
// The parsed trip id, as the removal itself takes it; member:removed goes out from the use case.
|
||||
expect(writes.removeMember).toHaveBeenCalledWith(9, 1, restWriter('sock'));
|
||||
expect(removeMember).toHaveBeenCalledWith(9, 1);
|
||||
expect(removeMember).toHaveBeenCalledWith(9, 1, 1);
|
||||
expect(await thrown(() => tc(s).removeMember(user, '9', '2'))).toEqual({
|
||||
status: 403,
|
||||
body: { error: 'No permission to remove members' },
|
||||
|
||||
@@ -1695,7 +1695,7 @@ describe('folded quirk branches', () => {
|
||||
await expect(membersSvc.addMember(trip.id, owner.email, owner.id, owner.id)).rejects.toThrow('Trip owner is already a member');
|
||||
await expect(membersSvc.addMember(trip.id, '', owner.id, owner.id)).rejects.toThrow('Email or username required');
|
||||
|
||||
await membersSvc.removeMember(trip.id, invitee.id);
|
||||
await membersSvc.removeMember(trip.id, invitee.id, trip.user_id);
|
||||
expect(await storedFields(TripMembers, { trip: trip.id, user: invitee.id }, ['id'])).toBeUndefined();
|
||||
});
|
||||
|
||||
|
||||
@@ -371,7 +371,7 @@ describe('TripsRpc membership', () => {
|
||||
it('TRIPS-RPC-017 a non-owner member is removed', async () => {
|
||||
const f = build();
|
||||
expect((await f.host().dispatch(req('trips.removeMember', { tripId: 1, userId: 7 }), 42)).ok).toBe(true);
|
||||
expect(f.roster.removeMember).toHaveBeenCalledWith(1, 7);
|
||||
expect(f.roster.removeMember).toHaveBeenCalledWith(1, 7, 42);
|
||||
expect(f.realtime.broadcast).toHaveBeenCalledWith(1, 'member:removed', { userId: 7 });
|
||||
});
|
||||
|
||||
|
||||
@@ -264,23 +264,6 @@ describe('a mark set as text on a page laid out again', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('the page background a saved layout brings', () => {
|
||||
it("BOOKLAY-030: replaces the page's colour and its link to a theme colour, so a theme cannot paint over it", () => {
|
||||
const from = bookSpreadSchema.parse({ id: 'a', role: 'inner', background: '#123456', elements: [] });
|
||||
const layout = bookLayoutFromSpread(from, page, 'Navy');
|
||||
const linked = bookSpreadSchema.parse({
|
||||
id: 'b',
|
||||
role: 'inner',
|
||||
background: '#ffffff',
|
||||
backgroundToken: 'paper',
|
||||
elements: [],
|
||||
});
|
||||
const next = applySavedBookLayout(linked, layout, page);
|
||||
expect(next.background).toBe('#123456');
|
||||
expect(next).not.toHaveProperty('backgroundToken');
|
||||
});
|
||||
});
|
||||
|
||||
describe('letters filled with a photo in a saved layout', () => {
|
||||
const frame = { x: 10, y: 10, w: 80, h: 40 };
|
||||
const filledWith = (photoId: number) => ({ type: 'photo' as const, photoId, focalX: 0.5, focalY: 0.5, zoom: 1 });
|
||||
|
||||
@@ -145,9 +145,7 @@ export function applySavedBookLayout(spread: BookSpread, layout: BookLayout, pag
|
||||
const used = new Set(wordsFor.values());
|
||||
const parked = [...photos.slice(pi), ...texts.filter((t) => !used.has(t))].slice(0, MAX_SPREAD_ELEMENTS);
|
||||
|
||||
// The layout's background replaces the page's, so the page's link to a theme
|
||||
// colour goes with it: kept, the next theme would paint over the layout's colour.
|
||||
const { backgroundPaint: _old, backgroundToken: _unlinked, ...rest } = spread;
|
||||
const { backgroundPaint: _old, ...rest } = spread;
|
||||
const backgroundPaint = layout.backgroundPaint ? { backgroundPaint: layout.backgroundPaint } : {};
|
||||
return { ...rest, background: layout.background, ...backgroundPaint, elements: out, parked };
|
||||
}
|
||||
|
||||
@@ -79,40 +79,31 @@ export const FOOD_STICKERS: readonly BookStickerDef[] = [
|
||||
id: 'croissant',
|
||||
theme: 'food',
|
||||
tags: ['croissant', 'pastry', 'bakery', 'france', 'paris', 'breakfast', 'boulangerie', 'cafe'],
|
||||
descr: 'A golden croissant curved into a crescent, its rolled layers marked by darker folds and a baked underside.',
|
||||
descr: 'A golden, flaky croissant curved into a crescent, its rolled layers shaded darker at each fold.',
|
||||
box: [110, 70],
|
||||
slots: [
|
||||
{ name: 'pastry', color: '#e2a04a' },
|
||||
{ name: 'crust', color: '#b86b24' },
|
||||
],
|
||||
layers: [
|
||||
// One closed crescent, so no seam between two pieces can let the page show through.
|
||||
{ p: 0, d: 'M8 50C4 44 8 36 16 36C18 40 22 44 28 46C26 52 18 56 12 54Z' },
|
||||
{ p: 0, d: 'M102 50C106 44 102 36 94 36C92 40 88 44 82 46C84 52 92 56 98 54Z' },
|
||||
{ p: 0, d: 'M18 34C20 24 28 18 36 18C38 26 40 34 40 46C32 48 24 44 18 34Z' },
|
||||
{ p: 0, d: 'M92 34C90 24 82 18 74 18C72 26 70 34 70 46C78 48 86 44 92 34Z' },
|
||||
{ p: 0, d: 'M36 16C42 8 50 6 55 6C60 6 68 8 74 16C72 28 70 40 68 50C60 54 50 54 42 50C40 40 38 28 36 16Z' },
|
||||
{
|
||||
p: 0,
|
||||
d: 'M6 54C3 46 10 38 20 34C27 21 39 11 55 10C71 11 83 21 90 34C100 38 107 46 104 54C98 57 92 55 86 50C78 54 66 56 55 56C44 56 32 54 24 50C18 55 12 57 6 54Z',
|
||||
},
|
||||
{
|
||||
p: 'shade',
|
||||
d: 'M8 51C14 53 19 51 24 47C32 51 44 53 55 53C66 53 78 51 86 47C91 51 96 53 102 51L104 54C98 57 92 55 86 50C78 54 66 56 55 56C44 56 32 54 24 50C18 55 12 57 6 54Z',
|
||||
o: 0.22,
|
||||
},
|
||||
{
|
||||
p: 'shade',
|
||||
d: 'M20 34C26 36 30 40 32 52C28 52 26 51 24 50C21 46 20 40 20 34ZM90 34C84 36 80 40 78 52C82 52 84 51 86 50C89 46 90 40 90 34Z',
|
||||
o: 0.12,
|
||||
p: 1,
|
||||
d: 'M28 46C24 44 20 40 16 36M40 46C40 34 38 26 36 18M70 46C70 34 72 26 74 18M82 46C86 44 90 40 94 36',
|
||||
o: 0.8,
|
||||
w: 1.6,
|
||||
},
|
||||
{
|
||||
p: 1,
|
||||
d: 'M23 49C19 44 18.5 38.5 21 34M39 54C35 44 34.5 28 38.5 14.5M71 54C75 44 75.5 28 71.5 14.5M87 49C91 44 91.5 38.5 89 34',
|
||||
o: 0.85,
|
||||
w: 2,
|
||||
},
|
||||
{
|
||||
p: 'light',
|
||||
d: 'M47 15.5C50.5 13 59.5 13 63 15.5M26.5 31C28.5 27 31.5 24 35 22M75 22C78.5 24 81.5 27 83.5 31M9.5 46C10.5 43 13 40.5 16 39M94 39C97 40.5 99.5 43 100.5 46',
|
||||
o: 0.5,
|
||||
w: 1.8,
|
||||
d: 'M18 34C24 44 32 48 40 46L42 50C32 52 22 46 18 34ZM92 34C86 44 78 48 70 46L68 50C78 52 88 46 92 34ZM8 50C10 54 14 55 18 54L12 54C10 53 9 52 8 50Z',
|
||||
o: 0.6,
|
||||
},
|
||||
{ p: 'shade', d: 'M42 50C50 54 60 54 68 50L68 52C60 57 50 57 42 52Z', o: 0.2 },
|
||||
{ p: 'light', d: 'M44 14C48 10 54 9 58 9M24 28C26 24 30 21 34 20M78 22C82 23 86 26 88 30', o: 0.45, w: 1.6 },
|
||||
],
|
||||
license: 'in-house',
|
||||
},
|
||||
@@ -131,7 +122,7 @@ export const FOOD_STICKERS: readonly BookStickerDef[] = [
|
||||
{ p: 0, d: 'M8 28L18 62C20 66 24 68 30 68L70 68C76 68 80 66 82 62L92 28Z' },
|
||||
{
|
||||
p: 'shade',
|
||||
d: 'M14 30L21.2 62M23 30L29.4 66M32 30L36.8 66M41 30L44.2 66M50 30L51.6 66M59 30L59 66M68 30L66.4 66M77 30L73.8 66M86 30L81.8 62',
|
||||
d: 'M14 30L22 66M23 30L29.4 66M32 30L36.8 66M41 30L44.2 66M50 30L51.6 66M59 30L59 66M68 30L66.4 66M77 30L73.8 66M86 30L81.2 66',
|
||||
o: 0.18,
|
||||
w: 1,
|
||||
},
|
||||
@@ -404,8 +395,6 @@ export const FOOD_STICKERS: readonly BookStickerDef[] = [
|
||||
],
|
||||
layers: [
|
||||
{ p: '#c9a77c', d: 'M22 8L98 36M30 4L102 30', w: 2.4 },
|
||||
// The bowl's rim behind the broth, and its front edge reaching over the broth below.
|
||||
{ p: 0, d: 'M102 46C102 53.2 81 59 55 59C29 59 8 53.2 8 46C8 38.8 29 33 55 33C81 33 102 38.8 102 46Z' },
|
||||
{ p: 1, d: 'M101 46C101 52.6 80.4 58 55 58C29.6 58 9 52.6 9 46C9 39.4 29.6 34 55 34C80.4 34 101 39.4 101 46Z' },
|
||||
{
|
||||
p: '#f3e3b8',
|
||||
@@ -435,7 +424,7 @@ export const FOOD_STICKERS: readonly BookStickerDef[] = [
|
||||
d: 'M21.6 50C21.6 50.9 20.9 51.6 20 51.6C19.1 51.6 18.4 50.9 18.4 50C18.4 49.1 19.1 48.4 20 48.4C20.9 48.4 21.6 49.1 21.6 50ZM20.7 50C20.7 50.4 20.4 50.7 20 50.7C19.6 50.7 19.3 50.4 19.3 50C19.3 49.6 19.6 49.3 20 49.3C20.4 49.3 20.7 49.6 20.7 50ZM25.6 48C25.6 48.9 24.9 49.6 24 49.6C23.1 49.6 22.4 48.9 22.4 48C22.4 47.1 23.1 46.4 24 46.4C24.9 46.4 25.6 47.1 25.6 48ZM24.7 48C24.7 48.4 24.4 48.7 24 48.7C23.6 48.7 23.3 48.4 23.3 48C23.3 47.6 23.6 47.3 24 47.3C24.4 47.3 24.7 47.6 24.7 48ZM53.6 52C53.6 52.9 52.9 53.6 52 53.6C51.1 53.6 50.4 52.9 50.4 52C50.4 51.1 51.1 50.4 52 50.4C52.9 50.4 53.6 51.1 53.6 52ZM52.7 52C52.7 52.4 52.4 52.7 52 52.7C51.6 52.7 51.3 52.4 51.3 52C51.3 51.6 51.6 51.3 52 51.3C52.4 51.3 52.7 51.6 52.7 52ZM87.6 44C87.6 44.9 86.9 45.6 86 45.6C85.1 45.6 84.4 44.9 84.4 44C84.4 43.1 85.1 42.4 86 42.4C86.9 42.4 87.6 43.1 87.6 44ZM86.7 44C86.7 44.4 86.4 44.7 86 44.7C85.6 44.7 85.3 44.4 85.3 44C85.3 43.6 85.6 43.3 86 43.3C86.4 43.3 86.7 43.6 86.7 44ZM91.6 48C91.6 48.9 90.9 49.6 90 49.6C89.1 49.6 88.4 48.9 88.4 48C88.4 47.1 89.1 46.4 90 46.4C90.9 46.4 91.6 47.1 91.6 48ZM90.7 48C90.7 48.4 90.4 48.7 90 48.7C89.6 48.7 89.3 48.4 89.3 48C89.3 47.6 89.6 47.3 90 47.3C90.4 47.3 90.7 47.6 90.7 48ZM57.6 40C57.6 40.9 56.9 41.6 56 41.6C55.1 41.6 54.4 40.9 54.4 40C54.4 39.1 55.1 38.4 56 38.4C56.9 38.4 57.6 39.1 57.6 40ZM56.7 40C56.7 40.4 56.4 40.7 56 40.7C55.6 40.7 55.3 40.4 55.3 40C55.3 39.6 55.6 39.3 56 39.3C56.4 39.3 56.7 39.6 56.7 40Z',
|
||||
eo: 1,
|
||||
},
|
||||
{ p: 0, d: 'M8 46C8 72 30 86 55 86C80 86 102 72 102 46C96 53 80 57 55 57C30 57 14 53 8 46Z' },
|
||||
{ p: 0, d: 'M8 46C8 72 30 86 55 86C80 86 102 72 102 46C96 54 80 60 55 60C30 60 14 54 8 46Z' },
|
||||
{ p: 'shade', d: 'M72 59C84 56 98 52 102 46C102 72 80 86 55 86C74 80 78 70 72 59Z', o: 0.18 },
|
||||
{
|
||||
p: '#fbf8f0',
|
||||
|
||||
@@ -493,8 +493,6 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.copyToPage': 'نسخ للصق في أي صفحة (Ctrl+C)',
|
||||
'journey.studio.paste': 'لصق (Ctrl+V)',
|
||||
'journey.studio.pasteEmpty': 'انسخ شيئًا أولًا، ثم الصقه في أي صفحة',
|
||||
'journey.studio.pageFull':
|
||||
'هذه الصفحة ممتلئة: تحتوي بالفعل على أقصى عدد من العناصر. احذف شيئًا أولًا أو استخدم صفحة أخرى.',
|
||||
'journey.studio.myLayouts': 'تخطيطاتي',
|
||||
'journey.studio.myLayoutsEmpty': 'احتفظ بصفحة رتّبتها، ورتّب صفحات أخرى بالطريقة نفسها. تبقى صورها ونصوصها كما هي.',
|
||||
'journey.studio.saveLayout': 'حفظ هذه الصفحة كتخطيط',
|
||||
|
||||
@@ -56,6 +56,8 @@ const trip: TranslationStrings = {
|
||||
'trip.invite.joining': 'جارٍ الانضمام…',
|
||||
'trip.invite.invalidTitle': 'الدعوة غير متاحة',
|
||||
'trip.invite.invalid': 'رابط الدعوة هذا غير صالح أو انتهت صلاحيته.',
|
||||
'trip.invite.removedTitle': 'تمت إزالة وصولك',
|
||||
'trip.invite.removed': 'لقد تمت إزالتك من هذه الرحلة. اطلب من مالكها إضافتك مرة أخرى.',
|
||||
'trip.invite.backToDashboard': 'العودة إلى لوحة التحكم',
|
||||
'transit.title': 'النقل العام',
|
||||
'transit.from': 'من',
|
||||
|
||||
@@ -510,8 +510,6 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.copyToPage': 'İstənilən səhifəyə yapışdırmaq üçün kopyala (Ctrl+C)',
|
||||
'journey.studio.paste': 'Yapışdır (Ctrl+V)',
|
||||
'journey.studio.pasteEmpty': 'Əvvəlcə nəyisə kopyalayın, sonra onu istənilən səhifəyə yapışdırın',
|
||||
'journey.studio.pageFull':
|
||||
'Bu səhifə doludur: bir səhifəyə daha çox element sığmır. Əvvəlcə nəyisə silin və ya başqa səhifədən istifadə edin.',
|
||||
'journey.studio.myLayouts': 'Mənim tərtibatlarım',
|
||||
'journey.studio.myLayoutsEmpty':
|
||||
'Tərtib etdiyiniz səhifəni saxlayın və digər səhifələri eyni şəkildə düzün. Onların fotoları və mətnləri qalır.',
|
||||
|
||||
@@ -53,6 +53,8 @@ const trip: TranslationStrings = {
|
||||
'trip.invite.joining': 'Qoşulur…',
|
||||
'trip.invite.invalidTitle': 'Dəvət əlçatan deyil',
|
||||
'trip.invite.invalid': 'Bu dəvət linki etibarsızdır və ya vaxtı bitib.',
|
||||
'trip.invite.removedTitle': 'Giriş götürülüb',
|
||||
'trip.invite.removed': 'Siz bu səyahətdən çıxarılmısınız. Sahibindən sizi yenidən əlavə etməsini xahiş edin.',
|
||||
'trip.invite.backToDashboard': 'İdarə panelinə qayıt',
|
||||
|
||||
'transit.title': 'İctimai nəqliyyat',
|
||||
|
||||
@@ -466,8 +466,6 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.copyToPage': 'Copiar, para colar em qualquer página (Ctrl+C)',
|
||||
'journey.studio.paste': 'Colar (Ctrl+V)',
|
||||
'journey.studio.pasteEmpty': 'Copie algo primeiro e depois cole em qualquer página',
|
||||
'journey.studio.pageFull':
|
||||
'Esta página está cheia: ela já tem o máximo de elementos de uma página. Remova algo primeiro ou use outra página.',
|
||||
'journey.studio.myLayouts': 'Minhas disposições',
|
||||
'journey.studio.myLayoutsEmpty':
|
||||
'Guarde uma página que você organizou e disponha outras páginas do mesmo jeito. As fotos e os textos delas continuam.',
|
||||
|
||||
@@ -48,6 +48,8 @@ const trip: TranslationStrings = {
|
||||
'trip.invite.joining': 'Entrando…',
|
||||
'trip.invite.invalidTitle': 'Convite indisponível',
|
||||
'trip.invite.invalid': 'Este link de convite é inválido ou expirou.',
|
||||
'trip.invite.removedTitle': 'Acesso removido',
|
||||
'trip.invite.removed': 'Você foi removido desta viagem. Peça ao dono dela para adicionar você novamente.',
|
||||
'trip.invite.backToDashboard': 'Voltar ao painel',
|
||||
'transit.title': 'Transporte público',
|
||||
'transit.from': 'De',
|
||||
|
||||
@@ -465,8 +465,6 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.copyToPage': 'Copia, per enganxar a qualsevol pàgina (Ctrl+C)',
|
||||
'journey.studio.paste': 'Enganxa (Ctrl+V)',
|
||||
'journey.studio.pasteEmpty': 'Primer copia alguna cosa i després enganxa-la a qualsevol pàgina',
|
||||
'journey.studio.pageFull':
|
||||
'Aquesta pàgina és plena: ja té tots els elements que hi caben. Treu-ne algun primer o fes servir una altra pàgina.',
|
||||
'journey.studio.myLayouts': 'Les meves disposicions',
|
||||
'journey.studio.myLayoutsEmpty':
|
||||
'Guarda una pàgina que hagis organitzat i disposa altres pàgines de la mateixa manera. Les seves fotos i textos es mantenen.',
|
||||
|
||||
@@ -44,6 +44,8 @@ const trip: TranslationStrings = {
|
||||
'trip.invite.joining': 'Unint-se…',
|
||||
'trip.invite.invalidTitle': 'Invitació no disponible',
|
||||
'trip.invite.invalid': "Aquest enllaç d'invitació no és vàlid o ha caducat.",
|
||||
'trip.invite.removedTitle': 'Accés retirat',
|
||||
'trip.invite.removed': "T'han tret d'aquest viatge. Demana al seu propietari que et torni a afegir.",
|
||||
'trip.invite.backToDashboard': 'Tornar al tauler',
|
||||
'transit.title': 'Transport públic',
|
||||
'transit.from': 'Des de',
|
||||
|
||||
@@ -470,8 +470,6 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.copyToPage': 'Kopírovat pro vložení na libovolnou stranu (Ctrl+C)',
|
||||
'journey.studio.paste': 'Vložit (Ctrl+V)',
|
||||
'journey.studio.pasteEmpty': 'Nejprve něco zkopírujte a pak to vložte na libovolnou stranu',
|
||||
'journey.studio.pageFull':
|
||||
'Tato stránka je plná: víc prvků se na stránku nevejde. Nejdřív něco odeberte, nebo použijte jinou stránku.',
|
||||
'journey.studio.myLayouts': 'Moje rozvržení',
|
||||
'journey.studio.myLayoutsEmpty':
|
||||
'Uložte si stranu, kterou jste uspořádali, a rozvrhněte stejně i další strany. Jejich fotky a texty zůstanou.',
|
||||
|
||||
@@ -50,6 +50,8 @@ const trip: TranslationStrings = {
|
||||
'trip.invite.joining': 'Připojování…',
|
||||
'trip.invite.invalidTitle': 'Pozvánka není dostupná',
|
||||
'trip.invite.invalid': 'Tento odkaz pro pozvání je neplatný nebo jeho platnost vypršela.',
|
||||
'trip.invite.removedTitle': 'Přístup odebrán',
|
||||
'trip.invite.removed': 'Z této cesty jste byli odebráni. Požádejte jejího vlastníka, aby vás znovu přidal.',
|
||||
'trip.invite.backToDashboard': 'Zpět na přehled',
|
||||
'transit.title': 'Veřejná doprava',
|
||||
'transit.from': 'Odkud',
|
||||
|
||||
@@ -466,8 +466,6 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.copyToPage': 'Kopieren, um es auf einer beliebigen Seite einzufügen (Ctrl+C)',
|
||||
'journey.studio.paste': 'Einfügen (Ctrl+V)',
|
||||
'journey.studio.pasteEmpty': 'Kopiere zuerst etwas und füge es dann auf einer beliebigen Seite ein',
|
||||
'journey.studio.pageFull':
|
||||
'Diese Seite ist voll: Mehr Elemente passen auf eine Seite nicht. Entferne zuerst etwas oder nimm eine andere Seite.',
|
||||
'journey.studio.myLayouts': 'Meine Anordnungen',
|
||||
'journey.studio.myLayoutsEmpty':
|
||||
'Behalte eine Seite, die du angeordnet hast, und ordne andere Seiten genauso an. Ihre Fotos und Texte bleiben erhalten.',
|
||||
|
||||
@@ -48,6 +48,9 @@ const trip: TranslationStrings = {
|
||||
'trip.invite.joining': 'Trete bei …',
|
||||
'trip.invite.invalidTitle': 'Einladung nicht verfügbar',
|
||||
'trip.invite.invalid': 'Dieser Einladungslink ist ungültig oder abgelaufen.',
|
||||
'trip.invite.removedTitle': 'Zugang entfernt',
|
||||
'trip.invite.removed':
|
||||
'Du wurdest aus dieser Reise entfernt. Bitte den Eigentümer der Reise, dich wieder hinzuzufügen.',
|
||||
'trip.invite.backToDashboard': 'Zurück zum Dashboard',
|
||||
'transit.title': 'Öffentliche Verkehrsmittel',
|
||||
'transit.from': 'Von',
|
||||
|
||||
@@ -463,8 +463,6 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.copyToPage': 'Copy, to paste on any page (Ctrl+C)',
|
||||
'journey.studio.paste': 'Paste (Ctrl+V)',
|
||||
'journey.studio.pasteEmpty': 'Copy something first, then paste it on any page',
|
||||
'journey.studio.pageFull':
|
||||
'This page is full: it already holds as many elements as a page can. Remove something first, or use another page.',
|
||||
'journey.studio.myLayouts': 'My layouts',
|
||||
'journey.studio.myLayoutsEmpty':
|
||||
'Keep a page you arranged and lay other pages out the same way. Their photos and words stay.',
|
||||
|
||||
@@ -48,6 +48,8 @@ const trip: TranslationStrings = {
|
||||
'trip.invite.joining': 'Joining…',
|
||||
'trip.invite.invalidTitle': 'Invite unavailable',
|
||||
'trip.invite.invalid': 'This invite link is invalid or has expired.',
|
||||
'trip.invite.removedTitle': 'Access removed',
|
||||
'trip.invite.removed': 'You were removed from this trip. Ask its owner to add you again.',
|
||||
'trip.invite.backToDashboard': 'Back to dashboard',
|
||||
'transit.title': 'Public transit',
|
||||
'transit.from': 'From',
|
||||
|
||||
@@ -466,8 +466,6 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.copyToPage': 'Copiar, para pegar en cualquier página (Ctrl+C)',
|
||||
'journey.studio.paste': 'Pegar (Ctrl+V)',
|
||||
'journey.studio.pasteEmpty': 'Copia algo primero y luego pégalo en cualquier página',
|
||||
'journey.studio.pageFull':
|
||||
'Esta página está llena: ya tiene todos los elementos que caben. Quita algo primero o usa otra página.',
|
||||
'journey.studio.myLayouts': 'Mis disposiciones',
|
||||
'journey.studio.myLayoutsEmpty':
|
||||
'Guarda una página que hayas organizado y dispón otras páginas de la misma forma. Sus fotos y textos se mantienen.',
|
||||
|
||||
@@ -48,6 +48,8 @@ const trip: TranslationStrings = {
|
||||
'trip.invite.joining': 'Uniéndose…',
|
||||
'trip.invite.invalidTitle': 'Invitación no disponible',
|
||||
'trip.invite.invalid': 'Este enlace de invitación no es válido o ha caducado.',
|
||||
'trip.invite.removedTitle': 'Acceso retirado',
|
||||
'trip.invite.removed': 'Te han quitado de este viaje. Pide a su propietario que te vuelva a añadir.',
|
||||
'trip.invite.backToDashboard': 'Volver al panel',
|
||||
'transit.title': 'Transporte público',
|
||||
'transit.from': 'Origen',
|
||||
|
||||
@@ -464,8 +464,6 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.copyToPage': 'Kopeeri, et kleepida mis tahes lehele (Ctrl+C)',
|
||||
'journey.studio.paste': 'Kleebi (Ctrl+V)',
|
||||
'journey.studio.pasteEmpty': 'Kopeeri esmalt midagi, siis kleebi see mis tahes lehele',
|
||||
'journey.studio.pageFull':
|
||||
'See leht on täis: rohkem elemente ühele lehele ei mahu. Eemalda esmalt midagi või kasuta teist lehte.',
|
||||
'journey.studio.myLayouts': 'Minu paigutused',
|
||||
'journey.studio.myLayoutsEmpty':
|
||||
'Salvesta enda kujundatud leht ja paiguta teised lehed samamoodi. Nende fotod ja tekstid jäävad alles.',
|
||||
|
||||
@@ -48,6 +48,8 @@ const trip: TranslationStrings = {
|
||||
'trip.invite.joining': 'Liitumine…',
|
||||
'trip.invite.invalidTitle': 'Kutse pole saadaval',
|
||||
'trip.invite.invalid': 'See kutselink on vigane või aegunud.',
|
||||
'trip.invite.removedTitle': 'Ligipääs eemaldatud',
|
||||
'trip.invite.removed': 'Sind eemaldati sellelt reisilt. Palu selle omanikul sind uuesti lisada.',
|
||||
'trip.invite.backToDashboard': 'Tagasi avalehele',
|
||||
'transit.title': 'Ühistransport',
|
||||
'transit.from': 'Lähtekoht',
|
||||
|
||||
@@ -464,8 +464,6 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.copyToPage': 'Copier, pour coller sur n’importe quelle page (Ctrl+C)',
|
||||
'journey.studio.paste': 'Coller (Ctrl+V)',
|
||||
'journey.studio.pasteEmpty': 'Copiez d’abord quelque chose, puis collez-le sur n’importe quelle page',
|
||||
'journey.studio.pageFull':
|
||||
"Cette page est pleine : elle contient déjà le maximum d'éléments. Retirez d'abord un élément ou utilisez une autre page.",
|
||||
'journey.studio.myLayouts': 'Mes dispositions',
|
||||
'journey.studio.myLayoutsEmpty':
|
||||
'Conservez une page que vous avez agencée et disposez d’autres pages de la même façon. Leurs photos et textes restent.',
|
||||
|
||||
@@ -48,6 +48,8 @@ const trip: TranslationStrings = {
|
||||
'trip.invite.joining': 'Ajout en cours…',
|
||||
'trip.invite.invalidTitle': 'Invitation indisponible',
|
||||
'trip.invite.invalid': "Ce lien d'invitation est invalide ou a expiré.",
|
||||
'trip.invite.removedTitle': 'Accès retiré',
|
||||
'trip.invite.removed': 'Vous avez été retiré de ce voyage. Demandez à son propriétaire de vous ajouter à nouveau.',
|
||||
'trip.invite.backToDashboard': 'Retour au tableau de bord',
|
||||
'transit.title': 'Transports en commun',
|
||||
'transit.from': 'Départ',
|
||||
|
||||
@@ -469,8 +469,6 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.copyToPage': 'Αντιγραφή, για επικόλληση σε οποιαδήποτε σελίδα (Ctrl+C)',
|
||||
'journey.studio.paste': 'Επικόλληση (Ctrl+V)',
|
||||
'journey.studio.pasteEmpty': 'Αντιγράψτε πρώτα κάτι και μετά επικολλήστε το σε οποιαδήποτε σελίδα',
|
||||
'journey.studio.pageFull':
|
||||
'Αυτή η σελίδα είναι γεμάτη: έχει ήδη τα περισσότερα στοιχεία που χωρά μια σελίδα. Αφαιρέστε πρώτα κάτι ή χρησιμοποιήστε άλλη σελίδα.',
|
||||
'journey.studio.myLayouts': 'Οι διατάξεις μου',
|
||||
'journey.studio.myLayoutsEmpty':
|
||||
'Κρατήστε μια σελίδα που έχετε διαμορφώσει και στήστε άλλες σελίδες με τον ίδιο τρόπο. Οι φωτογραφίες και τα κείμενά τους παραμένουν.',
|
||||
|
||||
@@ -48,6 +48,8 @@ const trip: TranslationStrings = {
|
||||
'trip.invite.joining': 'Γίνεται συμμετοχή…',
|
||||
'trip.invite.invalidTitle': 'Η πρόσκληση δεν είναι διαθέσιμη',
|
||||
'trip.invite.invalid': 'Αυτός ο σύνδεσμος πρόσκλησης δεν είναι έγκυρος ή έχει λήξει.',
|
||||
'trip.invite.removedTitle': 'Η πρόσβαση αφαιρέθηκε',
|
||||
'trip.invite.removed': 'Αφαιρεθήκατε από αυτό το ταξίδι. Ζητήστε από τον κάτοχό του να σας προσθέσει ξανά.',
|
||||
'trip.invite.backToDashboard': 'Επιστροφή στα ταξίδια μου',
|
||||
'transit.title': 'Μέσα μαζικής μεταφοράς',
|
||||
'transit.from': 'Από',
|
||||
|
||||
@@ -463,8 +463,6 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.copyToPage': 'Másolás, bármelyik oldalra beilleszthető (Ctrl+C)',
|
||||
'journey.studio.paste': 'Beillesztés (Ctrl+V)',
|
||||
'journey.studio.pasteEmpty': 'Előbb másoljon ki valamit, aztán illessze be bármelyik oldalra',
|
||||
'journey.studio.pageFull':
|
||||
'Ez az oldal megtelt: több elem nem fér egy oldalra. Előbb távolíts el valamit, vagy használj másik oldalt.',
|
||||
'journey.studio.myLayouts': 'Saját elrendezéseim',
|
||||
'journey.studio.myLayoutsEmpty':
|
||||
'Mentsen el egy elrendezett oldalt, és rendezzen el más oldalakat is ugyanígy. Azok fotói és szövegei megmaradnak.',
|
||||
|
||||
@@ -48,6 +48,8 @@ const trip: TranslationStrings = {
|
||||
'trip.invite.joining': 'Csatlakozás…',
|
||||
'trip.invite.invalidTitle': 'A meghívó nem érhető el',
|
||||
'trip.invite.invalid': 'Ez a meghívó link érvénytelen vagy lejárt.',
|
||||
'trip.invite.removedTitle': 'Hozzáférés megvonva',
|
||||
'trip.invite.removed': 'Eltávolítottak ebből az utazásból. Kérd meg a tulajdonosát, hogy adjon hozzá újra.',
|
||||
'trip.invite.backToDashboard': 'Vissza az irányítópultra',
|
||||
'transit.title': 'Tömegközlekedés',
|
||||
'transit.from': 'Innen',
|
||||
|
||||
@@ -455,8 +455,6 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.copyToPage': 'Salin, untuk ditempel di halaman mana pun (Ctrl+C)',
|
||||
'journey.studio.paste': 'Tempel (Ctrl+V)',
|
||||
'journey.studio.pasteEmpty': 'Salin sesuatu dulu, lalu tempel di halaman mana pun',
|
||||
'journey.studio.pageFull':
|
||||
'Halaman ini penuh: sudah berisi elemen sebanyak yang muat dalam satu halaman. Hapus sesuatu dulu atau gunakan halaman lain.',
|
||||
'journey.studio.myLayouts': 'Tata letakku',
|
||||
'journey.studio.myLayoutsEmpty':
|
||||
'Simpan halaman yang sudah kamu susun dan tata halaman lain dengan cara yang sama. Foto dan teksnya tetap.',
|
||||
|
||||
@@ -46,6 +46,8 @@ const trip: TranslationStrings = {
|
||||
'trip.invite.joining': 'Bergabung…',
|
||||
'trip.invite.invalidTitle': 'Undangan tidak tersedia',
|
||||
'trip.invite.invalid': 'Tautan undangan ini tidak valid atau sudah kedaluwarsa.',
|
||||
'trip.invite.removedTitle': 'Akses dicabut',
|
||||
'trip.invite.removed': 'Anda telah dikeluarkan dari perjalanan ini. Minta pemiliknya untuk menambahkan Anda lagi.',
|
||||
'trip.invite.backToDashboard': 'Kembali ke dasbor',
|
||||
'transit.title': 'Transportasi umum',
|
||||
'transit.from': 'Dari',
|
||||
|
||||
@@ -465,8 +465,6 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.copyToPage': 'Copia, per incollare su qualsiasi pagina (Ctrl+C)',
|
||||
'journey.studio.paste': 'Incolla (Ctrl+V)',
|
||||
'journey.studio.pasteEmpty': 'Copia prima qualcosa, poi incollalo su qualsiasi pagina',
|
||||
'journey.studio.pageFull':
|
||||
"Questa pagina è piena: contiene già il massimo di elementi. Rimuovi prima qualcosa o usa un'altra pagina.",
|
||||
'journey.studio.myLayouts': 'Le mie disposizioni',
|
||||
'journey.studio.myLayoutsEmpty':
|
||||
'Conserva una pagina che hai sistemato e disponi le altre pagine allo stesso modo. Le loro foto e i testi restano.',
|
||||
|
||||
@@ -48,6 +48,8 @@ const trip: TranslationStrings = {
|
||||
'trip.invite.joining': 'Accesso in corso…',
|
||||
'trip.invite.invalidTitle': 'Invito non disponibile',
|
||||
'trip.invite.invalid': 'Questo link di invito non è valido o è scaduto.',
|
||||
'trip.invite.removedTitle': 'Accesso rimosso',
|
||||
'trip.invite.removed': 'Sei stato rimosso da questo viaggio. Chiedi al proprietario di aggiungerti di nuovo.',
|
||||
'trip.invite.backToDashboard': 'Torna alla dashboard',
|
||||
'transit.title': 'Trasporto pubblico',
|
||||
'transit.from': 'Da',
|
||||
|
||||
@@ -453,8 +453,6 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.copyToPage': 'コピーして任意のページに貼り付け (Ctrl+C)',
|
||||
'journey.studio.paste': '貼り付け (Ctrl+V)',
|
||||
'journey.studio.pasteEmpty': 'まず何かをコピーしてから、任意のページに貼り付けてください',
|
||||
'journey.studio.pageFull':
|
||||
'このページはいっぱいです。1ページに置ける要素の上限に達しています。先に何かを削除するか、別のページを使ってください。',
|
||||
'journey.studio.myLayouts': 'マイレイアウト',
|
||||
'journey.studio.myLayoutsEmpty':
|
||||
'配置したページを保存して、他のページも同じ配置にできます。写真と文章はそのまま残ります。',
|
||||
|
||||
@@ -46,6 +46,8 @@ const trip: TranslationStrings = {
|
||||
'trip.invite.joining': '参加中…',
|
||||
'trip.invite.invalidTitle': '招待は利用できません',
|
||||
'trip.invite.invalid': 'この招待リンクは無効か、有効期限が切れています。',
|
||||
'trip.invite.removedTitle': 'アクセスが削除されました',
|
||||
'trip.invite.removed': 'この旅行から削除されました。再度追加してもらうよう、オーナーに依頼してください。',
|
||||
'trip.invite.backToDashboard': 'ダッシュボードに戻る',
|
||||
'transit.title': '公共交通機関',
|
||||
'transit.from': '出発地',
|
||||
|
||||
@@ -450,8 +450,6 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.copyToPage': '복사해서 아무 페이지에나 붙여넣기 (Ctrl+C)',
|
||||
'journey.studio.paste': '붙여넣기 (Ctrl+V)',
|
||||
'journey.studio.pasteEmpty': '먼저 무언가를 복사한 뒤 아무 페이지에나 붙여넣으세요',
|
||||
'journey.studio.pageFull':
|
||||
'이 페이지는 가득 찼습니다. 한 페이지에 넣을 수 있는 요소가 이미 최대입니다. 먼저 무언가를 지우거나 다른 페이지를 사용하세요.',
|
||||
'journey.studio.myLayouts': '내 레이아웃',
|
||||
'journey.studio.myLayoutsEmpty':
|
||||
'배치한 페이지를 저장해 두고 다른 페이지도 같은 방식으로 배치하세요. 사진과 글은 그대로 유지됩니다.',
|
||||
|
||||
@@ -46,6 +46,8 @@ const trip: TranslationStrings = {
|
||||
'trip.invite.joining': '참여 중…',
|
||||
'trip.invite.invalidTitle': '초대를 사용할 수 없음',
|
||||
'trip.invite.invalid': '이 초대 링크는 유효하지 않거나 만료되었습니다.',
|
||||
'trip.invite.removedTitle': '접근 권한이 제거됨',
|
||||
'trip.invite.removed': '이 여행에서 제외되었습니다. 소유자에게 다시 추가해 달라고 요청하세요.',
|
||||
'trip.invite.backToDashboard': '대시보드로 돌아가기',
|
||||
'transit.title': '대중교통',
|
||||
'transit.from': '출발',
|
||||
|
||||
@@ -466,8 +466,6 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.copyToPage': 'Kopiëren, om op elke pagina te plakken (Ctrl+C)',
|
||||
'journey.studio.paste': 'Plakken (Ctrl+V)',
|
||||
'journey.studio.pasteEmpty': 'Kopieer eerst iets en plak het dan op een willekeurige pagina',
|
||||
'journey.studio.pageFull':
|
||||
'Deze pagina is vol: er passen niet meer elementen op een pagina. Verwijder eerst iets of gebruik een andere pagina.',
|
||||
'journey.studio.myLayouts': 'Mijn indelingen',
|
||||
'journey.studio.myLayoutsEmpty':
|
||||
'Bewaar een pagina die je hebt ingedeeld en deel andere pagina’s op dezelfde manier in. Hun foto’s en teksten blijven staan.',
|
||||
|
||||
@@ -48,6 +48,8 @@ const trip: TranslationStrings = {
|
||||
'trip.invite.joining': 'Bezig met deelnemen…',
|
||||
'trip.invite.invalidTitle': 'Uitnodiging niet beschikbaar',
|
||||
'trip.invite.invalid': 'Deze uitnodigingslink is ongeldig of verlopen.',
|
||||
'trip.invite.removedTitle': 'Toegang ingetrokken',
|
||||
'trip.invite.removed': 'Je bent uit deze reis verwijderd. Vraag de eigenaar om je opnieuw toe te voegen.',
|
||||
'trip.invite.backToDashboard': 'Terug naar dashboard',
|
||||
'transit.title': 'Openbaar vervoer',
|
||||
'transit.from': 'Van',
|
||||
|
||||
@@ -482,8 +482,6 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.copyToPage': 'Kopiuj, aby wkleić na dowolnej stronie (Ctrl+C)',
|
||||
'journey.studio.paste': 'Wklej (Ctrl+V)',
|
||||
'journey.studio.pasteEmpty': 'Najpierw coś skopiuj, a potem wklej to na dowolnej stronie',
|
||||
'journey.studio.pageFull':
|
||||
'Ta strona jest pełna: więcej elementów się na niej nie zmieści. Najpierw coś usuń albo użyj innej strony.',
|
||||
'journey.studio.myLayouts': 'Moje układy',
|
||||
'journey.studio.myLayoutsEmpty':
|
||||
'Zachowaj ułożoną stronę i rozmieść inne strony w ten sam sposób. Ich zdjęcia i teksty pozostaną.',
|
||||
|
||||
@@ -52,6 +52,8 @@ const trip: TranslationStrings = {
|
||||
'trip.invite.joining': 'Dołączanie…',
|
||||
'trip.invite.invalidTitle': 'Zaproszenie niedostępne',
|
||||
'trip.invite.invalid': 'Ten link zaproszenia jest nieprawidłowy lub wygasł.',
|
||||
'trip.invite.removedTitle': 'Dostęp odebrany',
|
||||
'trip.invite.removed': 'Usunięto Cię z tej podróży. Poproś jej właściciela, aby dodał Cię ponownie.',
|
||||
'trip.invite.backToDashboard': 'Powrót do panelu',
|
||||
'transit.title': 'Transport publiczny',
|
||||
'transit.from': 'Skąd',
|
||||
|
||||
@@ -481,8 +481,6 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.copyToPage': 'Копировать, чтобы вставить на любую страницу (Ctrl+C)',
|
||||
'journey.studio.paste': 'Вставить (Ctrl+V)',
|
||||
'journey.studio.pasteEmpty': 'Сначала скопируйте что-нибудь, затем вставьте на любую страницу',
|
||||
'journey.studio.pageFull':
|
||||
'Эта страница заполнена: больше элементов на страницу не помещается. Сначала удалите что-нибудь или используйте другую страницу.',
|
||||
'journey.studio.myLayouts': 'Мои макеты',
|
||||
'journey.studio.myLayoutsEmpty':
|
||||
'Сохраните оформленную страницу и расположите другие страницы так же. Их фото и тексты останутся.',
|
||||
|
||||
@@ -52,6 +52,8 @@ const trip: TranslationStrings = {
|
||||
'trip.invite.joining': 'Присоединение…',
|
||||
'trip.invite.invalidTitle': 'Приглашение недоступно',
|
||||
'trip.invite.invalid': 'Эта ссылка-приглашение недействительна или срок её действия истёк.',
|
||||
'trip.invite.removedTitle': 'Доступ отозван',
|
||||
'trip.invite.removed': 'Вас удалили из этой поездки. Попросите её владельца снова добавить вас.',
|
||||
'trip.invite.backToDashboard': 'Вернуться на панель управления',
|
||||
'transit.title': 'Общественный транспорт',
|
||||
'transit.from': 'Откуда',
|
||||
|
||||
@@ -474,8 +474,6 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.copyToPage': 'Kopírovať na vloženie na ľubovoľnú stranu (Ctrl+C)',
|
||||
'journey.studio.paste': 'Vložiť (Ctrl+V)',
|
||||
'journey.studio.pasteEmpty': 'Najprv niečo skopírujte a potom to vložte na ľubovoľnú stranu',
|
||||
'journey.studio.pageFull':
|
||||
'Táto stránka je plná: viac prvkov sa na stránku nezmestí. Najprv niečo odstráňte alebo použite inú stránku.',
|
||||
'journey.studio.myLayouts': 'Moje rozloženia',
|
||||
'journey.studio.myLayoutsEmpty':
|
||||
'Uložte si stranu, ktorú ste usporiadali, a rozložte rovnako aj ďalšie strany. Ich fotky a texty zostanú.',
|
||||
|
||||
@@ -50,6 +50,8 @@ const trip: TranslationStrings = {
|
||||
'trip.invite.joining': 'Pripája sa…',
|
||||
'trip.invite.invalidTitle': 'Pozvánka nie je dostupná',
|
||||
'trip.invite.invalid': 'Tento odkaz na pozvanie je neplatný alebo jeho platnosť vypršala.',
|
||||
'trip.invite.removedTitle': 'Prístup odobratý',
|
||||
'trip.invite.removed': 'Z tejto cesty vás odobrali. Požiadajte jej vlastníka, aby vás znova pridal.',
|
||||
'trip.invite.backToDashboard': 'Späť na prehľad',
|
||||
'transit.title': 'Verejná doprava',
|
||||
'transit.from': 'Odkiaľ',
|
||||
|
||||
@@ -463,8 +463,6 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.copyToPage': 'Kopiera för att klistra in på valfri sida (Ctrl+C)',
|
||||
'journey.studio.paste': 'Klistra in (Ctrl+V)',
|
||||
'journey.studio.pasteEmpty': 'Kopiera något först och klistra sedan in det på valfri sida',
|
||||
'journey.studio.pageFull':
|
||||
'Den här sidan är full: fler element får inte plats på en sida. Ta bort något först eller använd en annan sida.',
|
||||
'journey.studio.myLayouts': 'Mina layouter',
|
||||
'journey.studio.myLayoutsEmpty':
|
||||
'Spara en sida du har ordnat och lägg upp andra sidor på samma sätt. Deras foton och texter finns kvar.',
|
||||
|
||||
@@ -48,6 +48,8 @@ const trip: TranslationStrings = {
|
||||
'trip.invite.joining': 'Går med…',
|
||||
'trip.invite.invalidTitle': 'Inbjudan inte tillgänglig',
|
||||
'trip.invite.invalid': 'Den här inbjudningslänken är ogiltig eller har gått ut.',
|
||||
'trip.invite.removedTitle': 'Åtkomst borttagen',
|
||||
'trip.invite.removed': 'Du har tagits bort från den här resan. Be ägaren att lägga till dig igen.',
|
||||
'trip.invite.backToDashboard': 'Tillbaka till översikten',
|
||||
'transit.title': 'Kollektivtrafik',
|
||||
'transit.from': 'Från',
|
||||
|
||||
@@ -449,8 +449,6 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.copyToPage': 'คัดลอกเพื่อวางในหน้าใดก็ได้ (Ctrl+C)',
|
||||
'journey.studio.paste': 'วาง (Ctrl+V)',
|
||||
'journey.studio.pasteEmpty': 'คัดลอกบางอย่างก่อน แล้ววางในหน้าใดก็ได้',
|
||||
'journey.studio.pageFull':
|
||||
'หน้านี้เต็มแล้ว ใส่องค์ประกอบได้ครบจำนวนสูงสุดของหนึ่งหน้า โปรดลบบางอย่างก่อนหรือใช้หน้าอื่น',
|
||||
'journey.studio.myLayouts': 'เค้าโครงของฉัน',
|
||||
'journey.studio.myLayoutsEmpty':
|
||||
'เก็บหน้าที่คุณจัดไว้ แล้วจัดหน้าอื่นในแบบเดียวกัน รูปภาพและข้อความของหน้าเหล่านั้นยังคงอยู่',
|
||||
|
||||
@@ -44,6 +44,8 @@ const trip: TranslationStrings = {
|
||||
'trip.invite.joining': 'กำลังเข้าร่วม...',
|
||||
'trip.invite.invalidTitle': 'ไม่สามารถเชิญได้',
|
||||
'trip.invite.invalid': 'ลิงก์คำเชิญนี้ไม่ถูกต้องหรือหมดอายุแล้ว',
|
||||
'trip.invite.removedTitle': 'ถูกนำสิทธิ์การเข้าถึงออกแล้ว',
|
||||
'trip.invite.removed': 'คุณถูกนำออกจากทริปนี้ โปรดขอให้เจ้าของทริปเพิ่มคุณอีกครั้ง',
|
||||
'trip.invite.backToDashboard': 'กลับไปที่แดชบอร์ด',
|
||||
'transit.title': 'การขนส่งสาธารณะ',
|
||||
'transit.from': 'จาก',
|
||||
|
||||
@@ -464,8 +464,6 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.copyToPage': 'Kopyala, herhangi bir sayfaya yapıştırmak için (Ctrl+C)',
|
||||
'journey.studio.paste': 'Yapıştır (Ctrl+V)',
|
||||
'journey.studio.pasteEmpty': 'Önce bir şey kopyala, sonra herhangi bir sayfaya yapıştır',
|
||||
'journey.studio.pageFull':
|
||||
'Bu sayfa dolu: bir sayfaya daha fazla öğe sığmıyor. Önce bir şey kaldırın ya da başka bir sayfa kullanın.',
|
||||
'journey.studio.myLayouts': 'Yerleşimlerim',
|
||||
'journey.studio.myLayoutsEmpty':
|
||||
'Düzenlediğin bir sayfayı sakla ve diğer sayfaları da aynı şekilde yerleştir. Fotoğrafları ve metinleri olduğu gibi kalır.',
|
||||
|
||||
@@ -48,6 +48,8 @@ const trip: TranslationStrings = {
|
||||
'trip.invite.joining': 'Katılınıyor…',
|
||||
'trip.invite.invalidTitle': 'Davet kullanılamıyor',
|
||||
'trip.invite.invalid': 'Bu davet bağlantısı geçersiz veya süresi dolmuş.',
|
||||
'trip.invite.removedTitle': 'Erişim kaldırıldı',
|
||||
'trip.invite.removed': 'Bu geziden çıkarıldınız. Sahibinden sizi yeniden eklemesini isteyin.',
|
||||
'trip.invite.backToDashboard': 'Panoya dön',
|
||||
'transit.title': 'Toplu taşıma',
|
||||
'transit.from': 'Nereden',
|
||||
|
||||
@@ -485,8 +485,6 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.copyToPage': 'Копіювати, щоб вставити на будь-яку сторінку (Ctrl+C)',
|
||||
'journey.studio.paste': 'Вставити (Ctrl+V)',
|
||||
'journey.studio.pasteEmpty': 'Спершу щось скопіюйте, потім вставте на будь-яку сторінку',
|
||||
'journey.studio.pageFull':
|
||||
'Ця сторінка заповнена: більше елементів на сторінку не вміщується. Спершу видаліть щось або скористайтеся іншою сторінкою.',
|
||||
'journey.studio.myLayouts': 'Мої макети',
|
||||
'journey.studio.myLayoutsEmpty':
|
||||
'Збережіть сторінку, яку ви впорядкували, і розкладайте інші сторінки так само. Їхні фото й тексти залишаться.',
|
||||
|
||||
@@ -52,6 +52,8 @@ const trip: TranslationStrings = {
|
||||
'trip.invite.joining': 'Приєднання…',
|
||||
'trip.invite.invalidTitle': 'Запрошення недоступне',
|
||||
'trip.invite.invalid': 'Це посилання-запрошення недійсне або термін його дії минув.',
|
||||
'trip.invite.removedTitle': 'Доступ скасовано',
|
||||
'trip.invite.removed': 'Вас видалили з цієї подорожі. Попросіть її власника знову додати вас.',
|
||||
'trip.invite.backToDashboard': 'Назад до панелі',
|
||||
'transit.title': 'Громадський транспорт',
|
||||
'transit.from': 'Звідки',
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user