mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
feat(collections): import a whole trip's places into a list (#1903)
* feat(collections): preview which of a trip's places a list already holds The bulk import endpoint has been there since the select-mode copy, but nothing calls it and it only reports duplicates after the fact. A dialog needs the verdict up front, so /:id/importable/:tripId returns the trip's places with the duplicate check the import itself applies, plus whether any day holds the place. * feat(collections): import a whole trip's places into a list Two steps: pick a trip, then tick its places. Places already on the list are greyed out and unpickable, and the ones no day holds start selected — those are the plans a trip left behind, which is the reason to import at all. Sorted so a mostly-saved trip reads as the two that are left rather than a wall of grey. * feat(collections): reach the trip import from the filter row and an empty list * i18n(collections): translate the trip import into all locales * fix(collections): stop the label dialog's buttons wrapping mid-word Three actions in a max-w-sm footer wrapped into two lines each. The assign button also painted white text on the accent, which disappears on a light one. * docs(collections): document the trip import
This commit is contained in:
@@ -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<CollectionImportablesResponse> =>
|
||||
ax.get(`${base}/${collectionId}/importable/${tripId}`).then((r: AxiosResponse) => r.data),
|
||||
updatePlace: (pid: number, body: CollectionPlaceUpdateRequest): Promise<CollectionPlace> =>
|
||||
ax.patch(`${base}/places/${pid}`, body satisfies CollectionPlaceUpdateRequest).then((r: AxiosResponse) => r.data),
|
||||
uploadPlaceImage: (pid: number, formData: FormData): Promise<CollectionPlace> =>
|
||||
|
||||
@@ -39,7 +39,7 @@ export default function BulkAssignLabelModal({ isOpen, labels, count, onAssign,
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title={t('collections.labels.assignN', { count })} size="sm">
|
||||
<Modal isOpen={isOpen} onClose={onClose} title={t('collections.labels.assignN', { count })} size="lg">
|
||||
{labels.length === 0 ? (
|
||||
<div className="flex flex-col items-center gap-3 py-6 text-center">
|
||||
<Tags size={26} className="text-content-faint" />
|
||||
@@ -67,18 +67,20 @@ export default function BulkAssignLabelModal({ isOpen, labels, count, onAssign,
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{/* 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. */}
|
||||
<div className="flex justify-end gap-2 pt-2 border-t border-edge">
|
||||
<button type="button" onClick={onManage} className="mr-auto flex items-center gap-1.5 px-3 py-2 rounded-lg text-[13px] text-content-secondary hover:bg-surface-hover">
|
||||
<button type="button" onClick={onManage} className="mr-auto flex items-center gap-1.5 px-3 py-2 rounded-lg text-[13px] text-content-secondary whitespace-nowrap hover:bg-surface-hover">
|
||||
<Settings2 size={14} /> {t('collections.labels.manage')}
|
||||
</button>
|
||||
<button type="button" onClick={onClose} className="px-3 py-2 rounded-lg border border-edge text-content-secondary text-[13px] hover:bg-surface-hover">
|
||||
<button type="button" onClick={onClose} className="px-3 py-2 rounded-lg border border-edge text-content-secondary text-[13px] whitespace-nowrap hover:bg-surface-hover">
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={assign}
|
||||
disabled={picked.length === 0 || busy}
|
||||
className="flex items-center gap-1.5 px-3 py-2 rounded-lg bg-accent text-white text-[13px] font-semibold hover:opacity-90 disabled:opacity-50"
|
||||
className="flex items-center gap-1.5 px-3 py-2 rounded-lg bg-accent text-accent-text text-[13px] font-semibold whitespace-nowrap hover:opacity-90 disabled:opacity-50"
|
||||
>
|
||||
{busy ? <Loader2 size={14} className="animate-spin" /> : <Check size={14} />} {t('collections.labels.assign')}
|
||||
</button>
|
||||
|
||||
@@ -41,6 +41,8 @@ function makeProps(overrides: Partial<HarnessProps> = {}): HarnessProps {
|
||||
onLabelFilter: vi.fn(),
|
||||
canManageLabels: false,
|
||||
onManageLabels: vi.fn(),
|
||||
canImport: false,
|
||||
onImport: vi.fn(),
|
||||
showSelect: true,
|
||||
selectMode: false,
|
||||
onToggleSelect: vi.fn(),
|
||||
|
||||
@@ -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({
|
||||
<CheckSquare size={15} />
|
||||
</button>
|
||||
)}
|
||||
{canImport && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onImport}
|
||||
className="col-filter-btn col-filter-icon"
|
||||
aria-label={t('collections.importFromTrip')}
|
||||
title={t('collections.importFromTrip')}
|
||||
>
|
||||
<DownloadCloud size={15} />
|
||||
</button>
|
||||
)}
|
||||
{showLabels && (labelOptions.length > 0 || canManageLabels) && (
|
||||
<CollectionLabelFilter
|
||||
labelOptions={labelOptions}
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
// FE-COMP-COLIMPORT-001 to FE-COMP-COLIMPORT-007
|
||||
import React from 'react';
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { render, screen, waitFor, within } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { server } from '../../../tests/helpers/msw/server';
|
||||
import { useTranslation } from '../../i18n/TranslationContext';
|
||||
import ImportFromTripModal from './ImportFromTripModal';
|
||||
|
||||
const BASE = '/api/addons/collections';
|
||||
|
||||
const TRIPS = [
|
||||
{ id: 7, user_id: 1, title: 'Rome 2026', currency: 'EUR', is_archived: 0, reminder_days: 3, start_date: '2026-05-01', end_date: '2026-05-08', place_count: 3, cover_image: null },
|
||||
{ id: 9, user_id: 1, title: 'Lisbon', currency: 'EUR', is_archived: 0, reminder_days: 3, place_count: 0, cover_image: null },
|
||||
];
|
||||
|
||||
const place = (over: Record<string, unknown>) => ({
|
||||
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 (
|
||||
<ImportFromTripModal
|
||||
isOpen
|
||||
collectionId={4}
|
||||
collectionName="Italy"
|
||||
categories={[]}
|
||||
onClose={props.onClose ?? (() => {})}
|
||||
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<typeof userEvent.setup>) {
|
||||
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(<Harness />);
|
||||
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(<Harness />);
|
||||
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(<Harness />);
|
||||
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(<Harness />);
|
||||
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(<Harness onImported={onImported} />);
|
||||
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(<Harness />);
|
||||
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(<Harness />);
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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<Step>('trip')
|
||||
const [trips, setTrips] = useState<Trip[]>([])
|
||||
const [tripsLoading, setTripsLoading] = useState(false)
|
||||
const [trip, setTrip] = useState<Trip | null>(null)
|
||||
const [items, setItems] = useState<CollectionImportablePlace[]>([])
|
||||
const [itemsLoading, setItemsLoading] = useState(false)
|
||||
const [selected, setSelected] = useState<Set<number>>(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'
|
||||
? (
|
||||
<div className="flex justify-end">
|
||||
<button type="button" onClick={onClose} className="px-4 py-1.5 rounded-lg bg-accent text-accent-text text-[13px] font-semibold">{t('common.close')}</button>
|
||||
</div>
|
||||
)
|
||||
: step === 'places'
|
||||
? (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-[12.5px] text-content-faint">
|
||||
{t('collections.importSelectedCount', { count: selected.size })}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<button type="button" onClick={onClose} className="px-3 py-1.5 rounded-lg border border-edge text-content-secondary text-[13px] hover:bg-surface-hover">{t('common.cancel')}</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={runImport}
|
||||
disabled={importing || selected.size === 0}
|
||||
className="px-4 py-1.5 rounded-lg bg-accent text-accent-text text-[13px] font-semibold disabled:opacity-50 inline-flex items-center gap-1.5"
|
||||
>
|
||||
{importing ? <Loader2 size={14} className="animate-spin" /> : <Check size={14} />}
|
||||
{t('collections.importAction', { count: selected.size })}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<div className="flex justify-end">
|
||||
<button type="button" onClick={onClose} className="px-3 py-1.5 rounded-lg border border-edge text-content-secondary text-[13px] hover:bg-surface-hover">{t('common.cancel')}</button>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title={title} size="xl" footer={footer}>
|
||||
<div className="col-imp">
|
||||
{/* ── Step 1: which trip ─────────────────────────────────────────── */}
|
||||
{step === 'trip' && (
|
||||
<>
|
||||
<p className="col-imp-lead">{t('collections.importPickTripHint', { name: collectionName })}</p>
|
||||
{tripsLoading ? (
|
||||
<div className="col-imp-grid">
|
||||
{[0, 1, 2, 3].map(i => <div key={i} className="col-imp-trip trek-skeleton" style={{ height: 68 }} />)}
|
||||
</div>
|
||||
) : trips.length === 0 ? (
|
||||
<div className="col-imp-empty">
|
||||
<MapIcon size={22} />
|
||||
<span>{t('collections.importNoTrips')}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="col-imp-grid trek-stagger">
|
||||
{trips.map(tr => {
|
||||
const range = tripRange(tr, language)
|
||||
const places = tr.place_count ?? 0
|
||||
return (
|
||||
<button key={tr.id} type="button" className="col-imp-trip" onClick={() => pickTrip(tr)}>
|
||||
<span className="col-imp-trip-cover">
|
||||
{tr.cover_image
|
||||
? <img src={tr.cover_image} alt="" loading="lazy" />
|
||||
: <MapIcon size={16} />}
|
||||
</span>
|
||||
<span className="col-imp-trip-body">
|
||||
<span className="col-imp-trip-name">{tr.title}</span>
|
||||
{/* Badges rather than one run-on line: the tile is half the dialog
|
||||
wide, and a date range plus a place count ran out of room and
|
||||
got clipped mid-word. Each pill can now wrap as a unit. */}
|
||||
<span className="col-imp-trip-meta">
|
||||
{range && (
|
||||
<span className="col-imp-pill" title={range}>
|
||||
<Calendar size={10} />{range}
|
||||
</span>
|
||||
)}
|
||||
<span className="col-imp-pill num" title={t('collections.importPlacesCount', { count: places })}>
|
||||
<MapPin size={10} />{places}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
<ChevronRight size={16} className="col-imp-trip-go" />
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Step 2: which places ───────────────────────────────────────── */}
|
||||
{step === 'places' && (
|
||||
<>
|
||||
<div className="col-imp-bar">
|
||||
<button type="button" className="col-imp-back" onClick={() => setStep('trip')} aria-label={t('common.back')}>
|
||||
<ArrowLeft size={15} />
|
||||
</button>
|
||||
<div className="col-imp-search">
|
||||
<Search size={14} />
|
||||
<input
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
placeholder={t('collections.importSearchPlaces')}
|
||||
aria-label={t('collections.importSearchPlaces')}
|
||||
/>
|
||||
</div>
|
||||
{savedCount > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
className={`col-imp-chip${onlyNew ? ' on' : ''}`}
|
||||
aria-pressed={onlyNew}
|
||||
onClick={() => setOnlyNew(v => !v)}
|
||||
>
|
||||
<Sparkles size={12} />{t('collections.importOnlyNew')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="col-imp-chip"
|
||||
onClick={toggleAllVisible}
|
||||
disabled={selectableVisible.length === 0}
|
||||
>
|
||||
{allVisibleSelected ? t('collections.importClearSelection') : t('collections.importSelectAll')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{itemsLoading ? (
|
||||
<div className="col-imp-list">
|
||||
{[0, 1, 2, 3, 4].map(i => <div key={i} className="col-imp-row trek-skeleton" style={{ height: 56 }} />)}
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="col-imp-empty">
|
||||
<MapPin size={22} />
|
||||
<span>{t('collections.importEmptyTrip')}</span>
|
||||
</div>
|
||||
) : newCount === 0 ? (
|
||||
<div className="col-imp-empty col-imp-empty-good">
|
||||
<Check size={22} />
|
||||
<span>{t('collections.importNothingNew')}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="col-imp-list trek-stagger">
|
||||
{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 (
|
||||
<button
|
||||
key={p.place_id}
|
||||
type="button"
|
||||
className={`col-imp-row${on ? ' on' : ''}${dup ? ' dup' : ''}`}
|
||||
onClick={() => !dup && toggle(p.place_id)}
|
||||
disabled={dup}
|
||||
aria-pressed={dup ? undefined : on}
|
||||
>
|
||||
<span className="col-imp-box">{on && <Check size={12} strokeWidth={3} />}</span>
|
||||
<span className="col-imp-ico" style={cat?.color ? { color: cat.color } : undefined}>
|
||||
<Icon size={15} />
|
||||
</span>
|
||||
<span className="col-imp-txt">
|
||||
<span className="nm">{p.name}</span>
|
||||
{p.address && <span className="ad">{p.address}</span>}
|
||||
</span>
|
||||
<span className={`col-imp-tag${dup ? ' saved' : p.scheduled ? '' : ' fresh'}`}>
|
||||
{dup
|
||||
? t('collections.importAlreadySaved')
|
||||
: p.scheduled
|
||||
? t('collections.importOnDay', { count: p.day_number ?? 0 })
|
||||
: t('collections.importUnscheduled')}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Step 3: what happened ──────────────────────────────────────── */}
|
||||
{step === 'done' && result && (
|
||||
<div className="col-imp-done">
|
||||
<span className="col-imp-done-ring"><Check size={26} strokeWidth={2.5} /></span>
|
||||
<span className="col-imp-done-t">{t('collections.importDone', { count: result.copied })}</span>
|
||||
<span className="col-imp-done-s">
|
||||
{result.skipped > 0
|
||||
? t('collections.importDoneSkipped', { count: result.skipped })
|
||||
: t('collections.importDoneClean', { name: collectionName })}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -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 {
|
||||
<div className="flex flex-col items-center">
|
||||
<EmptyState scene="collections" title={t('collections.empty.title')} className={canAddPlace ? 'pb-4' : undefined} />
|
||||
{canAddPlace && (
|
||||
<button type="button" onClick={() => c.setShowAddPlace(true)} className="col-cta">
|
||||
<Plus size={16} /> {t('collections.addPlace')}
|
||||
</button>
|
||||
<div className="flex flex-wrap items-center justify-center gap-2">
|
||||
<button type="button" onClick={() => c.setShowAddPlace(true)} className="col-cta">
|
||||
<Plus size={16} /> {t('collections.addPlace')}
|
||||
</button>
|
||||
{/* 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. */}
|
||||
<button type="button" onClick={() => c.setShowImport(true)} className="col-cta col-cta-ghost">
|
||||
<DownloadCloud size={16} /> {t('collections.importFromTrip')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
@@ -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 && (
|
||||
<ImportFromTripModal
|
||||
isOpen={c.showImport}
|
||||
collectionId={c.activeId}
|
||||
collectionName={c.activeCollection.name}
|
||||
categories={c.categories}
|
||||
onClose={() => c.setShowImport(false)}
|
||||
onImported={c.handlePlaceAdded}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Copy to trip */}
|
||||
<CopyToTripModal
|
||||
isOpen={c.copyIds != null}
|
||||
|
||||
@@ -68,6 +68,7 @@ export function useCollections() {
|
||||
const [mobileRailOpen, setMobileRailOpen] = useState(false)
|
||||
const [showShare, setShowShare] = useState(false)
|
||||
const [showAddPlace, setShowAddPlace] = useState(false)
|
||||
const [showImport, setShowImport] = useState(false)
|
||||
// The place ids the Copy-to-trip modal is open for (null = closed). Single
|
||||
// place from the detail panel, or the select-mode set for a bulk copy.
|
||||
const [copyIds, setCopyIds] = useState<number[] | null>(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,
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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'. */
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<typeof collectionMembershipSchema>;
|
||||
|
||||
/** 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<typeof collectionImportablePlaceSchema>;
|
||||
|
||||
export const collectionImportablesResponseSchema = z.object({
|
||||
places: z.array(collectionImportablePlaceSchema),
|
||||
});
|
||||
export type CollectionImportablesResponse = z.infer<typeof collectionImportablesResponseSchema>;
|
||||
|
||||
@@ -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': 'الإحداثيات',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'Συντεταγμένες',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': '座標',
|
||||
|
||||
@@ -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': '좌표',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'Координаты',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'Координати',
|
||||
|
||||
@@ -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 độ',
|
||||
|
||||
@@ -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': '座標',
|
||||
|
||||
@@ -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': '坐标',
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user