feat(collections): bulk-add selected trip places to a list (#1081)

Add a "Save to collection" action to the trip place list's selection bar (next to
bulk category + delete): it opens a list picker and copies every selected place
into the chosen list in one request, instead of one-by-one from each place.

- Server: saveFromTripPlaces (one access check + one WS notify), POST
  places/from-trip-many; dedups by name/coords, skips missing ids, honours force.
- Client: saveFromTripMany api + SaveTripPlacesToListModal; the selection-bar
  button is gated on the collections addon being enabled.
- Copy count / skipped-duplicates toast; strings in all 22 locales.
- Service + controller tests for the bulk path.
This commit is contained in:
Maurice
2026-07-01 17:22:50 +02:00
committed by Maurice
parent 1e0feb93dd
commit 6cdcc82d9c
31 changed files with 305 additions and 2 deletions
+2
View File
@@ -63,6 +63,8 @@ export const collectionsApi = {
ax.post(`${base}/places`, body satisfies CollectionSavePlaceRequest).then((r: AxiosResponse) => r.data),
saveFromTrip: (body: CollectionSaveFromTripRequest): Promise<CollectionSaveResult> =>
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),
updatePlace: (pid: number, body: CollectionPlaceUpdateRequest): Promise<CollectionPlace> =>
ax.patch(`${base}/places/${pid}`, body satisfies CollectionPlaceUpdateRequest).then((r: AxiosResponse) => r.data),
setStatus: (pid: number, status: CollectionStatus): Promise<CollectionPlace> =>
@@ -0,0 +1,118 @@
import React, { useEffect, useMemo, useState } from 'react'
import { Search, Bookmark, ArrowRight, Loader2, Plus } from 'lucide-react'
import Modal from '../shared/Modal'
import { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n'
import { collectionsApi } from '../../api/collections'
import { getApiErrorMessage } from '../../utils/apiError'
import type { Collection } from '@trek/shared'
interface SaveTripPlacesToListModalProps {
isOpen: boolean
tripId: number
/** The selected trip place ids to copy into the chosen list. */
placeIds: number[]
onClose: () => void
/** Called after a successful save (e.g. to clear the trip selection). */
onDone: () => void
}
/**
* Bulk "save to collection" for the trip place list: pick one of the user's lists
* and copy every selected trip place into it at once (server dedups by name/coords).
*/
export default function SaveTripPlacesToListModal({ isOpen, tripId, placeIds, onClose, onDone }: SaveTripPlacesToListModalProps): React.ReactElement | null {
const { t } = useTranslation()
const toast = useToast()
const [lists, setLists] = useState<Collection[]>([])
const [loading, setLoading] = useState(false)
const [search, setSearch] = useState('')
const [busyId, setBusyId] = useState<number | null>(null)
useEffect(() => {
if (!isOpen) return
let cancelled = false
setLoading(true)
setSearch('')
collectionsApi.list()
// Only lists the user can add to (their own or an editor/admin share). The
// server still enforces this; here we drop lists that are clearly read-only.
.then(res => { if (!cancelled) setLists((res.collections ?? []).filter(c => c.is_owner !== false)) })
.catch(() => { if (!cancelled) setLists([]) })
.finally(() => { if (!cancelled) setLoading(false) })
return () => { cancelled = true }
}, [isOpen])
const filtered = useMemo(() => {
const q = search.trim().toLowerCase()
return q ? lists.filter(l => l.name.toLowerCase().includes(q)) : lists
}, [lists, search])
if (!isOpen) return null
const pick = async (list: Collection) => {
if (busyId != null || placeIds.length === 0) return
setBusyId(list.id)
try {
const res = await collectionsApi.saveFromTripMany(list.id, tripId, placeIds)
if (res.copied > 0) toast.success(t('collections.addedNToList', { count: res.copied, name: list.name }))
if (res.skipped.length > 0) toast.info(t('collections.skippedDuplicates', { count: res.skipped.length }))
if (res.copied === 0 && res.skipped.length === 0) toast.info(t('collections.copyNothing'))
onDone()
onClose()
} catch (err) {
toast.error(getApiErrorMessage(err, t('common.error')))
} finally {
setBusyId(null)
}
}
return (
<Modal isOpen onClose={onClose} title={t('collections.saveNToList', { count: placeIds.length })} size="sm">
<div className="flex flex-col gap-3">
{lists.length > 5 && (
<div className="relative">
<Search size={14} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-content-faint" />
<input
autoFocus
value={search}
onChange={e => setSearch(e.target.value)}
placeholder={t('collections.copyToTripSearch')}
className="w-full pl-8 pr-3 py-2 rounded-lg border border-edge bg-surface-input text-content text-[13px] outline-none focus:border-accent"
/>
</div>
)}
{loading ? (
<div className="flex items-center justify-center py-10 text-content-faint"><Loader2 size={20} className="animate-spin" /></div>
) : filtered.length === 0 ? (
<p className="text-center text-[13px] text-content-faint py-8">{t('collections.noOwnLists')}</p>
) : (
<div className="flex flex-col gap-1 max-h-[50vh] overflow-y-auto -mx-1 px-1">
{filtered.map(list => {
const busy = busyId === list.id
return (
<button
key={list.id}
type="button"
onClick={() => pick(list)}
disabled={busyId != null}
className="flex items-center gap-3 px-3 py-2.5 rounded-xl border border-edge bg-surface-card text-left hover:bg-surface-hover transition-colors disabled:opacity-60"
>
<span className="w-9 h-9 min-w-[36px] rounded-lg flex items-center justify-center shrink-0 text-white" style={{ background: list.color || '#6366f1' }}>
<Bookmark size={15} />
</span>
<span className="flex-1 min-w-0">
<span className="block text-[13px] font-semibold text-content truncate">{list.name}</span>
<span className="block text-[11.5px] text-content-faint">{t('collections.placeCount', { count: list.place_count ?? 0 })}</span>
</span>
{busy ? <Loader2 size={15} className="animate-spin text-content-faint shrink-0" /> : <ArrowRight size={15} className="text-content-faint shrink-0" />}
</button>
)
})}
</div>
)}
<p className="text-[11.5px] text-content-faint inline-flex items-center gap-1.5"><Plus size={12} /> {t('collections.saveToListHint')}</p>
</div>
</Modal>
)
}
@@ -9,6 +9,7 @@ import { PlacesList } from './PlacesSidebarList'
import { MobileDayPickerSheet } from './PlacesSidebarMobileDayPicker'
import { ListImportModal } from './PlacesSidebarListImportModal'
import { PlacesBulkCategoryModal } from './PlacesBulkCategoryModal'
import SaveTripPlacesToListModal from '../Collections/SaveTripPlacesToListModal'
const PlacesSidebar = React.memo(function PlacesSidebar(props: PlacesSidebarProps) {
const S = usePlacesSidebar(props)
@@ -18,6 +19,7 @@ const PlacesSidebar = React.memo(function PlacesSidebar(props: PlacesSidebarProp
fileImportOpen, setFileImportOpen, sidebarDropFile, setSidebarDropFile, tripId, pushUndo,
ctxMenu, isMobile, pendingDeleteIds, setPendingDeleteIds, onBulkDeleteConfirm,
categories, selectedIds, exitSelectMode, onBulkChangeCategory, categoryPickerOpen, setCategoryPickerOpen,
collectionsEnabled, saveToListOpen, setSaveToListOpen,
} = S
return (
<div
@@ -61,6 +63,15 @@ const PlacesSidebar = React.memo(function PlacesSidebar(props: PlacesSidebarProp
onPick={(catId) => { onBulkChangeCategory?.(Array.from(selectedIds), catId); setCategoryPickerOpen(false); exitSelectMode() }}
/>
)}
{collectionsEnabled && (
<SaveTripPlacesToListModal
isOpen={saveToListOpen}
tripId={tripId}
placeIds={Array.from(selectedIds)}
onClose={() => setSaveToListOpen(false)}
onDone={exitSelectMode}
/>
)}
{isMobile && (
<ConfirmDialog
isOpen={!!pendingDeleteIds?.length}
@@ -1,9 +1,9 @@
import { Check, Tag, Trash2 } from 'lucide-react'
import { Check, Tag, Trash2, Bookmark } from 'lucide-react'
import Tooltip from '../shared/Tooltip'
import type { SidebarState } from './usePlacesSidebar'
export function PlacesSelectionBar(S: SidebarState) {
const { t, selectedIds, filtered, setSelectedIds, isMobile, setPendingDeleteIds, onBulkDeletePlaces, setCategoryPickerOpen } = S
const { t, selectedIds, filtered, setSelectedIds, isMobile, setPendingDeleteIds, onBulkDeletePlaces, setCategoryPickerOpen, collectionsEnabled, setSaveToListOpen } = S
return (
<div style={{
margin: '6px 16px', padding: '5px 8px 5px 10px', borderRadius: 8,
@@ -49,6 +49,25 @@ export function PlacesSelectionBar(S: SidebarState) {
<Tag size={13} strokeWidth={2} />
</button>
</Tooltip>
{collectionsEnabled && (
<Tooltip label={t('inspector.saveToCollection')} placement="bottom">
<button
onClick={() => { if (selectedIds.size === 0) return; setSaveToListOpen(true) }}
disabled={selectedIds.size === 0}
aria-label={t('inspector.saveToCollection')}
className={selectedIds.size > 0 ? 'bg-transparent text-content-muted' : 'bg-transparent text-content-faint'}
style={{
display: 'flex', alignItems: 'center', justifyContent: 'center',
width: 24, height: 24, borderRadius: 6, border: 'none',
cursor: selectedIds.size > 0 ? 'pointer' : 'default', padding: 0,
}}
onMouseEnter={e => { if (selectedIds.size > 0) e.currentTarget.style.background = 'var(--bg-hover)' }}
onMouseLeave={e => { e.currentTarget.style.background = 'transparent' }}
>
<Bookmark size={13} strokeWidth={2} />
</button>
</Tooltip>
)}
<Tooltip label={t('places.deleteSelected')} placement="bottom">
<button
onClick={() => {
@@ -153,6 +153,7 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set())
const [pendingDeleteIds, setPendingDeleteIds] = useState<number[] | null>(null)
const [categoryPickerOpen, setCategoryPickerOpen] = useState(false)
const [saveToListOpen, setSaveToListOpen] = useState(false)
const exitSelectMode = () => { setSelectMode(false); setSelectedIds(new Set()) }
@@ -266,6 +267,7 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
search, setSearch, filter, setFilter, categoryFilters, setCategoryFiltersLocal,
selectMode, setSelectMode, selectedIds, setSelectedIds, pendingDeleteIds, setPendingDeleteIds,
categoryPickerOpen, setCategoryPickerOpen,
saveToListOpen, setSaveToListOpen, collectionsEnabled, tripId,
exitSelectMode, toggleSelected, toggleCategoryFilter, dayPickerPlace, setDayPickerPlace,
catDropOpen, setCatDropOpen, mobileShowDays, setMobileShowDays,
hasTracks, plannedIds, filtered, registerPlaceRow, isAssignedToSelectedDay, inDaySet, openContextMenu,
@@ -32,6 +32,7 @@ import {
collectionUpdateRequestSchema,
collectionSavePlaceRequestSchema,
collectionSaveFromTripRequestSchema,
collectionSaveFromTripManyRequestSchema,
collectionPlaceUpdateRequestSchema,
collectionSetStatusRequestSchema,
collectionCopyToTripRequestSchema,
@@ -44,6 +45,7 @@ import {
type CollectionUpdateRequest,
type CollectionSavePlaceRequest,
type CollectionSaveFromTripRequest,
type CollectionSaveFromTripManyRequest,
type CollectionPlaceUpdateRequest,
type CollectionSetStatusRequest,
type CollectionCopyToTripRequest,
@@ -122,6 +124,12 @@ export class CollectionsController {
return this.collections.saveFromTripPlace(user.id, body.collection_id, body.source_trip_id, body.source_place_id, body.force);
}
@Post('places/from-trip-many')
@HttpCode(200)
saveFromTripMany(@CurrentUser() user: User, @Body(new ZodValidationPipe(collectionSaveFromTripManyRequestSchema)) body: CollectionSaveFromTripManyRequest) {
return this.collections.saveFromTripPlaces(user.id, body.collection_id, body.source_trip_id, body.source_place_ids, body.force);
}
@Post('places/delete-many')
@HttpCode(200)
deleteMany(@CurrentUser() user: User, @Body('ids') ids: unknown, @Headers('x-socket-id') socketId?: string) {
@@ -28,6 +28,9 @@ export class CollectionsService {
saveFromTripPlace(userId: number, collectionId: number, tripId: number, placeId: number, force?: boolean) {
return svc.saveFromTripPlace(userId, collectionId, tripId, placeId, force);
}
saveFromTripPlaces(userId: number, collectionId: number, tripId: number, placeIds: number[], force?: boolean) {
return svc.saveFromTripPlaces(userId, collectionId, tripId, placeIds, force);
}
updatePlace(userId: number, placeId: number, body: CollectionPlaceUpdateRequest, socketId?: string) { return svc.updatePlace(userId, placeId, body, socketId); }
setStatus(userId: number, placeId: number, status: CollectionStatus, socketId?: string) { return svc.setStatus(userId, placeId, status, socketId); }
deletePlace(userId: number, placeId: number, socketId?: string) { return svc.deletePlace(userId, placeId, socketId); }
+43
View File
@@ -453,6 +453,49 @@ export function saveFromTripPlace(
});
}
/** 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'. */
export function saveFromTripPlaces(
userId: number, collectionId: number, tripId: number, placeIds: number[], force?: boolean,
): { copied: number; skipped: { id: number; name: string }[] } {
assertCanEdit(userId, collectionId);
if (!canAccessTrip(tripId, userId)) httpError(404, 'Trip not found');
const ownerId = ownerOf(collectionId);
const insert = db.prepare(`
INSERT INTO collection_places (
collection_id, owner_id, saved_by, name, description, lat, lng, address,
category_id, price, currency, notes, image_url, google_place_id, google_ftid,
osm_id, website, phone, status, source_trip_id, source_place_id, links
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'idea', ?, ?, NULL)
`);
let copied = 0;
const skipped: { id: number; name: string }[] = [];
for (const placeId of placeIds) {
const p = db.prepare('SELECT * FROM places WHERE id = ? AND trip_id = ?').get(placeId, tripId) as Record<string, unknown> | undefined;
if (!p) continue;
const name = p.name as string;
const lat = (p.lat as number | null) ?? null;
const lng = (p.lng as number | null) ?? null;
if (!force && findDuplicateCollectionPlace(collectionId, { name, lat, lng })) {
skipped.push({ id: placeId, name });
continue;
}
insert.run(
collectionId, ownerId, userId,
name, (p.description as string | null) ?? null, lat, lng, (p.address as string | null) ?? null,
(p.category_id as number | null) ?? null, (p.price as number | null) ?? null, (p.currency as string | null) ?? null, (p.notes as string | null) ?? null,
(p.image_url as string | null) ?? null, (p.google_place_id as string | null) ?? null, (p.google_ftid as string | null) ?? null,
(p.osm_id as string | null) ?? null, (p.website as string | null) ?? null, (p.phone as string | null) ?? null,
tripId, placeId,
);
copied++;
}
if (copied > 0) notifyCollectionUsers(collectionId, undefined, 'collections:updated');
return { copied, skipped };
}
export function updatePlace(userId: number, placeId: number, body: import('@trek/shared').CollectionPlaceUpdateRequest, socketId?: string): CollectionPlace {
const currentCollection = collectionIdOfPlace(placeId);
assertCanEdit(userId, currentCollection);
@@ -138,6 +138,15 @@ export const collectionSaveFromTripRequestSchema = z.object({
});
export type CollectionSaveFromTripRequest = z.infer<typeof collectionSaveFromTripRequestSchema>;
/** Bulk: copy several selected trip places into a list at once. */
export const collectionSaveFromTripManyRequestSchema = z.object({
collection_id: z.number(),
source_trip_id: z.number(),
source_place_ids: z.array(z.number()).min(1).max(1000),
force: z.boolean().optional(),
});
export type CollectionSaveFromTripManyRequest = z.infer<typeof collectionSaveFromTripManyRequestSchema>;
export const collectionPlaceUpdateRequestSchema = z.object({
name: z.string().min(1).optional(),
description: z.string().nullable().optional(),
+4
View File
@@ -1,6 +1,10 @@
import type { TranslationStrings } from '../types';
const collection: TranslationStrings = {
'collections.saveNToList': 'حفظ {count} في قائمة',
'collections.addedNToList': 'تم حفظ {count} في {name}',
'collections.noOwnLists': 'ليس لديك قوائم بعد',
'collections.saveToListHint': 'يتم تخطي التكرارات تلقائيًا',
'collections.role.label': 'الدور',
'collections.role.viewer': 'مشاهد',
'collections.role.editor': 'محرر',
+4
View File
@@ -1,6 +1,10 @@
import type { TranslationStrings } from '../types';
const collection: TranslationStrings = {
'collections.saveNToList': 'Salvar {count} em uma lista',
'collections.addedNToList': '{count} salvos em {name}',
'collections.noOwnLists': 'Você ainda não tem listas',
'collections.saveToListHint': 'Duplicados são ignorados automaticamente',
'collections.role.label': 'Função',
'collections.role.viewer': 'Visualizador',
'collections.role.editor': 'Editor',
+4
View File
@@ -1,6 +1,10 @@
import type { TranslationStrings } from '../types';
const collection: TranslationStrings = {
'collections.saveNToList': 'Uložit {count} do seznamu',
'collections.addedNToList': 'Uloženo {count} do {name}',
'collections.noOwnLists': 'Zatím nemáte žádné seznamy',
'collections.saveToListHint': 'Duplikáty se automaticky přeskočí',
'collections.role.label': 'Role',
'collections.role.viewer': 'Divák',
'collections.role.editor': 'Editor',
+4
View File
@@ -1,6 +1,10 @@
import type { TranslationStrings } from '../types';
const collection: TranslationStrings = {
'collections.saveNToList': '{count} in eine Liste speichern',
'collections.addedNToList': '{count} in {name} gespeichert',
'collections.noOwnLists': 'Du hast noch keine Listen',
'collections.saveToListHint': 'Duplikate werden automatisch übersprungen',
'collections.role.label': 'Rolle',
'collections.role.viewer': 'Betrachter',
'collections.role.editor': 'Bearbeiter',
+4
View File
@@ -1,6 +1,10 @@
import type { TranslationStrings } from '../types';
const collection: TranslationStrings = {
'collections.saveNToList': 'Save {count} to a list',
'collections.addedNToList': 'Saved {count} to {name}',
'collections.noOwnLists': 'You have no lists yet',
'collections.saveToListHint': 'Duplicates are skipped automatically',
'collections.role.label': 'Role',
'collections.role.viewer': 'Viewer',
'collections.role.editor': 'Editor',
+4
View File
@@ -1,6 +1,10 @@
import type { TranslationStrings } from '../types';
const collection: TranslationStrings = {
'collections.saveNToList': 'Guardar {count} en una lista',
'collections.addedNToList': '{count} guardados en {name}',
'collections.noOwnLists': 'Aún no tienes listas',
'collections.saveToListHint': 'Los duplicados se omiten automáticamente',
'collections.role.label': 'Rol',
'collections.role.viewer': 'Lector',
'collections.role.editor': 'Editor',
+4
View File
@@ -1,6 +1,10 @@
import type { TranslationStrings } from '../types';
const collection: TranslationStrings = {
'collections.saveNToList': 'Enregistrer {count} dans une liste',
'collections.addedNToList': '{count} enregistrés dans {name}',
'collections.noOwnLists': 'Vous n\'avez pas encore de listes',
'collections.saveToListHint': 'Les doublons sont ignorés automatiquement',
'collections.role.label': 'Rôle',
'collections.role.viewer': 'Lecteur',
'collections.role.editor': 'Éditeur',
+4
View File
@@ -1,6 +1,10 @@
import type { TranslationStrings } from '../types';
const collection: TranslationStrings = {
'collections.saveNToList': 'Αποθήκευση {count} σε λίστα',
'collections.addedNToList': 'Αποθηκεύτηκαν {count} στο {name}',
'collections.noOwnLists': 'Δεν έχετε λίστες ακόμη',
'collections.saveToListHint': 'Τα διπλότυπα παραλείπονται αυτόματα',
'collections.role.label': 'Ρόλος',
'collections.role.viewer': 'Θεατής',
'collections.role.editor': 'Συντάκτης',
+4
View File
@@ -1,6 +1,10 @@
import type { TranslationStrings } from '../types';
const collection: TranslationStrings = {
'collections.saveNToList': '{count} mentése listába',
'collections.addedNToList': '{count} mentve ide: {name}',
'collections.noOwnLists': 'Még nincsenek listáid',
'collections.saveToListHint': 'A duplikátumok automatikusan kimaradnak',
'collections.role.label': 'Szerep',
'collections.role.viewer': 'Néző',
'collections.role.editor': 'Szerkesztő',
+4
View File
@@ -1,6 +1,10 @@
import type { TranslationStrings } from '../types';
const collection: TranslationStrings = {
'collections.saveNToList': 'Simpan {count} ke daftar',
'collections.addedNToList': '{count} disimpan ke {name}',
'collections.noOwnLists': 'Kamu belum punya daftar',
'collections.saveToListHint': 'Duplikat dilewati otomatis',
'collections.role.label': 'Peran',
'collections.role.viewer': 'Peninjau',
'collections.role.editor': 'Editor',
+4
View File
@@ -1,6 +1,10 @@
import type { TranslationStrings } from '../types';
const collection: TranslationStrings = {
'collections.saveNToList': 'Salva {count} in una lista',
'collections.addedNToList': '{count} salvati in {name}',
'collections.noOwnLists': 'Non hai ancora liste',
'collections.saveToListHint': 'I duplicati vengono ignorati automaticamente',
'collections.role.label': 'Ruolo',
'collections.role.viewer': 'Visualizzatore',
'collections.role.editor': 'Editore',
+4
View File
@@ -1,6 +1,10 @@
import type { TranslationStrings } from '../types';
const collection: TranslationStrings = {
'collections.saveNToList': '{count} 件をリストに保存',
'collections.addedNToList': '{count} 件を {name} に保存しました',
'collections.noOwnLists': 'リストがまだありません',
'collections.saveToListHint': '重複は自動的にスキップされます',
'collections.role.label': '役割',
'collections.role.viewer': '閲覧者',
'collections.role.editor': '編集者',
+4
View File
@@ -1,6 +1,10 @@
import type { TranslationStrings } from '../types';
const collection: TranslationStrings = {
'collections.saveNToList': '{count}개를 목록에 저장',
'collections.addedNToList': '{count}개를 {name}에 저장함',
'collections.noOwnLists': '아직 목록이 없습니다',
'collections.saveToListHint': '중복 항목은 자동으로 건너뜁니다',
'collections.role.label': '역할',
'collections.role.viewer': '뷰어',
'collections.role.editor': '편집자',
+4
View File
@@ -1,6 +1,10 @@
import type { TranslationStrings } from '../types';
const collection: TranslationStrings = {
'collections.saveNToList': '{count} in een lijst opslaan',
'collections.addedNToList': '{count} opgeslagen in {name}',
'collections.noOwnLists': 'Je hebt nog geen lijsten',
'collections.saveToListHint': 'Duplicaten worden automatisch overgeslagen',
'collections.role.label': 'Rol',
'collections.role.viewer': 'Kijker',
'collections.role.editor': 'Bewerker',
+4
View File
@@ -1,6 +1,10 @@
import type { TranslationStrings } from '../types';
const collection: TranslationStrings = {
'collections.saveNToList': 'Zapisz {count} na liście',
'collections.addedNToList': 'Zapisano {count} w {name}',
'collections.noOwnLists': 'Nie masz jeszcze list',
'collections.saveToListHint': 'Duplikaty są pomijane automatycznie',
'collections.role.label': 'Rola',
'collections.role.viewer': 'Widz',
'collections.role.editor': 'Edytor',
+4
View File
@@ -1,6 +1,10 @@
import type { TranslationStrings } from '../types';
const collection: TranslationStrings = {
'collections.saveNToList': 'Сохранить {count} в список',
'collections.addedNToList': 'Сохранено {count} в {name}',
'collections.noOwnLists': 'У вас пока нет списков',
'collections.saveToListHint': 'Дубликаты пропускаются автоматически',
'collections.role.label': 'Роль',
'collections.role.viewer': 'Наблюдатель',
'collections.role.editor': 'Редактор',
+4
View File
@@ -1,6 +1,10 @@
import type { TranslationStrings } from '../types';
const collection: TranslationStrings = {
'collections.saveNToList': 'Spara {count} i en lista',
'collections.addedNToList': 'Sparade {count} i {name}',
'collections.noOwnLists': 'Du har inga listor än',
'collections.saveToListHint': 'Dubbletter hoppas över automatiskt',
'collections.role.label': 'Roll',
'collections.role.viewer': 'Läsare',
'collections.role.editor': 'Redigerare',
+4
View File
@@ -1,6 +1,10 @@
import type { TranslationStrings } from '../types';
const collection: TranslationStrings = {
'collections.saveNToList': '{count} öğeyi bir listeye kaydet',
'collections.addedNToList': '{count} öğe {name} listesine kaydedildi',
'collections.noOwnLists': 'Henüz listeniz yok',
'collections.saveToListHint': 'Yinelenenler otomatik olarak atlanır',
'collections.role.label': 'Rol',
'collections.role.viewer': 'Görüntüleyici',
'collections.role.editor': 'Editör',
+4
View File
@@ -1,6 +1,10 @@
import type { TranslationStrings } from '../types';
const collection: TranslationStrings = {
'collections.saveNToList': 'Зберегти {count} до списку',
'collections.addedNToList': 'Збережено {count} до {name}',
'collections.noOwnLists': 'У вас ще немає списків',
'collections.saveToListHint': 'Дублікати пропускаються автоматично',
'collections.role.label': 'Роль',
'collections.role.viewer': 'Глядач',
'collections.role.editor': 'Редактор',
+4
View File
@@ -1,6 +1,10 @@
import type { TranslationStrings } from '../types';
const collection: TranslationStrings = {
'collections.saveNToList': 'Lưu {count} vào danh sách',
'collections.addedNToList': 'Đã lưu {count} vào {name}',
'collections.noOwnLists': 'Bạn chưa có danh sách nào',
'collections.saveToListHint': 'Các mục trùng lặp được bỏ qua tự động',
'collections.role.label': 'Vai trò',
'collections.role.viewer': 'Người xem',
'collections.role.editor': 'Người sửa',
+4
View File
@@ -1,6 +1,10 @@
import type { TranslationStrings } from '../types';
const collection: TranslationStrings = {
'collections.saveNToList': '將 {count} 個儲存到清單',
'collections.addedNToList': '已將 {count} 個儲存到 {name}',
'collections.noOwnLists': '你還沒有清單',
'collections.saveToListHint': '重複項目會被自動略過',
'collections.role.label': '角色',
'collections.role.viewer': '檢視者',
'collections.role.editor': '編輯者',
+4
View File
@@ -1,6 +1,10 @@
import type { TranslationStrings } from '../types';
const collection: TranslationStrings = {
'collections.saveNToList': '将 {count} 个保存到列表',
'collections.addedNToList': '已将 {count} 个保存到 {name}',
'collections.noOwnLists': '你还没有列表',
'collections.saveToListHint': '重复项会被自动跳过',
'collections.role.label': '角色',
'collections.role.viewer': '查看者',
'collections.role.editor': '编辑者',