diff --git a/client/src/api/collections.ts b/client/src/api/collections.ts index 0b8f54274..ab6b15dec 100644 --- a/client/src/api/collections.ts +++ b/client/src/api/collections.ts @@ -9,6 +9,7 @@ import type { CollectionUpdateRequest, CollectionSavePlaceRequest, CollectionSaveFromTripRequest, + CollectionImportablesResponse, CollectionPlaceUpdateRequest, CollectionCopyToTripRequest, CollectionInviteRequest, @@ -68,6 +69,8 @@ export const collectionsApi = { ax.post(`${base}/places/from-trip`, body satisfies CollectionSaveFromTripRequest).then((r: AxiosResponse) => r.data), saveFromTripMany: (collectionId: number, tripId: number, placeIds: number[], force?: boolean): Promise<{ copied: number; skipped: { id: number; name: string }[] }> => ax.post(`${base}/places/from-trip-many`, { collection_id: collectionId, source_trip_id: tripId, source_place_ids: placeIds, force }).then((r: AxiosResponse) => r.data), + importable: (collectionId: number, tripId: number): Promise => + ax.get(`${base}/${collectionId}/importable/${tripId}`).then((r: AxiosResponse) => r.data), updatePlace: (pid: number, body: CollectionPlaceUpdateRequest): Promise => ax.patch(`${base}/places/${pid}`, body satisfies CollectionPlaceUpdateRequest).then((r: AxiosResponse) => r.data), uploadPlaceImage: (pid: number, formData: FormData): Promise => diff --git a/client/src/components/Collections/BulkAssignLabelModal.tsx b/client/src/components/Collections/BulkAssignLabelModal.tsx index 2e05368c7..63035c178 100644 --- a/client/src/components/Collections/BulkAssignLabelModal.tsx +++ b/client/src/components/Collections/BulkAssignLabelModal.tsx @@ -39,7 +39,7 @@ export default function BulkAssignLabelModal({ isOpen, labels, count, onAssign, } return ( - + {labels.length === 0 ? (
@@ -67,18 +67,20 @@ export default function BulkAssignLabelModal({ isOpen, labels, count, onAssign, ) })}
+ {/* nowrap on all three: the labels are long in several locales and used to + wrap into two lines inside the buttons before the dialog got wider. */}
- - diff --git a/client/src/components/Collections/CollectionFilterBar.test.tsx b/client/src/components/Collections/CollectionFilterBar.test.tsx index bc63e3c55..3a12bcbf2 100644 --- a/client/src/components/Collections/CollectionFilterBar.test.tsx +++ b/client/src/components/Collections/CollectionFilterBar.test.tsx @@ -41,6 +41,8 @@ function makeProps(overrides: Partial = {}): HarnessProps { onLabelFilter: vi.fn(), canManageLabels: false, onManageLabels: vi.fn(), + canImport: false, + onImport: vi.fn(), showSelect: true, selectMode: false, onToggleSelect: vi.fn(), diff --git a/client/src/components/Collections/CollectionFilterBar.tsx b/client/src/components/Collections/CollectionFilterBar.tsx index 21b262ff4..266539383 100644 --- a/client/src/components/Collections/CollectionFilterBar.tsx +++ b/client/src/components/Collections/CollectionFilterBar.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useRef, useState } from 'react' -import { ChevronDown, Check, Layers, Tag, CheckSquare, Star, Plus, ArrowDownUp } from 'lucide-react' +import { ChevronDown, Check, Layers, Tag, CheckSquare, Star, Plus, ArrowDownUp, DownloadCloud } from 'lucide-react' import type { StatusFilter, CollectionSortMode } from '../../store/collectionStore' import type { TranslationFn } from '../../types' import { getCategoryIcon } from '../shared/categoryIcons' @@ -87,6 +87,10 @@ interface CollectionFilterBarProps { showSelect: boolean selectMode: boolean onToggleSelect: () => void + /** Bulk import from a trip — only on a real list, never the "All saved" union, + * which has no single destination to import into. */ + canImport: boolean + onImport: () => void t: TranslationFn } @@ -100,7 +104,7 @@ export default function CollectionFilterBar({ onStatusFilter, onCategoryFilter, onRatingFilter, onSortMode, canAddPlace, onAddPlace, showLabels, labelOptions, labelFilter, onLabelFilter, canManageLabels, onManageLabels, - showSelect, selectMode, onToggleSelect, t, + showSelect, selectMode, onToggleSelect, canImport, onImport, t, }: CollectionFilterBarProps): React.ReactElement { const statusOpts: Opt[] = [ { key: 'all', label: t('common.all'), count: counts.all }, @@ -160,6 +164,17 @@ export default function CollectionFilterBar({ )} + {canImport && ( + + )} {showLabels && (labelOptions.length > 0 || canManageLabels) && ( ) => ({ + place_id: 1, name: 'Somewhere', address: null, lat: null, lng: null, category_id: null, + image_url: null, already_in_list: false, scheduled: false, day_number: null, date: null, ...over, +}); + +// Colosseum sits on day 2, the market was never scheduled, the Pantheon is already saved. +const IMPORTABLE = [ + place({ place_id: 11, name: 'Colosseum', scheduled: true, day_number: 2, date: '2026-05-02' }), + place({ place_id: 12, name: 'Testaccio Market' }), + place({ place_id: 13, name: 'Pantheon', already_in_list: true, scheduled: true, day_number: 1 }), +]; + +function Harness(props: { onImported?: () => void; onClose?: () => void }): React.ReactElement { + const { t } = useTranslation(); + return ( + {})} + onImported={props.onImported ?? (() => {})} + t={t} + /> + ); +} + +function useHandlers(importable = IMPORTABLE, onPost?: (body: unknown) => void) { + server.use( + http.get('/api/trips', () => HttpResponse.json({ trips: TRIPS })), + http.get(`${BASE}/4/importable/7`, () => HttpResponse.json({ places: importable })), + http.post(`${BASE}/places/from-trip-many`, async ({ request }) => { + onPost?.(await request.json()); + return HttpResponse.json({ copied: 2, skipped: [{ id: 13, name: 'Pantheon' }] }); + }), + ); +} + +async function openTrip(user: ReturnType) { + await waitFor(() => expect(screen.getByText('Rome 2026')).toBeInTheDocument()); + await user.click(screen.getByText('Rome 2026')); + await waitFor(() => expect(screen.getByText('Colosseum')).toBeInTheDocument()); +} + +describe('ImportFromTripModal', () => { + beforeEach(() => useHandlers()); + + it('FE-COMP-COLIMPORT-001: lists the trips with their place count and a localized date range', async () => { + render(); + await waitFor(() => expect(screen.getByText('Rome 2026')).toBeInTheDocument()); + // The count is a compact badge; the full wording stays as its title. + expect(screen.getByTitle('3 places')).toHaveTextContent('3'); + // Formatted through the shared formatter, never the raw ISO string. + const dateBadge = screen.getByTitle(/May/); + expect(dateBadge.textContent).toMatch(/May 1.*–.*May 8/); + expect(dateBadge.textContent).not.toMatch(/2026-05-01/); + expect(screen.getByText('Lisbon')).toBeInTheDocument(); + }); + + it('FE-COMP-COLIMPORT-002: pre-selects the unscheduled place, not the scheduled one', async () => { + const user = userEvent.setup(); + render(); + await openTrip(user); + + // Only Testaccio Market is both new and dayless. + expect(screen.getByRole('button', { name: /Import 1/ })).toBeInTheDocument(); + expect(screen.getByText('1 selected')).toBeInTheDocument(); + }); + + it('FE-COMP-COLIMPORT-003: marks a place already on the list and refuses to select it', async () => { + const user = userEvent.setup(); + render(); + await openTrip(user); + + const row = screen.getByText('Pantheon').closest('button')!; + expect(within(row).getByText('Already saved')).toBeInTheDocument(); + expect(row).toBeDisabled(); + + await user.click(row); + expect(screen.getByText('1 selected')).toBeInTheDocument(); // unchanged + }); + + it('FE-COMP-COLIMPORT-004: shows the day a scheduled place sits on', async () => { + const user = userEvent.setup(); + render(); + await openTrip(user); + + const row = screen.getByText('Colosseum').closest('button')!; + expect(within(row).getByText('Day 2')).toBeInTheDocument(); + expect(within(screen.getByText('Testaccio Market').closest('button')!).getByText('Unscheduled')).toBeInTheDocument(); + }); + + it('FE-COMP-COLIMPORT-005: only the selected ids are sent, and the result is reported', async () => { + const onPost = vi.fn(); + const onImported = vi.fn(); + useHandlers(IMPORTABLE, onPost); + const user = userEvent.setup(); + render(); + await openTrip(user); + + // Add the scheduled one to the pre-selected leftover. + await user.click(screen.getByText('Colosseum').closest('button')!); + await user.click(screen.getByRole('button', { name: /Import 2/ })); + + await waitFor(() => expect(onPost).toHaveBeenCalled()); + const body = onPost.mock.calls[0][0] as { source_place_ids: number[]; source_trip_id: number }; + expect(body.source_trip_id).toBe(7); + expect([...body.source_place_ids].sort()).toEqual([11, 12]); + + await waitFor(() => expect(screen.getByText('2 places added')).toBeInTheDocument()); + expect(screen.getByText(/1 were already on the list/)).toBeInTheDocument(); + expect(onImported).toHaveBeenCalled(); + }); + + it('FE-COMP-COLIMPORT-006: the only-new filter hides what is already saved', async () => { + const user = userEvent.setup(); + render(); + await openTrip(user); + + await user.click(screen.getByRole('button', { name: 'Only new' })); + await waitFor(() => expect(screen.queryByText('Pantheon')).not.toBeInTheDocument()); + expect(screen.getByText('Colosseum')).toBeInTheDocument(); + }); + + it('FE-COMP-COLIMPORT-007: a trip whose places are all saved says so instead of offering an empty list', async () => { + useHandlers([place({ place_id: 13, name: 'Pantheon', already_in_list: true })]); + const user = userEvent.setup(); + render(); + await waitFor(() => expect(screen.getByText('Rome 2026')).toBeInTheDocument()); + await user.click(screen.getByText('Rome 2026')); + + await waitFor(() => expect(screen.getByText(/already on this list/)).toBeInTheDocument()); + expect(screen.getByRole('button', { name: /Import 0/ })).toBeDisabled(); + }); +}); diff --git a/client/src/components/Collections/ImportFromTripModal.tsx b/client/src/components/Collections/ImportFromTripModal.tsx new file mode 100644 index 000000000..4d7dd5d97 --- /dev/null +++ b/client/src/components/Collections/ImportFromTripModal.tsx @@ -0,0 +1,340 @@ +import React, { useEffect, useMemo, useState } from 'react' +import { ArrowLeft, Calendar, Check, ChevronRight, Loader2, MapPin, Map as MapIcon, Search, Sparkles } from 'lucide-react' +import Modal from '../shared/Modal' +import { tripsApi } from '../../api/client' +import { collectionsApi } from '../../api/collections' +import { getCategoryIcon } from '../shared/categoryIcons' +import { useToast } from '../shared/Toast' +import { useTranslation } from '../../i18n' +import { formatDate } from '../../utils/formatters' +import { getApiErrorMessage } from '../../types' +import type { Category, TranslationFn } from '../../types' +import type { CollectionImportablePlace, Trip } from '@trek/shared' + +interface ImportFromTripModalProps { + isOpen: boolean + collectionId: number + collectionName: string + categories: Category[] + onClose: () => void + onImported: () => void + t: TranslationFn +} + +type Step = 'trip' | 'places' | 'done' + +/** Rows sort new-and-unscheduled first, then the rest of the new ones, and the + * already-saved ones last. A trip whose places are mostly saved already then reads + * as "here are the two left over" instead of a wall of greyed-out rows. */ +function importOrder(p: CollectionImportablePlace): number { + if (p.already_in_list) return 2 + return p.scheduled ? 1 : 0 +} + +/** Dates go through the shared formatter, so they follow the reader's locale + * (and drop the year when it is the current one) instead of showing raw ISO. */ +function tripRange(trip: Trip, locale: string): string | null { + const from = formatDate(trip.start_date, locale) + const to = formatDate(trip.end_date, locale) + if (from && to && to !== from) return `${from} – ${to}` + return from || to || null +} + +/** + * Bulk import of a trip's places into the current list. + * + * Two steps: pick a trip, then pick its places. The duplicate verdict comes from + * the server (`/importable/:tripId`) rather than a second comparison here, so a row + * shown as new can never come back as skipped. Places no day holds are pre-selected: + * those are the ones a trip left behind, which is what this import is for. + */ +export default function ImportFromTripModal({ isOpen, collectionId, collectionName, categories, onClose, onImported, t }: ImportFromTripModalProps): React.ReactElement { + const toast = useToast() + const { language } = useTranslation() + const [step, setStep] = useState('trip') + const [trips, setTrips] = useState([]) + const [tripsLoading, setTripsLoading] = useState(false) + const [trip, setTrip] = useState(null) + const [items, setItems] = useState([]) + const [itemsLoading, setItemsLoading] = useState(false) + const [selected, setSelected] = useState>(new Set()) + const [onlyNew, setOnlyNew] = useState(false) + const [query, setQuery] = useState('') + const [importing, setImporting] = useState(false) + const [result, setResult] = useState<{ copied: number; skipped: number } | null>(null) + + useEffect(() => { + if (!isOpen) return + setStep('trip'); setTrip(null); setItems([]); setSelected(new Set()); setQuery(''); setOnlyNew(false); setResult(null) + setTripsLoading(true) + tripsApi.list() + .then((data: { trips?: Trip[] }) => setTrips(data.trips ?? [])) + .catch(err => toast.error(getApiErrorMessage(err, t('common.error')))) + .finally(() => setTripsLoading(false)) + // toast/t are stable enough for a mount effect; re-running on them would refetch on every render. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isOpen]) + + const pickTrip = async (chosen: Trip) => { + setTrip(chosen); setStep('places'); setItemsLoading(true); setQuery('') + try { + const data = await collectionsApi.importable(collectionId, chosen.id) + setItems(data.places) + // Unscheduled places are the point of the import, so they start selected. A trip + // where everything was scheduled would otherwise open with an empty selection, so + // fall back to every new place there. + const fresh = data.places.filter(p => !p.already_in_list) + const leftovers = fresh.filter(p => !p.scheduled) + setSelected(new Set((leftovers.length ? leftovers : fresh).map(p => p.place_id))) + } catch (err) { + toast.error(getApiErrorMessage(err, t('common.error'))) + setStep('trip') + } finally { + setItemsLoading(false) + } + } + + const newCount = useMemo(() => items.filter(p => !p.already_in_list).length, [items]) + const savedCount = items.length - newCount + + const visible = useMemo(() => { + const q = query.trim().toLowerCase() + return items + .filter(p => (onlyNew ? !p.already_in_list : true)) + .filter(p => !q || p.name.toLowerCase().includes(q) || (p.address ?? '').toLowerCase().includes(q)) + .sort((a, b) => importOrder(a) - importOrder(b) || a.name.localeCompare(b.name)) + }, [items, onlyNew, query]) + + const selectableVisible = visible.filter(p => !p.already_in_list) + const allVisibleSelected = selectableVisible.length > 0 && selectableVisible.every(p => selected.has(p.place_id)) + + const toggle = (id: number) => { + setSelected(prev => { + const next = new Set(prev) + if (!next.delete(id)) next.add(id) + return next + }) + } + + const toggleAllVisible = () => { + setSelected(prev => { + const next = new Set(prev) + if (allVisibleSelected) selectableVisible.forEach(p => next.delete(p.place_id)) + else selectableVisible.forEach(p => next.add(p.place_id)) + return next + }) + } + + const runImport = async () => { + if (!trip || selected.size === 0) return + setImporting(true) + try { + const res = await collectionsApi.saveFromTripMany(collectionId, trip.id, [...selected]) + setResult({ copied: res.copied, skipped: res.skipped.length }) + setStep('done') + onImported() + } catch (err) { + toast.error(getApiErrorMessage(err, t('common.error'))) + } finally { + setImporting(false) + } + } + + const categoryById = useMemo(() => new Map(categories.map(c => [c.id, c])), [categories]) + + const title = step === 'places' && trip + ? trip.title + : step === 'done' + ? t('collections.importDoneTitle') + : t('collections.importTitle') + + const footer = step === 'done' + ? ( +
+ +
+ ) + : step === 'places' + ? ( +
+ + {t('collections.importSelectedCount', { count: selected.size })} + +
+ + +
+
+ ) + : ( +
+ +
+ ) + + return ( + +
+ {/* ── Step 1: which trip ─────────────────────────────────────────── */} + {step === 'trip' && ( + <> +

{t('collections.importPickTripHint', { name: collectionName })}

+ {tripsLoading ? ( +
+ {[0, 1, 2, 3].map(i =>
)} +
+ ) : trips.length === 0 ? ( +
+ + {t('collections.importNoTrips')} +
+ ) : ( +
+ {trips.map(tr => { + const range = tripRange(tr, language) + const places = tr.place_count ?? 0 + return ( + + ) + })} +
+ )} + + )} + + {/* ── Step 2: which places ───────────────────────────────────────── */} + {step === 'places' && ( + <> +
+ +
+ + setQuery(e.target.value)} + placeholder={t('collections.importSearchPlaces')} + aria-label={t('collections.importSearchPlaces')} + /> +
+ {savedCount > 0 && ( + + )} + +
+ + {itemsLoading ? ( +
+ {[0, 1, 2, 3, 4].map(i =>
)} +
+ ) : items.length === 0 ? ( +
+ + {t('collections.importEmptyTrip')} +
+ ) : newCount === 0 ? ( +
+ + {t('collections.importNothingNew')} +
+ ) : ( +
+ {visible.map(p => { + const dup = p.already_in_list + const on = selected.has(p.place_id) + const cat = p.category_id != null ? categoryById.get(p.category_id) : undefined + const Icon = getCategoryIcon(cat?.icon ?? undefined) + return ( + + ) + })} +
+ )} + + )} + + {/* ── Step 3: what happened ──────────────────────────────────────── */} + {step === 'done' && result && ( +
+ + {t('collections.importDone', { count: result.copied })} + + {result.skipped > 0 + ? t('collections.importDoneSkipped', { count: result.skipped }) + : t('collections.importDoneClean', { name: collectionName })} + +
+ )} +
+ + ) +} diff --git a/client/src/pages/CollectionsPage.tsx b/client/src/pages/CollectionsPage.tsx index 5ac3015f9..89b3cc1b4 100644 --- a/client/src/pages/CollectionsPage.tsx +++ b/client/src/pages/CollectionsPage.tsx @@ -1,5 +1,5 @@ import React from 'react' -import { List as ListIcon, Map as MapIcon, Search, Bookmark, CheckCheck, X, Trash2, Copy, CopyPlus, FolderInput, Plus, Tags } from 'lucide-react' +import { List as ListIcon, Map as MapIcon, Search, Bookmark, CheckCheck, X, Trash2, Copy, CopyPlus, FolderInput, Plus, Tags, DownloadCloud } from 'lucide-react' import Navbar from '../components/Layout/Navbar' import Modal from '../components/shared/Modal' import ListsRail from '../components/Collections/ListsRail' @@ -12,6 +12,7 @@ import CopyToTripModal from '../components/Collections/CopyToTripModal' import MoveToListModal from '../components/Collections/MoveToListModal' import ShareCollectionModal from '../components/Collections/ShareCollectionModal' import AddPlaceToCollectionModal from '../components/Collections/AddPlaceToCollectionModal' +import ImportFromTripModal from '../components/Collections/ImportFromTripModal' import CollectionPlaceDetail from '../components/Collections/CollectionPlaceDetail' import LabelManager from '../components/Collections/LabelManager' import BulkAssignLabelModal from '../components/Collections/BulkAssignLabelModal' @@ -120,6 +121,8 @@ function CollectionsPageDesktop(): React.ReactElement { onLabelFilter={c.setLabelFilter} canManageLabels={canManageLabels} onManageLabels={() => c.setShowLabelManager(true)} + canImport={canAddPlace} + onImport={() => c.setShowImport(true)} showSelect={showSelect} selectMode={c.selectMode} onToggleSelect={() => c.setSelectMode(!c.selectMode)} @@ -143,9 +146,16 @@ function CollectionsPageDesktop(): React.ReactElement {
{canAddPlace && ( - +
+ + {/* An empty list is exactly where a trip import saves the most typing, + so it gets the same billing as adding one place by hand. */} + +
)}
) @@ -331,6 +341,19 @@ function CollectionsPageDesktop(): React.ReactElement { /> )} + {/* Bulk import of a trip's places into the current list */} + {typeof c.activeId === 'number' && c.activeCollection && ( + c.setShowImport(false)} + onImported={c.handlePlaceAdded} + t={t} + /> + )} + {/* Copy to trip */} (null) @@ -403,6 +404,7 @@ export function useCollections() { // local UI state editorTarget, setEditorTarget, handleEditorCreated, showAddPlace, setShowAddPlace, handlePlaceAdded, + showImport, setShowImport, confirmDeleteList, setConfirmDeleteList, mobileRailOpen, setMobileRailOpen, showShare, setShowShare, handleAfterLeave, diff --git a/client/src/styles/collections.css b/client/src/styles/collections.css index 3e8f9aeba..a58272f3d 100644 --- a/client/src/styles/collections.css +++ b/client/src/styles/collections.css @@ -300,6 +300,18 @@ .trek-dash .col-labelchip { display: inline-flex; align-items: center; gap: 6px; max-width: 180px; padding: 5px 9px; border-radius: 8px; font-size: 12.5px; font-weight: 600; white-space: nowrap; color: var(--ink-2); background: transparent; border: 1px solid transparent; transition: background .12s, border-color .12s, color .12s; } .trek-dash .col-labelchip:hover { background: var(--surface); color: var(--ink); } .trek-dash .col-labelchip .col-filter-lbl { overflow: hidden; text-overflow: ellipsis; } +/* The count sits next to a larger label, so as bare text it rides the baseline and + reads as misaligned. A pill gives it its own box and centres it against the name. */ +.trek-dash .col-labelchip .col-filter-count { + display: inline-flex; align-items: center; justify-content: center; flex-shrink: 0; + min-width: 18px; height: 18px; padding: 0 5px; border-radius: 999px; + background: color-mix(in oklch, var(--ink) 8%, transparent); + color: var(--ink-2); font-size: 10.5px; line-height: 1; +} +.trek-dash .col-labelchip.on .col-filter-count { + background: color-mix(in oklch, var(--label, var(--accent)) 22%, transparent); + color: inherit; +} .trek-dash .col-labelchip.on { color: var(--label, var(--accent)); background: color-mix(in oklch, var(--label, var(--accent)) 16%, transparent); border-color: color-mix(in oklch, var(--label, var(--accent)) 42%, transparent); } .trek-dash .col-labelchip.on .col-labelchip-dot { background: var(--label, var(--accent)); } .trek-dash .col-labelchip.static { cursor: default; color: var(--label, var(--accent)); background: color-mix(in oklch, var(--label, var(--accent)) 14%, transparent); border-color: transparent; } @@ -391,6 +403,13 @@ } .trek-dash .col-cta:hover { transform: translateY(-1px); box-shadow: var(--sh-lg); } .trek-dash .col-cta:active { transform: translateY(0); } +/* Second action beside the primary CTA — same shape, quieter, so an empty list can + offer both "add one place" and "import a whole trip" without competing. */ +.trek-dash .col-cta.col-cta-ghost { + background: var(--surface); color: var(--ink-2); border: 1px solid var(--line-2); + box-shadow: var(--sh-xs); +} +.trek-dash .col-cta.col-cta-ghost:hover { background: var(--surface-2); color: var(--ink); box-shadow: var(--sh-sm); } /* ----------------- place detail bottom sheet ----------------- */ .trek-dash .col-detail { @@ -541,3 +560,134 @@ .trek-dash .col-drawer-head button { width: 40px; height: 40px; display: grid; place-items: center; border-radius: var(--r-sm); color: var(--ink-2); } .trek-dash .col-drawer-head button:hover { background: var(--surface-2); color: var(--ink); } @media (max-width: 720px) { .trek-dash .col-drawer { padding-top: calc(16px + env(safe-area-inset-top, 0px)); } } + +/* ----------------- import-from-trip dialog ----------------- + Lives in the modal portal, i.e. OUTSIDE .trek-dash, so it reads the global + theme tokens directly instead of the dashboard's --ink/--surface aliases. */ +.col-imp { display: flex; flex-direction: column; gap: 14px; } +.col-imp-lead { font-size: 13px; line-height: 1.5; color: var(--text-secondary); margin: -2px 0 2px; } + +/* step 1 — trips */ +.col-imp-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; } +@media (max-width: 640px) { .col-imp-grid { grid-template-columns: 1fr; } } +.col-imp-trip { + display: flex; align-items: center; gap: 11px; width: 100%; padding: 10px 12px 10px 10px; + border: 1px solid var(--border-primary); border-radius: 14px; background: var(--bg-card); + text-align: left; cursor: pointer; + transition: border-color .14s ease, background .14s ease, transform .14s ease, box-shadow .14s ease; +} +.col-imp-trip:hover { + border-color: var(--accent); background: var(--bg-hover); + transform: translateY(-1px); box-shadow: var(--shadow-md); +} +.col-imp-trip-cover { + width: 42px; height: 42px; flex: none; border-radius: 11px; overflow: hidden; + background: var(--bg-tertiary); color: var(--text-faint); + display: grid; place-items: center; +} +.col-imp-trip-cover img { width: 100%; height: 100%; object-fit: cover; display: block; transition: transform .3s cubic-bezier(0.23, 1, 0.32, 1); } +.col-imp-trip:hover .col-imp-trip-cover img { transform: scale(1.06); } +.col-imp-trip-body { display: flex; flex-direction: column; min-width: 0; flex: 1; gap: 5px; } +.col-imp-trip-name { font-size: 13.5px; font-weight: 650; color: var(--text-primary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.col-imp-trip-meta { display: flex; align-items: center; flex-wrap: wrap; gap: 4px; min-width: 0; } +/* Each fact is its own pill so a narrow tile wraps between them instead of + clipping a word. The date pill yields first; the count never shrinks. */ +.col-imp-pill { + display: inline-flex; align-items: center; gap: 4px; min-width: 0; max-width: 100%; + padding: 2px 7px; border-radius: 999px; background: var(--bg-tertiary); + font-size: 10.5px; font-weight: 600; color: var(--text-muted); + font-variant-numeric: tabular-nums; line-height: 1.45; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +.col-imp-pill svg { flex: none; opacity: .75; } +.col-imp-pill.num { flex: none; } +.col-imp-trip-go { color: var(--text-faint); flex: none; transition: transform .14s ease, color .14s ease; } +.col-imp-trip:hover .col-imp-trip-go { transform: translateX(2px); color: var(--text-secondary); } + +/* step 2 — toolbar */ +.col-imp-bar { display: flex; align-items: center; gap: 7px; flex-wrap: wrap; } +.col-imp-back { + width: 30px; height: 30px; flex: none; display: grid; place-items: center; border-radius: 9px; + border: 1px solid var(--border-primary); color: var(--text-secondary); background: var(--bg-card); + transition: background .14s ease, color .14s ease; +} +.col-imp-back:hover { background: var(--bg-hover); color: var(--text-primary); } +.col-imp-search { + position: relative; display: flex; align-items: center; gap: 7px; flex: 1; min-width: 150px; + padding: 0 10px; height: 30px; border-radius: 9px; + border: 1px solid var(--border-primary); background: var(--bg-input); color: var(--text-faint); + transition: border-color .14s ease; +} +.col-imp-search:focus-within { border-color: var(--accent); } +.col-imp-search input { + flex: 1; min-width: 0; background: none; border: 0; outline: none; + font-size: 12.5px; color: var(--text-primary); +} +.col-imp-chip { + display: inline-flex; align-items: center; gap: 5px; height: 30px; padding: 0 11px; flex: none; + border-radius: 999px; border: 1px solid var(--border-primary); background: var(--bg-card); + font-size: 11.5px; font-weight: 600; color: var(--text-secondary); cursor: pointer; + transition: background .14s ease, color .14s ease, border-color .14s ease; +} +.col-imp-chip:hover:not(:disabled) { background: var(--bg-hover); color: var(--text-primary); } +.col-imp-chip.on { background: var(--accent-subtle); border-color: var(--accent); color: var(--accent-on); } +.col-imp-chip:disabled { opacity: .45; cursor: default; } + +/* step 2 — rows */ +.col-imp-list { display: flex; flex-direction: column; gap: 5px; max-height: 46vh; overflow-y: auto; padding: 1px; margin: -1px; } +.col-imp-row { + display: flex; align-items: center; gap: 10px; width: 100%; padding: 9px 11px; + border: 1px solid var(--border-primary); border-radius: 12px; background: var(--bg-card); + text-align: left; cursor: pointer; + transition: border-color .14s ease, background .14s ease; +} +.col-imp-row:hover:not(.dup) { background: var(--bg-hover); } +.col-imp-row.on { border-color: var(--accent); background: var(--accent-subtle); } +.col-imp-row.dup { opacity: .5; cursor: default; } +.col-imp-box { + width: 18px; height: 18px; flex: none; border-radius: 6px; display: grid; place-items: center; + border: 1.5px solid var(--border-secondary); color: var(--accent-text); background: var(--bg-input); + transition: background .14s ease, border-color .14s ease; +} +.col-imp-row.on .col-imp-box { background: var(--accent); border-color: var(--accent); } +.col-imp-row.dup .col-imp-box { visibility: hidden; } +.col-imp-ico { + width: 28px; height: 28px; flex: none; border-radius: 9px; display: grid; place-items: center; + background: var(--bg-tertiary); color: var(--text-muted); +} +.col-imp-txt { display: flex; flex-direction: column; min-width: 0; flex: 1; gap: 1px; } +.col-imp-txt .nm { font-size: 13px; font-weight: 600; color: var(--text-primary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.col-imp-txt .ad { font-size: 11px; color: var(--text-faint); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.col-imp-tag { + flex: none; padding: 3px 9px; border-radius: 999px; font-size: 10.5px; font-weight: 650; + background: var(--bg-tertiary); color: var(--text-muted); white-space: nowrap; + font-variant-numeric: tabular-nums; +} +.col-imp-tag.fresh { background: var(--accent-subtle); color: var(--accent-on); } +.col-imp-tag.saved { background: transparent; color: var(--text-faint); } + +/* empty + done */ +.col-imp-empty { + display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 9px; + padding: 34px 16px; border: 1px dashed var(--border-primary); border-radius: 14px; + color: var(--text-faint); font-size: 12.5px; text-align: center; +} +.col-imp-empty-good { color: var(--success); border-color: var(--success); background: var(--success-soft); } +.col-imp-done { display: flex; flex-direction: column; align-items: center; gap: 9px; padding: 26px 16px 20px; text-align: center; } +@keyframes col-imp-pop { + 0% { opacity: 0; transform: scale(0.6); } + 60% { opacity: 1; transform: scale(1.06); } + 100% { opacity: 1; transform: scale(1); } +} +.col-imp-done-ring { + width: 60px; height: 60px; border-radius: 50%; display: grid; place-items: center; + background: var(--success-soft); color: var(--success); + animation: col-imp-pop 420ms cubic-bezier(0.23, 1, 0.32, 1) both; +} +.col-imp-done-t { font-size: 15px; font-weight: 700; color: var(--text-primary); } +.col-imp-done-s { font-size: 12.5px; color: var(--text-faint); max-width: 320px; line-height: 1.5; } +@media (prefers-reduced-motion: reduce) { + .col-imp-done-ring { animation: none; } + .col-imp-trip:hover { transform: none; } + .col-imp-trip:hover .col-imp-trip-cover img { transform: none; } +} diff --git a/server/src/nest/collections/collections.controller.ts b/server/src/nest/collections/collections.controller.ts index 5d8733bda..b29306ad8 100644 --- a/server/src/nest/collections/collections.controller.ts +++ b/server/src/nest/collections/collections.controller.ts @@ -356,6 +356,14 @@ export class CollectionsController { return this.collections.setCollectionCover(user.id, Number(id), coverUrl, socketId); } + /** Preview for the bulk trip import: the trip's places plus, per place, the same + * duplicate verdict the import itself applies. Read-only, so the dialog can grey out + * what would be skipped instead of reporting it afterwards. */ + @Get(':id/importable/:tripId') + importable(@CurrentUser() user: User, @Param('id') id: string, @Param('tripId') tripId: string) { + return this.collections.importablePlaces(user.id, Number(id), Number(tripId)); + } + @Get(':id') get(@CurrentUser() user: User, @Param('id') id: string) { return this.collections.getCollection(user.id, Number(id)); diff --git a/server/src/nest/collections/collections.service.ts b/server/src/nest/collections/collections.service.ts index 78824f719..5eca70e7c 100644 --- a/server/src/nest/collections/collections.service.ts +++ b/server/src/nest/collections/collections.service.ts @@ -21,6 +21,7 @@ import type { CollectionPlaceUpdateRequest, CollectionStatus, CollectionLabel, + CollectionImportablesResponse, } from '@trek/shared'; import { NotificationsService } from '../notifications/notifications.service'; @@ -567,6 +568,46 @@ export class CollectionsService { }, socketId); } + /** The trip's places as offered to the bulk import, each already carrying the verdict + * saveFromTripPlaces would reach for it. Reusing findDuplicateCollectionPlace is the + * whole point: a row the dialog shows as new can never come back as `skipped`, and a + * greyed-out one is exactly a row the import would refuse. Re-deriving that rule in the + * client would be a second copy of it, free to drift. + * + * `scheduled` is false for places no day holds. Those are what a trip leaves behind and + * what this import exists for, so the dialog pre-selects them. */ + importablePlaces(userId: number, collectionId: number, tripId: number): CollectionImportablesResponse { + this.assertCanEdit(userId, collectionId); + if (!this.db.canAccessTrip(tripId, userId)) httpError(404, 'Trip not found'); + + // One row per place: a place can sit on several days, so the day columns resolve to the + // earliest one rather than multiplying the place out across its assignments. + const rows = this.db.all<{ + place_id: number; name: string; address: string | null; lat: number | null; lng: number | null; + category_id: number | null; image_url: string | null; day_number: number | null; date: string | null; + }>(` + SELECT p.id AS place_id, p.name, p.address, p.lat, p.lng, p.category_id, p.image_url, + (SELECT MIN(d.day_number) FROM day_assignments da + JOIN days d ON d.id = da.day_id + WHERE da.place_id = p.id AND d.trip_id = p.trip_id) AS day_number, + (SELECT d.date FROM day_assignments da + JOIN days d ON d.id = da.day_id + WHERE da.place_id = p.id AND d.trip_id = p.trip_id + ORDER BY d.day_number ASC LIMIT 1) AS date + FROM places p + WHERE p.trip_id = ? + ORDER BY p.name COLLATE NOCASE + `, tripId); + + return { + places: rows.map(r => ({ + ...r, + already_in_list: this.findDuplicateCollectionPlace(collectionId, { name: r.name, lat: r.lat, lng: r.lng }) != null, + scheduled: r.day_number != null, + })), + }; + } + /** Bulk copy of several trip places into a list in one shot — one access check, * one WS notify (vs saving each place individually). Mirrors saveFromTripPlace's * field mapping + dedup; skips duplicates unless force. Status starts at 'idea'. */ diff --git a/server/tests/e2e/collections.e2e.test.ts b/server/tests/e2e/collections.e2e.test.ts index dce1138e0..bf3e7b1a4 100644 --- a/server/tests/e2e/collections.e2e.test.ts +++ b/server/tests/e2e/collections.e2e.test.ts @@ -37,7 +37,7 @@ vi.mock('../../src/websocket', () => ({ broadcastToUser: vi.fn(), broadcast: vi. import { createTables } from '../../src/db/schema'; import { runMigrations } from '../../src/db/migrations'; -import { createUser, createTrip, createCategory } from '../helpers/factories'; +import { createUser, createTrip, createCategory, createDay, createPlace, createDayAssignment } from '../helpers/factories'; import { CollectionsModule } from '../../src/nest/collections/collections.module'; import { DatabaseModule } from '../../src/nest/database/database.module'; import { RealtimeModule } from '../../src/nest/realtime/realtime.module'; @@ -248,6 +248,57 @@ describe('Collections e2e (real auth guard + real service + temp SQLite)', () => expect(res.status).toBe(404); }); + // ── import preview ─────────────────────────────────────────────────────── + it('COLLECTIONS-E2E-070: the preview marks scheduled places and carries the day they sit on', async () => { + const col = (await request(server).post('/api/addons/collections').set('Cookie', sessionCookie(ownerId)).send({ name: 'Rome ideas' })).body; + const trip = createTrip(db as never, ownerId); + const day = createDay(db as never, trip.id, { day_number: 2, date: '2026-05-02' }); + const planned = createPlace(db as never, trip.id, { name: 'Colosseum', lat: 41.89, lng: 12.49 }); + createPlace(db as never, trip.id, { name: 'Testaccio Market', lat: 41.87, lng: 12.47 }); + createDayAssignment(db as never, day.id, planned.id); + + const res = await request(server).get(`/api/addons/collections/${col.id}/importable/${trip.id}`).set('Cookie', sessionCookie(ownerId)); + expect(res.status).toBe(200); + + const byName = Object.fromEntries(res.body.places.map((p: { name: string }) => [p.name, p])); + expect(byName['Colosseum'].scheduled).toBe(true); + expect(byName['Colosseum'].day_number).toBe(2); + expect(byName['Colosseum'].date).toBe('2026-05-02'); + // The one no day holds — what the import is for, so the dialog can pre-select it. + expect(byName['Testaccio Market'].scheduled).toBe(false); + expect(byName['Testaccio Market'].day_number).toBeNull(); + }); + + it('COLLECTIONS-E2E-071: the preview verdict is the one the import then acts on', async () => { + const col = (await request(server).post('/api/addons/collections').set('Cookie', sessionCookie(ownerId)).send({ name: 'Paris ideas' })).body; + const trip = createTrip(db as never, ownerId); + const a = createPlace(db as never, trip.id, { name: 'Musée Rodin', lat: 48.85, lng: 2.31 }); + const b = createPlace(db as never, trip.id, { name: 'Rue Cler', lat: 48.85, lng: 2.30 }); + + // Save one of them first, so the list already holds it. + await request(server).post('/api/addons/collections/places') + .set('Cookie', sessionCookie(ownerId)).send({ collection_id: col.id, name: 'Musée Rodin', lat: 48.85, lng: 2.31 }); + + const preview = await request(server).get(`/api/addons/collections/${col.id}/importable/${trip.id}`).set('Cookie', sessionCookie(ownerId)); + const flagged = preview.body.places.filter((p: { already_in_list: boolean }) => p.already_in_list).map((p: { name: string }) => p.name); + expect(flagged).toEqual(['Musée Rodin']); + + // Importing both must skip exactly what the preview greyed out, and copy the rest. + const imported = await request(server).post('/api/addons/collections/places/from-trip-many') + .set('Cookie', sessionCookie(ownerId)).send({ collection_id: col.id, source_trip_id: trip.id, source_place_ids: [a.id, b.id] }); + expect(imported.status).toBe(200); + expect(imported.body.copied).toBe(1); + expect(imported.body.skipped.map((s: { name: string }) => s.name)).toEqual(['Musée Rodin']); + }); + + it('COLLECTIONS-E2E-072: a trip the caller cannot see is a 404, and so is a foreign list', async () => { + const col = (await request(server).post('/api/addons/collections').set('Cookie', sessionCookie(ownerId)).send({ name: 'Private' })).body; + const foreignTrip = createTrip(db as never, otherId); + + expect((await request(server).get(`/api/addons/collections/${col.id}/importable/${foreignTrip.id}`).set('Cookie', sessionCookie(ownerId))).status).toBe(404); + expect((await request(server).get(`/api/addons/collections/${col.id}/importable/${tripId}`).set('Cookie', sessionCookie(otherId))).status).toBe(404); + }); + // ── delete ─────────────────────────────────────────────────────────────── it('COLLECTIONS-E2E-050: owner deletes; non-owner member cannot (403)', async () => { const col = (await request(server).post('/api/addons/collections').set('Cookie', sessionCookie(ownerId)).send({ name: 'Doomed' })).body; diff --git a/shared/src/collection/collection.schema.ts b/shared/src/collection/collection.schema.ts index a8e9623e0..51d073dbb 100644 --- a/shared/src/collection/collection.schema.ts +++ b/shared/src/collection/collection.schema.ts @@ -314,3 +314,28 @@ export const collectionMembershipSchema = z.object({ lists: z.array(z.object({ collection_id: z.number(), name: z.string(), place_id: z.number() })), }); export type CollectionMembership = z.infer; + +/** One trip place as offered by the import preview. `already_in_list` is the SAME dedup + * verdict the bulk import applies (name, or coordinates within tolerance), resolved on the + * server so the dialog can never disagree with what the write path then does. `scheduled` + * is false for a place no day holds — the ones a trip left behind, which is exactly what + * the import is for, so the dialog pre-selects them. */ +export const collectionImportablePlaceSchema = z.object({ + place_id: z.number(), + name: z.string(), + address: z.string().nullable(), + lat: z.number().nullable(), + lng: z.number().nullable(), + category_id: z.number().nullable(), + image_url: z.string().nullable(), + already_in_list: z.boolean(), + scheduled: z.boolean(), + day_number: z.number().nullable(), + date: z.string().nullable(), +}); +export type CollectionImportablePlace = z.infer; + +export const collectionImportablesResponseSchema = z.object({ + places: z.array(collectionImportablePlaceSchema), +}); +export type CollectionImportablesResponse = z.infer; diff --git a/shared/src/i18n/ar/collection.ts b/shared/src/i18n/ar/collection.ts index 039bfbd78..6b5abfacc 100644 --- a/shared/src/i18n/ar/collection.ts +++ b/shared/src/i18n/ar/collection.ts @@ -36,6 +36,26 @@ const collection: TranslationStrings = { 'collections.addPlace': 'إضافة مكان', 'collections.addPlaceSearch': 'ابحث عن مكان…', 'collections.addPlaceHint': 'ابحث لإضافة مكان إلى هذه القائمة', + 'collections.importFromTrip': 'استيراد من رحلة', + 'collections.importTitle': 'استيراد من رحلة', + 'collections.importDoneTitle': 'اكتمل الاستيراد', + 'collections.importPickTripHint': 'اختر رحلة وانقل أماكنها إلى {name}.', + 'collections.importNoTrips': 'لا توجد رحلات بعد', + 'collections.importPlacesCount': '{count} أماكن', + 'collections.importSearchPlaces': 'البحث عن أماكن…', + 'collections.importOnlyNew': 'الجديد فقط', + 'collections.importSelectAll': 'تحديد الكل', + 'collections.importClearSelection': 'إلغاء التحديد', + 'collections.importAlreadySaved': 'محفوظ بالفعل', + 'collections.importUnscheduled': 'بدون يوم', + 'collections.importOnDay': 'اليوم {count}', + 'collections.importSelectedCount': 'تم تحديد {count}', + 'collections.importAction': 'استيراد {count}', + 'collections.importEmptyTrip': 'لا تحتوي هذه الرحلة على أماكن بعد', + 'collections.importNothingNew': 'كل شيء من هذه الرحلة موجود بالفعل في القائمة', + 'collections.importDone': 'تمت إضافة {count} أماكن', + 'collections.importDoneSkipped': '{count} كانت موجودة بالفعل وبقيت كما هي', + 'collections.importDoneClean': 'جميعها الآن في {name}', 'collections.sort.default': 'افتراضي', 'collections.sort.nameAsc': 'الاسم (أ–ي)', 'collections.coordinates': 'الإحداثيات', diff --git a/shared/src/i18n/br/collection.ts b/shared/src/i18n/br/collection.ts index aa2dcf0bb..92b560ab1 100644 --- a/shared/src/i18n/br/collection.ts +++ b/shared/src/i18n/br/collection.ts @@ -36,6 +36,26 @@ const collection: TranslationStrings = { 'collections.addPlace': 'Adicionar um lugar', 'collections.addPlaceSearch': 'Pesquisar um lugar…', 'collections.addPlaceHint': 'Pesquise para adicionar um lugar a esta lista', + 'collections.importFromTrip': 'Importar de uma viagem', + 'collections.importTitle': 'Importar de uma viagem', + 'collections.importDoneTitle': 'Importação concluída', + 'collections.importPickTripHint': 'Escolha uma viagem e leve os lugares dela para {name}.', + 'collections.importNoTrips': 'Nenhuma viagem ainda', + 'collections.importPlacesCount': '{count} lugares', + 'collections.importSearchPlaces': 'Buscar lugares…', + 'collections.importOnlyNew': 'Apenas novos', + 'collections.importSelectAll': 'Selecionar tudo', + 'collections.importClearSelection': 'Limpar seleção', + 'collections.importAlreadySaved': 'Já salvo', + 'collections.importUnscheduled': 'Sem dia', + 'collections.importOnDay': 'Dia {count}', + 'collections.importSelectedCount': '{count} selecionados', + 'collections.importAction': 'Importar {count}', + 'collections.importEmptyTrip': 'Esta viagem ainda não tem lugares', + 'collections.importNothingNew': 'Tudo desta viagem já está nesta lista', + 'collections.importDone': '{count} lugares adicionados', + 'collections.importDoneSkipped': '{count} já estavam na lista e ficaram como estavam', + 'collections.importDoneClean': 'Agora estão todos em {name}', 'collections.sort.default': 'Padrão', 'collections.sort.nameAsc': 'Nome (A–Z)', 'collections.coordinates': 'Coordenadas', diff --git a/shared/src/i18n/ca/collection.ts b/shared/src/i18n/ca/collection.ts index bb3776651..54317730d 100644 --- a/shared/src/i18n/ca/collection.ts +++ b/shared/src/i18n/ca/collection.ts @@ -36,6 +36,26 @@ const collection: TranslationStrings = { 'collections.addPlace': 'Afegir un lloc', 'collections.addPlaceSearch': 'Cercar un lloc…', 'collections.addPlaceHint': 'Cerca per afegir un lloc a aquesta llista', + 'collections.importFromTrip': 'Importa des d’un viatge', + 'collections.importTitle': 'Importa des d’un viatge', + 'collections.importDoneTitle': 'Importació acabada', + 'collections.importPickTripHint': 'Tria un viatge i porta’n els llocs a {name}.', + 'collections.importNoTrips': 'Encara no hi ha viatges', + 'collections.importPlacesCount': '{count} llocs', + 'collections.importSearchPlaces': 'Cerca llocs…', + 'collections.importOnlyNew': 'Només nous', + 'collections.importSelectAll': 'Selecciona-ho tot', + 'collections.importClearSelection': 'Esborra la selecció', + 'collections.importAlreadySaved': 'Ja desat', + 'collections.importUnscheduled': 'Sense dia', + 'collections.importOnDay': 'Dia {count}', + 'collections.importSelectedCount': '{count} seleccionats', + 'collections.importAction': 'Importa {count}', + 'collections.importEmptyTrip': 'Aquest viatge encara no té llocs', + 'collections.importNothingNew': 'Tot aquest viatge ja és en aquesta llista', + 'collections.importDone': 'S’han afegit {count} llocs', + 'collections.importDoneSkipped': '{count} ja hi eren i s’han deixat igual', + 'collections.importDoneClean': 'Ara són tots a {name}', 'collections.sort.default': 'Predeterminat', 'collections.sort.nameAsc': 'Nom (A–Z)', 'collections.coordinates': 'Coordenades', diff --git a/shared/src/i18n/cs/collection.ts b/shared/src/i18n/cs/collection.ts index d66b07801..1912051c0 100644 --- a/shared/src/i18n/cs/collection.ts +++ b/shared/src/i18n/cs/collection.ts @@ -36,6 +36,26 @@ const collection: TranslationStrings = { 'collections.addPlace': 'Přidat místo', 'collections.addPlaceSearch': 'Hledat místo…', 'collections.addPlaceHint': 'Vyhledejte a přidejte místo do tohoto seznamu', + 'collections.importFromTrip': 'Importovat z cesty', + 'collections.importTitle': 'Importovat z cesty', + 'collections.importDoneTitle': 'Import dokončen', + 'collections.importPickTripHint': 'Vyber cestu a přenes její místa do {name}.', + 'collections.importNoTrips': 'Zatím žádné cesty', + 'collections.importPlacesCount': '{count} míst', + 'collections.importSearchPlaces': 'Hledat místa…', + 'collections.importOnlyNew': 'Jen nová', + 'collections.importSelectAll': 'Vybrat vše', + 'collections.importClearSelection': 'Zrušit výběr', + 'collections.importAlreadySaved': 'Již uloženo', + 'collections.importUnscheduled': 'Bez dne', + 'collections.importOnDay': 'Den {count}', + 'collections.importSelectedCount': 'Vybráno: {count}', + 'collections.importAction': 'Importovat {count}', + 'collections.importEmptyTrip': 'Tato cesta zatím nemá žádná místa', + 'collections.importNothingNew': 'Vše z této cesty už na seznamu je', + 'collections.importDone': 'Přidáno {count} míst', + 'collections.importDoneSkipped': '{count} už na seznamu bylo a zůstalo beze změny', + 'collections.importDoneClean': 'Teď jsou všechna v {name}', 'collections.sort.default': 'Výchozí', 'collections.sort.nameAsc': 'Název (A–Z)', 'collections.coordinates': 'Souřadnice', diff --git a/shared/src/i18n/de/collection.ts b/shared/src/i18n/de/collection.ts index 6eaa91808..baf898b49 100644 --- a/shared/src/i18n/de/collection.ts +++ b/shared/src/i18n/de/collection.ts @@ -36,6 +36,26 @@ const collection: TranslationStrings = { 'collections.addPlace': 'Ort hinzufügen', 'collections.addPlaceSearch': 'Nach einem Ort suchen…', 'collections.addPlaceHint': 'Suchen, um einen Ort zur Liste hinzuzufügen', + 'collections.importFromTrip': 'Aus einer Reise übernehmen', + 'collections.importTitle': 'Aus einer Reise übernehmen', + 'collections.importDoneTitle': 'Übernahme abgeschlossen', + 'collections.importPickTripHint': 'Wähle eine Reise und übernimm ihre Orte in {name}.', + 'collections.importNoTrips': 'Noch keine Reisen', + 'collections.importPlacesCount': '{count} Orte', + 'collections.importSearchPlaces': 'Orte durchsuchen…', + 'collections.importOnlyNew': 'Nur neue', + 'collections.importSelectAll': 'Alle auswählen', + 'collections.importClearSelection': 'Auswahl aufheben', + 'collections.importAlreadySaved': 'Schon gespeichert', + 'collections.importUnscheduled': 'Ohne Tag', + 'collections.importOnDay': 'Tag {count}', + 'collections.importSelectedCount': '{count} ausgewählt', + 'collections.importAction': '{count} übernehmen', + 'collections.importEmptyTrip': 'Diese Reise hat noch keine Orte', + 'collections.importNothingNew': 'Alles aus dieser Reise steht schon auf der Liste', + 'collections.importDone': '{count} Orte hinzugefügt', + 'collections.importDoneSkipped': '{count} standen schon auf der Liste und blieben unverändert', + 'collections.importDoneClean': 'Sie stehen jetzt alle in {name}', 'collections.sort.default': 'Standard', 'collections.sort.nameAsc': 'Name (A–Z)', 'collections.coordinates': 'Koordinaten', diff --git a/shared/src/i18n/en/collection.ts b/shared/src/i18n/en/collection.ts index 231cf61b3..564eb8ba2 100644 --- a/shared/src/i18n/en/collection.ts +++ b/shared/src/i18n/en/collection.ts @@ -36,6 +36,26 @@ const collection: TranslationStrings = { 'collections.addPlace': 'Add a place', 'collections.addPlaceSearch': 'Search for a place…', 'collections.addPlaceHint': 'Search to add a place to this list', + 'collections.importFromTrip': 'Import from a trip', + 'collections.importTitle': 'Import from a trip', + 'collections.importDoneTitle': 'Import finished', + 'collections.importPickTripHint': 'Pick a trip and take its places into {name}.', + 'collections.importNoTrips': 'No trips yet', + 'collections.importPlacesCount': '{count} places', + 'collections.importSearchPlaces': 'Search places…', + 'collections.importOnlyNew': 'Only new', + 'collections.importSelectAll': 'Select all', + 'collections.importClearSelection': 'Clear selection', + 'collections.importAlreadySaved': 'Already saved', + 'collections.importUnscheduled': 'Unscheduled', + 'collections.importOnDay': 'Day {count}', + 'collections.importSelectedCount': '{count} selected', + 'collections.importAction': 'Import {count}', + 'collections.importEmptyTrip': 'This trip has no places yet', + 'collections.importNothingNew': 'Everything from this trip is already on this list', + 'collections.importDone': '{count} places added', + 'collections.importDoneSkipped': '{count} were already on the list and stayed as they were', + 'collections.importDoneClean': 'They are all in {name} now', 'collections.sort.default': 'Default', 'collections.sort.nameAsc': 'Name (A–Z)', 'collections.coordinates': 'Coordinates', diff --git a/shared/src/i18n/es/collection.ts b/shared/src/i18n/es/collection.ts index fbea281d0..a258fe95f 100644 --- a/shared/src/i18n/es/collection.ts +++ b/shared/src/i18n/es/collection.ts @@ -36,6 +36,26 @@ const collection: TranslationStrings = { 'collections.addPlace': 'Añadir un lugar', 'collections.addPlaceSearch': 'Buscar un lugar…', 'collections.addPlaceHint': 'Busca para añadir un lugar a esta lista', + 'collections.importFromTrip': 'Importar de un viaje', + 'collections.importTitle': 'Importar de un viaje', + 'collections.importDoneTitle': 'Importación terminada', + 'collections.importPickTripHint': 'Elige un viaje y lleva sus lugares a {name}.', + 'collections.importNoTrips': 'Aún no hay viajes', + 'collections.importPlacesCount': '{count} lugares', + 'collections.importSearchPlaces': 'Buscar lugares…', + 'collections.importOnlyNew': 'Solo nuevos', + 'collections.importSelectAll': 'Seleccionar todo', + 'collections.importClearSelection': 'Quitar selección', + 'collections.importAlreadySaved': 'Ya guardado', + 'collections.importUnscheduled': 'Sin día', + 'collections.importOnDay': 'Día {count}', + 'collections.importSelectedCount': '{count} seleccionados', + 'collections.importAction': 'Importar {count}', + 'collections.importEmptyTrip': 'Este viaje aún no tiene lugares', + 'collections.importNothingNew': 'Todo este viaje ya está en la lista', + 'collections.importDone': 'Se añadieron {count} lugares', + 'collections.importDoneSkipped': '{count} ya estaban en la lista y se quedaron igual', + 'collections.importDoneClean': 'Ahora están todos en {name}', 'collections.sort.default': 'Predeterminado', 'collections.sort.nameAsc': 'Nombre (A–Z)', 'collections.coordinates': 'Coordenadas', diff --git a/shared/src/i18n/fr/collection.ts b/shared/src/i18n/fr/collection.ts index 6da82be31..2d217b3e1 100644 --- a/shared/src/i18n/fr/collection.ts +++ b/shared/src/i18n/fr/collection.ts @@ -36,6 +36,26 @@ const collection: TranslationStrings = { 'collections.addPlace': 'Ajouter un lieu', 'collections.addPlaceSearch': 'Rechercher un lieu…', 'collections.addPlaceHint': 'Recherchez pour ajouter un lieu à cette liste', + 'collections.importFromTrip': 'Importer depuis un voyage', + 'collections.importTitle': 'Importer depuis un voyage', + 'collections.importDoneTitle': 'Import terminé', + 'collections.importPickTripHint': 'Choisissez un voyage et récupérez ses lieux dans {name}.', + 'collections.importNoTrips': 'Aucun voyage pour l’instant', + 'collections.importPlacesCount': '{count} lieux', + 'collections.importSearchPlaces': 'Rechercher des lieux…', + 'collections.importOnlyNew': 'Nouveaux seulement', + 'collections.importSelectAll': 'Tout sélectionner', + 'collections.importClearSelection': 'Effacer la sélection', + 'collections.importAlreadySaved': 'Déjà enregistré', + 'collections.importUnscheduled': 'Sans jour', + 'collections.importOnDay': 'Jour {count}', + 'collections.importSelectedCount': '{count} sélectionnés', + 'collections.importAction': 'Importer {count}', + 'collections.importEmptyTrip': 'Ce voyage n’a pas encore de lieux', + 'collections.importNothingNew': 'Tout ce voyage figure déjà dans cette liste', + 'collections.importDone': '{count} lieux ajoutés', + 'collections.importDoneSkipped': '{count} y étaient déjà et n’ont pas bougé', + 'collections.importDoneClean': 'Ils sont tous dans {name} maintenant', 'collections.sort.default': 'Par défaut', 'collections.sort.nameAsc': 'Nom (A–Z)', 'collections.coordinates': 'Coordonnées', diff --git a/shared/src/i18n/gr/collection.ts b/shared/src/i18n/gr/collection.ts index 20197ce61..85623436d 100644 --- a/shared/src/i18n/gr/collection.ts +++ b/shared/src/i18n/gr/collection.ts @@ -36,6 +36,26 @@ const collection: TranslationStrings = { 'collections.addPlace': 'Προσθήκη τόπου', 'collections.addPlaceSearch': 'Αναζήτηση τόπου…', 'collections.addPlaceHint': 'Αναζητήστε για να προσθέσετε έναν τόπο σε αυτήν τη λίστα', + 'collections.importFromTrip': 'Εισαγωγή από ταξίδι', + 'collections.importTitle': 'Εισαγωγή από ταξίδι', + 'collections.importDoneTitle': 'Η εισαγωγή ολοκληρώθηκε', + 'collections.importPickTripHint': 'Διάλεξε ένα ταξίδι και πέρασε τα μέρη του στη λίστα {name}.', + 'collections.importNoTrips': 'Δεν υπάρχουν ακόμη ταξίδια', + 'collections.importPlacesCount': '{count} μέρη', + 'collections.importSearchPlaces': 'Αναζήτηση μερών…', + 'collections.importOnlyNew': 'Μόνο νέα', + 'collections.importSelectAll': 'Επιλογή όλων', + 'collections.importClearSelection': 'Καθαρισμός επιλογής', + 'collections.importAlreadySaved': 'Ήδη αποθηκευμένο', + 'collections.importUnscheduled': 'Χωρίς ημέρα', + 'collections.importOnDay': 'Ημέρα {count}', + 'collections.importSelectedCount': 'Επιλέχθηκαν {count}', + 'collections.importAction': 'Εισαγωγή {count}', + 'collections.importEmptyTrip': 'Αυτό το ταξίδι δεν έχει ακόμη μέρη', + 'collections.importNothingNew': 'Όλα από αυτό το ταξίδι είναι ήδη στη λίστα', + 'collections.importDone': 'Προστέθηκαν {count} μέρη', + 'collections.importDoneSkipped': '{count} ήταν ήδη στη λίστα και έμειναν ως είχαν', + 'collections.importDoneClean': 'Είναι όλα τώρα στη λίστα {name}', 'collections.sort.default': 'Προεπιλογή', 'collections.sort.nameAsc': 'Όνομα (Α–Ω)', 'collections.coordinates': 'Συντεταγμένες', diff --git a/shared/src/i18n/hu/collection.ts b/shared/src/i18n/hu/collection.ts index 3c984aa04..ba70ddda7 100644 --- a/shared/src/i18n/hu/collection.ts +++ b/shared/src/i18n/hu/collection.ts @@ -36,6 +36,26 @@ const collection: TranslationStrings = { 'collections.addPlace': 'Hely hozzáadása', 'collections.addPlaceSearch': 'Hely keresése…', 'collections.addPlaceHint': 'Keressen, hogy helyet adjon a listához', + 'collections.importFromTrip': 'Importálás egy utazásból', + 'collections.importTitle': 'Importálás egy utazásból', + 'collections.importDoneTitle': 'Az importálás kész', + 'collections.importPickTripHint': 'Válassz egy utazást, és vedd át a helyeit ide: {name}.', + 'collections.importNoTrips': 'Még nincs utazás', + 'collections.importPlacesCount': '{count} hely', + 'collections.importSearchPlaces': 'Helyek keresése…', + 'collections.importOnlyNew': 'Csak az újak', + 'collections.importSelectAll': 'Összes kijelölése', + 'collections.importClearSelection': 'Kijelölés törlése', + 'collections.importAlreadySaved': 'Már mentve', + 'collections.importUnscheduled': 'Nap nélkül', + 'collections.importOnDay': '{count}. nap', + 'collections.importSelectedCount': '{count} kijelölve', + 'collections.importAction': '{count} importálása', + 'collections.importEmptyTrip': 'Ennek az utazásnak még nincsenek helyei', + 'collections.importNothingNew': 'Ebből az utazásból már minden rajta van a listán', + 'collections.importDone': '{count} hely hozzáadva', + 'collections.importDoneSkipped': '{count} már a listán volt, és változatlan maradt', + 'collections.importDoneClean': 'Most már mind itt van: {name}', 'collections.sort.default': 'Alapértelmezett', 'collections.sort.nameAsc': 'Név (A–Z)', 'collections.coordinates': 'Koordináták', diff --git a/shared/src/i18n/id/collection.ts b/shared/src/i18n/id/collection.ts index a31b27d30..4556928ef 100644 --- a/shared/src/i18n/id/collection.ts +++ b/shared/src/i18n/id/collection.ts @@ -36,6 +36,26 @@ const collection: TranslationStrings = { 'collections.addPlace': 'Tambah tempat', 'collections.addPlaceSearch': 'Cari tempat…', 'collections.addPlaceHint': 'Cari untuk menambahkan tempat ke daftar ini', + 'collections.importFromTrip': 'Impor dari perjalanan', + 'collections.importTitle': 'Impor dari perjalanan', + 'collections.importDoneTitle': 'Impor selesai', + 'collections.importPickTripHint': 'Pilih perjalanan dan bawa tempatnya ke {name}.', + 'collections.importNoTrips': 'Belum ada perjalanan', + 'collections.importPlacesCount': '{count} tempat', + 'collections.importSearchPlaces': 'Cari tempat…', + 'collections.importOnlyNew': 'Hanya yang baru', + 'collections.importSelectAll': 'Pilih semua', + 'collections.importClearSelection': 'Hapus pilihan', + 'collections.importAlreadySaved': 'Sudah tersimpan', + 'collections.importUnscheduled': 'Tanpa hari', + 'collections.importOnDay': 'Hari {count}', + 'collections.importSelectedCount': '{count} dipilih', + 'collections.importAction': 'Impor {count}', + 'collections.importEmptyTrip': 'Perjalanan ini belum punya tempat', + 'collections.importNothingNew': 'Semua dari perjalanan ini sudah ada di daftar', + 'collections.importDone': '{count} tempat ditambahkan', + 'collections.importDoneSkipped': '{count} sudah ada di daftar dan dibiarkan apa adanya', + 'collections.importDoneClean': 'Semuanya kini ada di {name}', 'collections.sort.default': 'Bawaan', 'collections.sort.nameAsc': 'Nama (A–Z)', 'collections.coordinates': 'Koordinat', diff --git a/shared/src/i18n/it/collection.ts b/shared/src/i18n/it/collection.ts index 84cf4bca9..da44fc061 100644 --- a/shared/src/i18n/it/collection.ts +++ b/shared/src/i18n/it/collection.ts @@ -36,6 +36,26 @@ const collection: TranslationStrings = { 'collections.addPlace': 'Aggiungi un luogo', 'collections.addPlaceSearch': 'Cerca un luogo…', 'collections.addPlaceHint': 'Cerca per aggiungere un luogo a questo elenco', + 'collections.importFromTrip': 'Importa da un viaggio', + 'collections.importTitle': 'Importa da un viaggio', + 'collections.importDoneTitle': 'Importazione completata', + 'collections.importPickTripHint': 'Scegli un viaggio e porta i suoi luoghi in {name}.', + 'collections.importNoTrips': 'Ancora nessun viaggio', + 'collections.importPlacesCount': '{count} luoghi', + 'collections.importSearchPlaces': 'Cerca luoghi…', + 'collections.importOnlyNew': 'Solo nuovi', + 'collections.importSelectAll': 'Seleziona tutto', + 'collections.importClearSelection': 'Azzera selezione', + 'collections.importAlreadySaved': 'Già salvato', + 'collections.importUnscheduled': 'Senza giorno', + 'collections.importOnDay': 'Giorno {count}', + 'collections.importSelectedCount': '{count} selezionati', + 'collections.importAction': 'Importa {count}', + 'collections.importEmptyTrip': 'Questo viaggio non ha ancora luoghi', + 'collections.importNothingNew': 'Tutto questo viaggio è già in questa lista', + 'collections.importDone': 'Aggiunti {count} luoghi', + 'collections.importDoneSkipped': '{count} erano già in lista e sono rimasti com’erano', + 'collections.importDoneClean': 'Ora sono tutti in {name}', 'collections.sort.default': 'Predefinito', 'collections.sort.nameAsc': 'Nome (A–Z)', 'collections.coordinates': 'Coordinate', diff --git a/shared/src/i18n/ja/collection.ts b/shared/src/i18n/ja/collection.ts index 8e6269815..0df044104 100644 --- a/shared/src/i18n/ja/collection.ts +++ b/shared/src/i18n/ja/collection.ts @@ -36,6 +36,26 @@ const collection: TranslationStrings = { 'collections.addPlace': '場所を追加', 'collections.addPlaceSearch': '場所を検索…', 'collections.addPlaceHint': '検索してこのリストに場所を追加', + 'collections.importFromTrip': '旅行から取り込む', + 'collections.importTitle': '旅行から取り込む', + 'collections.importDoneTitle': '取り込みが完了しました', + 'collections.importPickTripHint': '旅行を選んで、その場所を{name}に取り込みます。', + 'collections.importNoTrips': 'まだ旅行がありません', + 'collections.importPlacesCount': '{count}件の場所', + 'collections.importSearchPlaces': '場所を検索…', + 'collections.importOnlyNew': '新しいものだけ', + 'collections.importSelectAll': 'すべて選択', + 'collections.importClearSelection': '選択を解除', + 'collections.importAlreadySaved': '保存済み', + 'collections.importUnscheduled': '日程なし', + 'collections.importOnDay': '{count}日目', + 'collections.importSelectedCount': '{count}件選択中', + 'collections.importAction': '{count}件を取り込む', + 'collections.importEmptyTrip': 'この旅行にはまだ場所がありません', + 'collections.importNothingNew': 'この旅行の場所はすべてリストにあります', + 'collections.importDone': '{count}件の場所を追加しました', + 'collections.importDoneSkipped': '{count}件はすでにリストにあり、そのままにしました', + 'collections.importDoneClean': 'すべて{name}に入りました', 'collections.sort.default': 'デフォルト', 'collections.sort.nameAsc': '名前順(A–Z)', 'collections.coordinates': '座標', diff --git a/shared/src/i18n/ko/collection.ts b/shared/src/i18n/ko/collection.ts index 646b33e58..a329cf8f4 100644 --- a/shared/src/i18n/ko/collection.ts +++ b/shared/src/i18n/ko/collection.ts @@ -36,6 +36,26 @@ const collection: TranslationStrings = { 'collections.addPlace': '장소 추가', 'collections.addPlaceSearch': '장소 검색…', 'collections.addPlaceHint': '검색하여 이 목록에 장소 추가', + 'collections.importFromTrip': '여행에서 가져오기', + 'collections.importTitle': '여행에서 가져오기', + 'collections.importDoneTitle': '가져오기 완료', + 'collections.importPickTripHint': '여행을 골라 그곳의 장소를 {name}(으)로 가져옵니다.', + 'collections.importNoTrips': '아직 여행이 없습니다', + 'collections.importPlacesCount': '장소 {count}개', + 'collections.importSearchPlaces': '장소 검색…', + 'collections.importOnlyNew': '새 항목만', + 'collections.importSelectAll': '전체 선택', + 'collections.importClearSelection': '선택 해제', + 'collections.importAlreadySaved': '이미 저장됨', + 'collections.importUnscheduled': '날짜 없음', + 'collections.importOnDay': '{count}일차', + 'collections.importSelectedCount': '{count}개 선택됨', + 'collections.importAction': '{count}개 가져오기', + 'collections.importEmptyTrip': '이 여행에는 아직 장소가 없습니다', + 'collections.importNothingNew': '이 여행의 장소는 모두 목록에 있습니다', + 'collections.importDone': '장소 {count}개를 추가했습니다', + 'collections.importDoneSkipped': '{count}개는 이미 목록에 있어 그대로 두었습니다', + 'collections.importDoneClean': '이제 모두 {name}에 있습니다', 'collections.sort.default': '기본', 'collections.sort.nameAsc': '이름순 (A–Z)', 'collections.coordinates': '좌표', diff --git a/shared/src/i18n/nl/collection.ts b/shared/src/i18n/nl/collection.ts index 842bfe285..4c8ca2209 100644 --- a/shared/src/i18n/nl/collection.ts +++ b/shared/src/i18n/nl/collection.ts @@ -36,6 +36,26 @@ const collection: TranslationStrings = { 'collections.addPlace': 'Plaats toevoegen', 'collections.addPlaceSearch': 'Zoek een plaats…', 'collections.addPlaceHint': 'Zoek om een plaats aan deze lijst toe te voegen', + 'collections.importFromTrip': 'Importeren uit een reis', + 'collections.importTitle': 'Importeren uit een reis', + 'collections.importDoneTitle': 'Import klaar', + 'collections.importPickTripHint': 'Kies een reis en haal de plekken ervan naar {name}.', + 'collections.importNoTrips': 'Nog geen reizen', + 'collections.importPlacesCount': '{count} plekken', + 'collections.importSearchPlaces': 'Plekken zoeken…', + 'collections.importOnlyNew': 'Alleen nieuwe', + 'collections.importSelectAll': 'Alles selecteren', + 'collections.importClearSelection': 'Selectie wissen', + 'collections.importAlreadySaved': 'Al opgeslagen', + 'collections.importUnscheduled': 'Zonder dag', + 'collections.importOnDay': 'Dag {count}', + 'collections.importSelectedCount': '{count} geselecteerd', + 'collections.importAction': '{count} importeren', + 'collections.importEmptyTrip': 'Deze reis heeft nog geen plekken', + 'collections.importNothingNew': 'Alles uit deze reis staat al op deze lijst', + 'collections.importDone': '{count} plekken toegevoegd', + 'collections.importDoneSkipped': '{count} stonden er al op en zijn ongewijzigd gebleven', + 'collections.importDoneClean': 'Ze staan nu allemaal in {name}', 'collections.sort.default': 'Standaard', 'collections.sort.nameAsc': 'Naam (A–Z)', 'collections.coordinates': 'Coördinaten', diff --git a/shared/src/i18n/pl/collection.ts b/shared/src/i18n/pl/collection.ts index 761a38e46..5aff76c1e 100644 --- a/shared/src/i18n/pl/collection.ts +++ b/shared/src/i18n/pl/collection.ts @@ -36,6 +36,26 @@ const collection: TranslationStrings = { 'collections.addPlace': 'Dodaj miejsce', 'collections.addPlaceSearch': 'Wyszukaj miejsce…', 'collections.addPlaceHint': 'Wyszukaj, aby dodać miejsce do tej listy', + 'collections.importFromTrip': 'Importuj z podróży', + 'collections.importTitle': 'Importuj z podróży', + 'collections.importDoneTitle': 'Import zakończony', + 'collections.importPickTripHint': 'Wybierz podróż i przenieś jej miejsca do {name}.', + 'collections.importNoTrips': 'Brak podróży', + 'collections.importPlacesCount': '{count} miejsc', + 'collections.importSearchPlaces': 'Szukaj miejsc…', + 'collections.importOnlyNew': 'Tylko nowe', + 'collections.importSelectAll': 'Zaznacz wszystko', + 'collections.importClearSelection': 'Wyczyść zaznaczenie', + 'collections.importAlreadySaved': 'Już zapisane', + 'collections.importUnscheduled': 'Bez dnia', + 'collections.importOnDay': 'Dzień {count}', + 'collections.importSelectedCount': 'Zaznaczono: {count}', + 'collections.importAction': 'Importuj {count}', + 'collections.importEmptyTrip': 'Ta podróż nie ma jeszcze miejsc', + 'collections.importNothingNew': 'Wszystko z tej podróży jest już na liście', + 'collections.importDone': 'Dodano {count} miejsc', + 'collections.importDoneSkipped': '{count} było już na liście i zostało bez zmian', + 'collections.importDoneClean': 'Teraz wszystkie są w {name}', 'collections.sort.default': 'Domyślnie', 'collections.sort.nameAsc': 'Nazwa (A–Z)', 'collections.coordinates': 'Współrzędne', diff --git a/shared/src/i18n/ru/collection.ts b/shared/src/i18n/ru/collection.ts index c08af9c30..6267176fe 100644 --- a/shared/src/i18n/ru/collection.ts +++ b/shared/src/i18n/ru/collection.ts @@ -36,6 +36,26 @@ const collection: TranslationStrings = { 'collections.addPlace': 'Добавить место', 'collections.addPlaceSearch': 'Поиск места…', 'collections.addPlaceHint': 'Найдите, чтобы добавить место в этот список', + 'collections.importFromTrip': 'Импорт из поездки', + 'collections.importTitle': 'Импорт из поездки', + 'collections.importDoneTitle': 'Импорт завершён', + 'collections.importPickTripHint': 'Выберите поездку и перенесите её места в список {name}.', + 'collections.importNoTrips': 'Поездок пока нет', + 'collections.importPlacesCount': '{count} мест', + 'collections.importSearchPlaces': 'Поиск мест…', + 'collections.importOnlyNew': 'Только новые', + 'collections.importSelectAll': 'Выбрать все', + 'collections.importClearSelection': 'Снять выделение', + 'collections.importAlreadySaved': 'Уже сохранено', + 'collections.importUnscheduled': 'Без дня', + 'collections.importOnDay': 'День {count}', + 'collections.importSelectedCount': 'Выбрано: {count}', + 'collections.importAction': 'Импортировать {count}', + 'collections.importEmptyTrip': 'В этой поездке пока нет мест', + 'collections.importNothingNew': 'Всё из этой поездки уже есть в списке', + 'collections.importDone': 'Добавлено мест: {count}', + 'collections.importDoneSkipped': '{count} уже были в списке и остались без изменений', + 'collections.importDoneClean': 'Теперь все они в списке {name}', 'collections.sort.default': 'По умолчанию', 'collections.sort.nameAsc': 'Название (А–Я)', 'collections.coordinates': 'Координаты', diff --git a/shared/src/i18n/sv/collection.ts b/shared/src/i18n/sv/collection.ts index ce287b26a..291fa95a0 100644 --- a/shared/src/i18n/sv/collection.ts +++ b/shared/src/i18n/sv/collection.ts @@ -36,6 +36,26 @@ const collection: TranslationStrings = { 'collections.addPlace': 'Lägg till en plats', 'collections.addPlaceSearch': 'Sök efter en plats…', 'collections.addPlaceHint': 'Sök för att lägga till en plats i listan', + 'collections.importFromTrip': 'Importera från en resa', + 'collections.importTitle': 'Importera från en resa', + 'collections.importDoneTitle': 'Importen är klar', + 'collections.importPickTripHint': 'Välj en resa och ta med dess platser till {name}.', + 'collections.importNoTrips': 'Inga resor än', + 'collections.importPlacesCount': '{count} platser', + 'collections.importSearchPlaces': 'Sök platser…', + 'collections.importOnlyNew': 'Bara nya', + 'collections.importSelectAll': 'Markera alla', + 'collections.importClearSelection': 'Rensa markering', + 'collections.importAlreadySaved': 'Redan sparad', + 'collections.importUnscheduled': 'Utan dag', + 'collections.importOnDay': 'Dag {count}', + 'collections.importSelectedCount': '{count} markerade', + 'collections.importAction': 'Importera {count}', + 'collections.importEmptyTrip': 'Den här resan har inga platser än', + 'collections.importNothingNew': 'Allt från den här resan finns redan i listan', + 'collections.importDone': '{count} platser tillagda', + 'collections.importDoneSkipped': '{count} fanns redan i listan och lämnades som de var', + 'collections.importDoneClean': 'Nu finns de alla i {name}', 'collections.sort.default': 'Standard', 'collections.sort.nameAsc': 'Namn (A–Ö)', 'collections.coordinates': 'Koordinater', diff --git a/shared/src/i18n/tr/collection.ts b/shared/src/i18n/tr/collection.ts index 3d7ba3323..a90835737 100644 --- a/shared/src/i18n/tr/collection.ts +++ b/shared/src/i18n/tr/collection.ts @@ -36,6 +36,26 @@ const collection: TranslationStrings = { 'collections.addPlace': 'Yer ekle', 'collections.addPlaceSearch': 'Yer ara…', 'collections.addPlaceHint': 'Bu listeye yer eklemek için arayın', + 'collections.importFromTrip': 'Bir geziden içe aktar', + 'collections.importTitle': 'Bir geziden içe aktar', + 'collections.importDoneTitle': 'İçe aktarma tamamlandı', + 'collections.importPickTripHint': 'Bir gezi seç ve oradaki yerleri {name} listesine aktar.', + 'collections.importNoTrips': 'Henüz gezi yok', + 'collections.importPlacesCount': '{count} yer', + 'collections.importSearchPlaces': 'Yer ara…', + 'collections.importOnlyNew': 'Yalnızca yeniler', + 'collections.importSelectAll': 'Tümünü seç', + 'collections.importClearSelection': 'Seçimi temizle', + 'collections.importAlreadySaved': 'Zaten kayıtlı', + 'collections.importUnscheduled': 'Günsüz', + 'collections.importOnDay': '{count}. gün', + 'collections.importSelectedCount': '{count} seçildi', + 'collections.importAction': '{count} yeri aktar', + 'collections.importEmptyTrip': 'Bu gezide henüz yer yok', + 'collections.importNothingNew': 'Bu gezideki her şey zaten bu listede', + 'collections.importDone': '{count} yer eklendi', + 'collections.importDoneSkipped': '{count} zaten listedeydi ve olduğu gibi bırakıldı', + 'collections.importDoneClean': 'Artık hepsi {name} listesinde', 'collections.sort.default': 'Varsayılan', 'collections.sort.nameAsc': 'Ad (A–Z)', 'collections.coordinates': 'Koordinatlar', diff --git a/shared/src/i18n/uk/collection.ts b/shared/src/i18n/uk/collection.ts index 964759874..19d85e07c 100644 --- a/shared/src/i18n/uk/collection.ts +++ b/shared/src/i18n/uk/collection.ts @@ -36,6 +36,26 @@ const collection: TranslationStrings = { 'collections.addPlace': 'Додати місце', 'collections.addPlaceSearch': 'Пошук місця…', 'collections.addPlaceHint': 'Знайдіть, щоб додати місце до цього списку', + 'collections.importFromTrip': 'Імпорт із подорожі', + 'collections.importTitle': 'Імпорт із подорожі', + 'collections.importDoneTitle': 'Імпорт завершено', + 'collections.importPickTripHint': 'Вибери подорож і перенеси її місця до списку {name}.', + 'collections.importNoTrips': 'Подорожей поки немає', + 'collections.importPlacesCount': '{count} місць', + 'collections.importSearchPlaces': 'Пошук місць…', + 'collections.importOnlyNew': 'Лише нові', + 'collections.importSelectAll': 'Вибрати все', + 'collections.importClearSelection': 'Зняти вибір', + 'collections.importAlreadySaved': 'Уже збережено', + 'collections.importUnscheduled': 'Без дня', + 'collections.importOnDay': 'День {count}', + 'collections.importSelectedCount': 'Вибрано: {count}', + 'collections.importAction': 'Імпортувати {count}', + 'collections.importEmptyTrip': 'У цій подорожі ще немає місць', + 'collections.importNothingNew': 'Усе з цієї подорожі вже є у списку', + 'collections.importDone': 'Додано місць: {count}', + 'collections.importDoneSkipped': '{count} уже були у списку й лишилися без змін', + 'collections.importDoneClean': 'Тепер усі вони у списку {name}', 'collections.sort.default': 'За замовчуванням', 'collections.sort.nameAsc': 'Назва (А–Я)', 'collections.coordinates': 'Координати', diff --git a/shared/src/i18n/vi/collection.ts b/shared/src/i18n/vi/collection.ts index de856f180..2330bbc0f 100644 --- a/shared/src/i18n/vi/collection.ts +++ b/shared/src/i18n/vi/collection.ts @@ -36,6 +36,26 @@ const collection: TranslationStrings = { 'collections.addPlace': 'Thêm địa điểm', 'collections.addPlaceSearch': 'Tìm địa điểm…', 'collections.addPlaceHint': 'Tìm để thêm địa điểm vào danh sách này', + 'collections.importFromTrip': 'Nhập từ một chuyến đi', + 'collections.importTitle': 'Nhập từ một chuyến đi', + 'collections.importDoneTitle': 'Đã nhập xong', + 'collections.importPickTripHint': 'Chọn một chuyến đi và mang các địa điểm của nó vào {name}.', + 'collections.importNoTrips': 'Chưa có chuyến đi nào', + 'collections.importPlacesCount': '{count} địa điểm', + 'collections.importSearchPlaces': 'Tìm địa điểm…', + 'collections.importOnlyNew': 'Chỉ cái mới', + 'collections.importSelectAll': 'Chọn tất cả', + 'collections.importClearSelection': 'Bỏ chọn', + 'collections.importAlreadySaved': 'Đã lưu rồi', + 'collections.importUnscheduled': 'Chưa xếp ngày', + 'collections.importOnDay': 'Ngày {count}', + 'collections.importSelectedCount': 'Đã chọn {count}', + 'collections.importAction': 'Nhập {count}', + 'collections.importEmptyTrip': 'Chuyến đi này chưa có địa điểm nào', + 'collections.importNothingNew': 'Mọi thứ trong chuyến đi này đã có trong danh sách', + 'collections.importDone': 'Đã thêm {count} địa điểm', + 'collections.importDoneSkipped': '{count} đã có sẵn trong danh sách và được giữ nguyên', + 'collections.importDoneClean': 'Giờ tất cả đều nằm trong {name}', 'collections.sort.default': 'Mặc định', 'collections.sort.nameAsc': 'Tên (A–Z)', 'collections.coordinates': 'Tọa độ', diff --git a/shared/src/i18n/zh-TW/collection.ts b/shared/src/i18n/zh-TW/collection.ts index 705956b56..b3f49ee34 100644 --- a/shared/src/i18n/zh-TW/collection.ts +++ b/shared/src/i18n/zh-TW/collection.ts @@ -36,6 +36,26 @@ const collection: TranslationStrings = { 'collections.addPlace': '新增地點', 'collections.addPlaceSearch': '搜尋地點…', 'collections.addPlaceHint': '搜尋以將地點加入此清單', + 'collections.importFromTrip': '從行程匯入', + 'collections.importTitle': '從行程匯入', + 'collections.importDoneTitle': '匯入完成', + 'collections.importPickTripHint': '選擇一次行程,把其中的地點帶到{name}。', + 'collections.importNoTrips': '還沒有行程', + 'collections.importPlacesCount': '{count} 個地點', + 'collections.importSearchPlaces': '搜尋地點…', + 'collections.importOnlyNew': '僅新增', + 'collections.importSelectAll': '全選', + 'collections.importClearSelection': '清除選擇', + 'collections.importAlreadySaved': '已儲存', + 'collections.importUnscheduled': '未排入行程', + 'collections.importOnDay': '第 {count} 天', + 'collections.importSelectedCount': '已選 {count} 個', + 'collections.importAction': '匯入 {count} 個', + 'collections.importEmptyTrip': '這次行程還沒有地點', + 'collections.importNothingNew': '這次行程的地點都已在清單中', + 'collections.importDone': '已新增 {count} 個地點', + 'collections.importDoneSkipped': '{count} 個已在清單中,保持原樣', + 'collections.importDoneClean': '現在它們都在{name}裡', 'collections.sort.default': '預設', 'collections.sort.nameAsc': '名稱 (A–Z)', 'collections.coordinates': '座標', diff --git a/shared/src/i18n/zh/collection.ts b/shared/src/i18n/zh/collection.ts index a6f50afc2..0d5b8dae6 100644 --- a/shared/src/i18n/zh/collection.ts +++ b/shared/src/i18n/zh/collection.ts @@ -36,6 +36,26 @@ const collection: TranslationStrings = { 'collections.addPlace': '添加地点', 'collections.addPlaceSearch': '搜索地点…', 'collections.addPlaceHint': '搜索以将地点添加到此清单', + 'collections.importFromTrip': '从行程导入', + 'collections.importTitle': '从行程导入', + 'collections.importDoneTitle': '导入完成', + 'collections.importPickTripHint': '选择一次行程,把其中的地点带到{name}。', + 'collections.importNoTrips': '还没有行程', + 'collections.importPlacesCount': '{count} 个地点', + 'collections.importSearchPlaces': '搜索地点…', + 'collections.importOnlyNew': '仅新增', + 'collections.importSelectAll': '全选', + 'collections.importClearSelection': '清除选择', + 'collections.importAlreadySaved': '已保存', + 'collections.importUnscheduled': '未排入日程', + 'collections.importOnDay': '第 {count} 天', + 'collections.importSelectedCount': '已选 {count} 个', + 'collections.importAction': '导入 {count} 个', + 'collections.importEmptyTrip': '这次行程还没有地点', + 'collections.importNothingNew': '这次行程的地点都已在列表中', + 'collections.importDone': '已添加 {count} 个地点', + 'collections.importDoneSkipped': '{count} 个已在列表中,保持原样', + 'collections.importDoneClean': '现在它们都在{name}里', 'collections.sort.default': '默认', 'collections.sort.nameAsc': '名称 (A–Z)', 'collections.coordinates': '坐标', diff --git a/wiki/Collections.md b/wiki/Collections.md index 401b27ddc..6cec3f155 100644 --- a/wiki/Collections.md +++ b/wiki/Collections.md @@ -43,6 +43,7 @@ Places can be assigned a **category** from the same admin-defined set used acros - **Search and add** — the "+" action opens a search; pick a result and set the name, category, status, a markdown description and links before saving, all in one step. - **Save from a trip** — the place inspector and the trip place context menu both offer **Save to collection**, which toggles that place in or out of each of your lists. - **Bulk-add from a trip** — in the trip place list, enter select mode, tick several places, and use the **Save to collection** action in the selection bar to copy them all into a chosen list at once. Duplicates (by name or coordinates) are skipped automatically. +- **Import a whole trip** — the download button in the list's filter row (and the second button on an empty list) opens **Import from a trip**: pick one of your trips, then tick the places you want. Places you already saved are greyed out and cannot be picked twice, and the ones no day of that trip holds start out selected, since those are the plans a trip left behind. **Only new** hides everything already on the list, and the counter on the import button always shows what is about to be added. ## Place detail