mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 21:46:49 +00:00
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:
@@ -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); }
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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': 'محرر',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'Συντάκτης',
|
||||
|
||||
@@ -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ő',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': '編集者',
|
||||
|
||||
@@ -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': '편집자',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'Редактор',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'Редактор',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': '編輯者',
|
||||
|
||||
@@ -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': '编辑者',
|
||||
|
||||
Reference in New Issue
Block a user