feat: bulk import for packing lists + complete i18n sync — closes #133

Packing list bulk import:
- Import button in packing list header opens a modal
- Paste items or load CSV/TXT file
- Format: Category, Name, Weight (g), Bag, checked/unchecked
- Bags are auto-created if they don't exist
- Server endpoint POST /packing/import with transaction

i18n sync:
- Added all missing translation keys to fr, es, nl, ru, zh, ar
- All 8 language files now have matching key sets
- Includes memories, vacay weekdays, packing import, settlement,
  GPX import, blur booking codes, transport timeline keys
This commit is contained in:
Maurice
2026-03-30 12:16:00 +02:00
parent 9a044ada28
commit e6c4c22a1d
11 changed files with 440 additions and 1 deletions

View File

@@ -112,6 +112,7 @@ export const assignmentsApi = {
export const packingApi = {
list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/packing`).then(r => r.data),
create: (tripId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/packing`, data).then(r => r.data),
bulkImport: (tripId: number | string, items: { name: string; category?: string; quantity?: number }[]) => apiClient.post(`/trips/${tripId}/packing/import`, { items }).then(r => r.data),
update: (tripId: number | string, id: number, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/packing/${id}`, data).then(r => r.data),
delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/packing/${id}`).then(r => r.data),
reorder: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/packing/reorder`, { orderedIds }).then(r => r.data),

View File

@@ -3,9 +3,10 @@ import { useTripStore } from '../../store/tripStore'
import { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n'
import { packingApi, tripsApi, adminApi } from '../../api/client'
import ReactDOM from 'react-dom'
import {
CheckSquare, Square, Trash2, Plus, ChevronDown, ChevronRight,
X, Pencil, Check, MoreHorizontal, CheckCheck, RotateCcw, Luggage, UserPlus, Package, FolderPlus,
X, Pencil, Check, MoreHorizontal, CheckCheck, RotateCcw, Luggage, UserPlus, Package, FolderPlus, Upload,
} from 'lucide-react'
import type { PackingItem } from '../../types'
@@ -727,6 +728,9 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
const [availableTemplates, setAvailableTemplates] = useState<{ id: number; name: string; item_count: number }[]>([])
const [showTemplateDropdown, setShowTemplateDropdown] = useState(false)
const [applyingTemplate, setApplyingTemplate] = useState(false)
const [showImportModal, setShowImportModal] = useState(false)
const [importText, setImportText] = useState('')
const csvInputRef = useRef<HTMLInputElement>(null)
const templateDropdownRef = useRef<HTMLDivElement>(null)
useEffect(() => {
@@ -757,6 +761,44 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
}
}
const parseImportLines = (text: string) => {
return text.split('\n').map(line => line.trim()).filter(Boolean).map(line => {
// Format: Category, Name, Weight (optional), Bag (optional), checked/unchecked (optional)
const parts = line.split(/[,;\t]/).map(s => s.trim())
if (parts.length >= 2) {
const category = parts[0]
const name = parts[1]
const weight_grams = parts[2] || undefined
const bag = parts[3] || undefined
const checked = parts[4]?.toLowerCase() === 'checked' || parts[4] === '1'
return { name, category, weight_grams, bag, checked }
}
// Single value = just a name
return { name: parts[0], category: undefined, weight_grams: undefined, bag: undefined, checked: false }
}).filter(i => i.name)
}
const handleBulkImport = async () => {
const parsed = parseImportLines(importText)
if (parsed.length === 0) { toast.error(t('packing.importEmpty')); return }
try {
const result = await packingApi.bulkImport(tripId, parsed)
toast.success(t('packing.importSuccess', { count: result.count }))
setImportText('')
setShowImportModal(false)
window.location.reload()
} catch { toast.error(t('packing.importError')) }
}
const handleCsvFile = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
e.target.value = ''
const reader = new FileReader()
reader.onload = () => { if (typeof reader.result === 'string') setImportText(reader.result) }
reader.readAsText(file)
}
const font = { fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }
return (
@@ -781,6 +823,13 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
<span className="sm:hidden">{t('packing.clearCheckedShort', { count: abgehakt })}</span>
</button>
)}
<button onClick={() => setShowImportModal(true)} style={{
display: 'flex', alignItems: 'center', gap: 5, padding: '5px 11px', borderRadius: 99,
border: '1px solid var(--border-primary)', fontSize: 12, fontWeight: 500, cursor: 'pointer',
fontFamily: 'inherit', background: 'var(--bg-card)', color: 'var(--text-muted)',
}}>
<Upload size={12} /> <span className="hidden sm:inline">{t('packing.import')}</span>
</button>
{availableTemplates.length > 0 && (
<div ref={templateDropdownRef} style={{ position: 'relative' }}>
<button onClick={() => setShowTemplateDropdown(v => !v)} disabled={applyingTemplate} style={{
@@ -1102,6 +1151,60 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
.assignee-chip:hover + .assignee-tooltip { opacity: 1 !important; }
.assignee-chip:hover { opacity: 0.7; }
`}</style>
{/* Bulk Import Modal */}
{showImportModal && ReactDOM.createPortal(
<div style={{
position: 'fixed', inset: 0, zIndex: 1000,
display: 'flex', alignItems: 'center', justifyContent: 'center',
background: 'rgba(0,0,0,0.3)', backdropFilter: 'blur(3px)',
}} onClick={() => setShowImportModal(false)}>
<div style={{
width: 420, maxHeight: '80vh', background: 'var(--bg-card)', borderRadius: 16,
boxShadow: '0 16px 48px rgba(0,0,0,0.22)', padding: '22px 22px 18px',
display: 'flex', flexDirection: 'column', gap: 14,
}} onClick={e => e.stopPropagation()}>
<div style={{ fontSize: 15, fontWeight: 600, color: 'var(--text-primary)' }}>{t('packing.importTitle')}</div>
<div style={{ fontSize: 12, color: 'var(--text-faint)', lineHeight: 1.5 }}>{t('packing.importHint')}</div>
<textarea
value={importText}
onChange={e => setImportText(e.target.value)}
rows={10}
placeholder={t('packing.importPlaceholder')}
style={{
width: '100%', border: '1px solid var(--border-primary)', borderRadius: 10,
padding: '10px 12px', fontSize: 13, fontFamily: 'monospace',
outline: 'none', boxSizing: 'border-box', color: 'var(--text-primary)',
background: 'var(--bg-input)', resize: 'vertical', lineHeight: 1.5,
}}
/>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<input ref={csvInputRef} type="file" accept=".csv,.txt" style={{ display: 'none' }} onChange={handleCsvFile} />
<button onClick={() => csvInputRef.current?.click()} style={{
display: 'flex', alignItems: 'center', gap: 5, padding: '5px 10px',
border: '1px dashed var(--border-primary)', borderRadius: 8, background: 'none',
fontSize: 11, color: 'var(--text-faint)', cursor: 'pointer', fontFamily: 'inherit',
}}>
<Upload size={11} /> {t('packing.importCsv')}
</button>
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button onClick={() => setShowImportModal(false)} style={{
fontSize: 12, background: 'none', border: '1px solid var(--border-primary)',
borderRadius: 8, padding: '6px 14px', cursor: 'pointer', color: 'var(--text-muted)', fontFamily: 'inherit',
}}>{t('common.cancel')}</button>
<button onClick={handleBulkImport} disabled={!importText.trim()} style={{
fontSize: 12, background: 'var(--accent)', color: 'var(--accent-text)',
border: 'none', borderRadius: 8, padding: '6px 16px', cursor: 'pointer', fontWeight: 600,
fontFamily: 'inherit', opacity: importText.trim() ? 1 : 0.5,
}}>{t('packing.importAction', { count: parseImportLines(importText).length })}</button>
</div>
</div>
</div>
</div>,
document.body
)}
</div>
)
}

View File

@@ -144,6 +144,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'settings.temperature': 'وحدة الحرارة',
'settings.timeFormat': 'تنسيق الوقت',
'settings.routeCalculation': 'حساب المسار',
'settings.blurBookingCodes': 'إخفاء رموز الحجز',
'settings.on': 'تشغيل',
'settings.off': 'إيقاف',
'settings.account': 'الحساب',
@@ -482,6 +483,14 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'vacay.carriedOver': 'من {year}',
'vacay.blockWeekends': 'حظر عطلة نهاية الأسبوع',
'vacay.blockWeekendsHint': 'منع إدخالات الإجازة يومي السبت والأحد',
'vacay.weekendDays': 'أيام عطلة نهاية الأسبوع',
'vacay.mon': 'الاثنين',
'vacay.tue': 'الثلاثاء',
'vacay.wed': 'الأربعاء',
'vacay.thu': 'الخميس',
'vacay.fri': 'الجمعة',
'vacay.sat': 'السبت',
'vacay.sun': 'الأحد',
'vacay.publicHolidays': 'العطل الرسمية',
'vacay.publicHolidaysHint': 'وضع علامة على العطل الرسمية في التقويم',
'vacay.selectCountry': 'اختر الدولة',
@@ -624,9 +633,18 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'dayplan.pdf': 'PDF',
'dayplan.pdfTooltip': 'تصدير خطة اليوم بصيغة PDF',
'dayplan.pdfError': 'فشل تصدير PDF',
'dayplan.cannotReorderTransport': 'لا يمكن إعادة ترتيب الحجوزات ذات الوقت الثابت',
'dayplan.confirmRemoveTimeTitle': 'إزالة الوقت؟',
'dayplan.confirmRemoveTimeBody': 'هذا المكان له وقت ثابت ({time}). نقله سيزيل الوقت ويسمح بالترتيب الحر.',
'dayplan.confirmRemoveTimeAction': 'إزالة الوقت ونقل',
'dayplan.cannotDropOnTimed': 'لا يمكن وضع العناصر بين الإدخالات المرتبطة بوقت',
'dayplan.cannotBreakChronology': 'سيؤدي هذا إلى كسر الترتيب الزمني للعناصر والحجوزات المجدولة',
// Places Sidebar
'places.addPlace': 'إضافة مكان/نشاط',
'places.importGpx': 'استيراد GPX',
'places.gpxImported': 'تم استيراد {count} مكان من GPX',
'places.gpxError': 'فشل استيراد GPX',
'places.assignToDay': 'إلى أي يوم تريد الإضافة؟',
'places.all': 'الكل',
'places.unplanned': 'غير مخطط',
@@ -731,6 +749,8 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'reservations.type.tour': 'جولة',
'reservations.type.other': 'أخرى',
'reservations.confirm.delete': 'هل تريد حذف الحجز "{name}"؟',
'reservations.confirm.deleteTitle': 'حذف الحجز؟',
'reservations.confirm.deleteBody': 'سيتم حذف "{name}" نهائيًا.',
'reservations.toast.updated': 'تم تحديث الحجز',
'reservations.toast.removed': 'تم حذف الحجز',
'reservations.toast.fileUploaded': 'تم رفع الملف',
@@ -788,6 +808,9 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'budget.paid': 'مدفوع',
'budget.open': 'مفتوح',
'budget.noMembers': 'لا أعضاء معينون',
'budget.settlement': 'التسوية',
'budget.settlementInfo': 'انقر على صورة العضو في بند الميزانية لتحديده باللون الأخضر — وهذا يعني أنه دفع. ثم تُظهر التسوية من يدين لمن وبكم.',
'budget.netBalances': 'الأرصدة الصافية',
// Files
'files.title': 'الملفات',
@@ -841,6 +864,15 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
// Packing
'packing.title': 'قائمة التجهيز',
'packing.empty': 'قائمة التجهيز فارغة',
'packing.import': 'استيراد',
'packing.importTitle': 'استيراد قائمة التعبئة',
'packing.importHint': 'عنصر واحد لكل سطر. يمكن إضافة الفئة والكمية مفصولة بفاصلة أو فاصلة منقوطة أو علامة تبويب: الاسم، الفئة، الكمية',
'packing.importPlaceholder': 'فرشاة أسنان\nواقي شمس، نظافة\nقمصان، ملابس، 5\nجواز سفر، مستندات',
'packing.importCsv': 'تحميل CSV/TXT',
'packing.importAction': 'استيراد {count}',
'packing.importSuccess': 'تم استيراد {count} عنصر',
'packing.importError': 'فشل الاستيراد',
'packing.importEmpty': 'لا توجد عناصر للاستيراد',
'packing.progress': '{packed} من {total} جُهّز ({percent}%)',
'packing.clearChecked': 'إزالة {count} محدد',
'packing.clearCheckedShort': 'إزالة {count}',
@@ -1144,6 +1176,19 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'memories.oldest': 'الأقدم أولاً',
'memories.newest': 'الأحدث أولاً',
'memories.allLocations': 'جميع المواقع',
'memories.addPhotos': 'إضافة صور',
'memories.selectPhotos': 'اختيار صور من Immich',
'memories.selectHint': 'انقر على الصور لتحديدها.',
'memories.selected': 'محدد',
'memories.addSelected': 'إضافة {count} صور',
'memories.alreadyAdded': 'تمت الإضافة',
'memories.private': 'خاص',
'memories.stopSharing': 'إيقاف المشاركة',
'memories.tripDates': 'تواريخ الرحلة',
'memories.allPhotos': 'جميع الصور',
'memories.confirmShareTitle': 'مشاركة مع أعضاء الرحلة؟',
'memories.confirmShareHint': '{count} صور ستكون مرئية لجميع أعضاء هذه الرحلة. يمكنك جعل الصور الفردية خاصة لاحقًا.',
'memories.confirmShareButton': 'مشاركة الصور',
// Collab Addon
'collab.tabs.chat': 'الدردشة',

View File

@@ -860,6 +860,15 @@ const de: Record<string, string | { name: string; category: string }[]> = {
// Packing
'packing.title': 'Packliste',
'packing.empty': 'Packliste ist leer',
'packing.import': 'Importieren',
'packing.importTitle': 'Packliste importieren',
'packing.importHint': 'Ein Eintrag pro Zeile. Format: Kategorie, Name, Gewicht in g (optional), Tasche (optional), checked/unchecked (optional)',
'packing.importPlaceholder': 'Hygiene, Zahnbürste\nKleidung, T-Shirts, 200\nDokumente, Reisepass, , Handgepäck\nElektronik, Ladekabel, 50, Koffer, checked',
'packing.importCsv': 'CSV/TXT laden',
'packing.importAction': '{count} importieren',
'packing.importSuccess': '{count} Einträge importiert',
'packing.importError': 'Import fehlgeschlagen',
'packing.importEmpty': 'Keine Einträge zum Importieren',
'packing.progress': '{packed} von {total} gepackt ({percent}%)',
'packing.clearChecked': '{count} abgehakte entfernen',
'packing.clearCheckedShort': '{count} entfernen',

View File

@@ -860,6 +860,15 @@ const en: Record<string, string | { name: string; category: string }[]> = {
// Packing
'packing.title': 'Packing List',
'packing.empty': 'Packing list is empty',
'packing.import': 'Import',
'packing.importTitle': 'Import Packing List',
'packing.importHint': 'One item per line. Format: Category, Name, Weight in g (optional), Bag (optional), checked/unchecked (optional)',
'packing.importPlaceholder': 'Hygiene, Toothbrush\nClothing, T-Shirts, 200\nDocuments, Passport, , Carry-on\nElectronics, Charger, 50, Suitcase, checked',
'packing.importCsv': 'Load CSV/TXT',
'packing.importAction': 'Import {count}',
'packing.importSuccess': '{count} items imported',
'packing.importError': 'Import failed',
'packing.importEmpty': 'No items to import',
'packing.progress': '{packed} of {total} packed ({percent}%)',
'packing.clearChecked': 'Remove {count} checked',
'packing.clearCheckedShort': 'Remove {count}',

View File

@@ -140,6 +140,7 @@ const es: Record<string, string> = {
'settings.temperature': 'Unidad de temperatura',
'settings.timeFormat': 'Formato de hora',
'settings.routeCalculation': 'Cálculo de ruta',
'settings.blurBookingCodes': 'Difuminar códigos de reserva',
'settings.on': 'Activado',
'settings.off': 'Desactivado',
'settings.account': 'Cuenta',
@@ -455,6 +456,14 @@ const es: Record<string, string> = {
'vacay.carriedOver': 'de {year}',
'vacay.blockWeekends': 'Bloquear fines de semana',
'vacay.blockWeekendsHint': 'Impide marcar vacaciones en sábados y domingos',
'vacay.weekendDays': 'Días de fin de semana',
'vacay.mon': 'Lun',
'vacay.tue': 'Mar',
'vacay.wed': 'Mié',
'vacay.thu': 'Jue',
'vacay.fri': 'Vie',
'vacay.sat': 'Sáb',
'vacay.sun': 'Dom',
'vacay.publicHolidays': 'Festivos',
'vacay.publicHolidaysHint': 'Marcar festivos en el calendario',
'vacay.selectCountry': 'Seleccionar país',
@@ -597,9 +606,18 @@ const es: Record<string, string> = {
'dayplan.pdf': 'PDF',
'dayplan.pdfTooltip': 'Exportar plan diario como PDF',
'dayplan.pdfError': 'No se pudo exportar el PDF',
'dayplan.cannotReorderTransport': 'Las reservas con hora fija no se pueden reordenar',
'dayplan.confirmRemoveTimeTitle': '¿Eliminar hora?',
'dayplan.confirmRemoveTimeBody': 'Este lugar tiene una hora fija ({time}). Al moverlo se eliminará la hora y se permitirá el orden libre.',
'dayplan.confirmRemoveTimeAction': 'Eliminar hora y mover',
'dayplan.cannotDropOnTimed': 'No se pueden colocar elementos entre entradas con hora fija',
'dayplan.cannotBreakChronology': 'Esto rompería el orden cronológico de los elementos y reservas programados',
// Places Sidebar
'places.addPlace': 'Añadir lugar/actividad',
'places.importGpx': 'Importar GPX',
'places.gpxImported': '{count} lugares importados desde GPX',
'places.gpxError': 'Error al importar GPX',
'places.assignToDay': '¿A qué día añadirlo?',
'places.all': 'Todo',
'places.unplanned': 'Sin planificar',
@@ -687,6 +705,8 @@ const es: Record<string, string> = {
'reservations.type.tour': 'Tour',
'reservations.type.other': 'Otro',
'reservations.confirm.delete': '¿Seguro que quieres eliminar la reserva "{name}"?',
'reservations.confirm.deleteTitle': '¿Eliminar reserva?',
'reservations.confirm.deleteBody': '« {name} » se eliminará permanentemente.',
'reservations.toast.updated': 'Reserva actualizada',
'reservations.toast.removed': 'Reserva eliminada',
'reservations.toast.fileUploaded': 'Archivo subido',
@@ -744,6 +764,9 @@ const es: Record<string, string> = {
'budget.paid': 'Pagado',
'budget.open': 'Abrir',
'budget.noMembers': 'No hay miembros asignados',
'budget.settlement': 'Liquidación',
'budget.settlementInfo': 'Haz clic en el avatar de un miembro en una partida del presupuesto para marcarlo en verde — esto significa que ha pagado. La liquidación muestra quién debe cuánto a quién.',
'budget.netBalances': 'Saldos netos',
// Files
'files.title': 'Archivos',
@@ -775,6 +798,15 @@ const es: Record<string, string> = {
// Packing
'packing.title': 'Lista de equipaje',
'packing.empty': 'La lista de equipaje está vacía',
'packing.import': 'Importar',
'packing.importTitle': 'Importar lista de equipaje',
'packing.importHint': 'Un elemento por línea. Categoría y cantidad opcionales separadas por coma, punto y coma o tabulación: Nombre, Categoría, Cantidad',
'packing.importPlaceholder': 'Cepillo de dientes\nProtector solar, Higiene\nCamisetas, Ropa, 5\nPasaporte, Documentos',
'packing.importCsv': 'Cargar CSV/TXT',
'packing.importAction': 'Importar {count}',
'packing.importSuccess': '{count} elementos importados',
'packing.importError': 'Error al importar',
'packing.importEmpty': 'Sin elementos para importar',
'packing.progress': '{packed} de {total} preparados ({percent}%)',
'packing.clearChecked': 'Eliminar {count} marcados',
'packing.clearCheckedShort': 'Eliminar {count}',
@@ -1091,6 +1123,19 @@ const es: Record<string, string> = {
'memories.oldest': 'Más antiguas',
'memories.newest': 'Más recientes',
'memories.allLocations': 'Todas las ubicaciones',
'memories.addPhotos': 'Añadir fotos',
'memories.selectPhotos': 'Seleccionar fotos de Immich',
'memories.selectHint': 'Toca las fotos para seleccionarlas.',
'memories.selected': 'seleccionado(s)',
'memories.addSelected': 'Añadir {count} fotos',
'memories.alreadyAdded': 'Añadido',
'memories.private': 'Privado',
'memories.stopSharing': 'Dejar de compartir',
'memories.tripDates': 'Fechas del viaje',
'memories.allPhotos': 'Todas las fotos',
'memories.confirmShareTitle': '¿Compartir con los miembros del viaje?',
'memories.confirmShareHint': '{count} fotos serán visibles para todos los miembros de este viaje. Puedes hacer fotos individuales privadas más tarde.',
'memories.confirmShareButton': 'Compartir fotos',
// Collab Addon
'collab.tabs.chat': 'Mensajes',

View File

@@ -139,6 +139,7 @@ const fr: Record<string, string> = {
'settings.temperature': 'Unité de température',
'settings.timeFormat': 'Format de l\'heure',
'settings.routeCalculation': 'Calcul d\'itinéraire',
'settings.blurBookingCodes': 'Masquer les codes de réservation',
'settings.on': 'Activé',
'settings.off': 'Désactivé',
'settings.account': 'Compte',
@@ -475,6 +476,14 @@ const fr: Record<string, string> = {
'vacay.carriedOver': 'de {year}',
'vacay.blockWeekends': 'Bloquer les week-ends',
'vacay.blockWeekendsHint': 'Empêcher les entrées de vacances les samedis et dimanches',
'vacay.weekendDays': 'Jours de week-end',
'vacay.mon': 'Lun',
'vacay.tue': 'Mar',
'vacay.wed': 'Mer',
'vacay.thu': 'Jeu',
'vacay.fri': 'Ven',
'vacay.sat': 'Sam',
'vacay.sun': 'Dim',
'vacay.publicHolidays': 'Jours fériés',
'vacay.publicHolidaysHint': 'Marquer les jours fériés dans le calendrier',
'vacay.selectCountry': 'Sélectionner un pays',
@@ -618,9 +627,18 @@ const fr: Record<string, string> = {
'dayplan.pdf': 'PDF',
'dayplan.pdfTooltip': 'Exporter le plan du jour en PDF',
'dayplan.pdfError': 'Échec de l\'export PDF',
'dayplan.cannotReorderTransport': 'Les réservations avec une heure fixe ne peuvent pas être réorganisées',
'dayplan.confirmRemoveTimeTitle': 'Supprimer l\'heure ?',
'dayplan.confirmRemoveTimeBody': 'Ce lieu a une heure fixe ({time}). Le déplacer supprimera l\'heure et permettra un tri libre.',
'dayplan.confirmRemoveTimeAction': 'Supprimer l\'heure et déplacer',
'dayplan.cannotDropOnTimed': 'Les éléments ne peuvent pas être placés entre des entrées à heure fixe',
'dayplan.cannotBreakChronology': 'Cela briserait l\'ordre chronologique des éléments et réservations planifiés',
// Places Sidebar
'places.addPlace': 'Ajouter un lieu/activité',
'places.importGpx': 'Importer GPX',
'places.gpxImported': '{count} lieux importés depuis GPX',
'places.gpxError': 'L\'import GPX a échoué',
'places.assignToDay': 'Ajouter à quel jour ?',
'places.all': 'Tous',
'places.unplanned': 'Non planifiés',
@@ -724,6 +742,8 @@ const fr: Record<string, string> = {
'reservations.type.tour': 'Visite',
'reservations.type.other': 'Autre',
'reservations.confirm.delete': 'Voulez-vous vraiment supprimer la réservation « {name} » ?',
'reservations.confirm.deleteTitle': 'Supprimer la réservation ?',
'reservations.confirm.deleteBody': '« {name} » sera définitivement supprimé.',
'reservations.toast.updated': 'Réservation mise à jour',
'reservations.toast.removed': 'Réservation supprimée',
'reservations.toast.fileUploaded': 'Fichier téléversé',
@@ -781,6 +801,9 @@ const fr: Record<string, string> = {
'budget.paid': 'Payé',
'budget.open': 'Ouvert',
'budget.noMembers': 'Aucun membre assigné',
'budget.settlement': 'Règlement',
'budget.settlementInfo': 'Cliquez sur l\'avatar d\'un membre sur un poste budgétaire pour le marquer en vert — cela signifie qu\'il a payé. Le règlement indique ensuite qui doit combien à qui.',
'budget.netBalances': 'Soldes nets',
// Files
'files.title': 'Fichiers',
@@ -834,6 +857,15 @@ const fr: Record<string, string> = {
// Packing
'packing.title': 'Liste de bagages',
'packing.empty': 'La liste de bagages est vide',
'packing.import': 'Importer',
'packing.importTitle': 'Importer la liste',
'packing.importHint': 'Un élément par ligne. Catégorie et quantité optionnelles séparées par virgule, point-virgule ou tabulation : Nom, Catégorie, Quantité',
'packing.importPlaceholder': 'Brosse à dents\nCrème solaire, Hygiène\nT-Shirts, Vêtements, 5\nPasseport, Documents',
'packing.importCsv': 'Charger CSV/TXT',
'packing.importAction': 'Importer {count}',
'packing.importSuccess': '{count} éléments importés',
'packing.importError': 'Échec de l\'import',
'packing.importEmpty': 'Aucun élément à importer',
'packing.progress': '{packed} sur {total} emballés ({percent} %)',
'packing.clearChecked': 'Supprimer {count} cochés',
'packing.clearCheckedShort': 'Supprimer {count}',
@@ -1137,6 +1169,19 @@ const fr: Record<string, string> = {
'memories.oldest': 'Plus anciennes',
'memories.newest': 'Plus récentes',
'memories.allLocations': 'Tous les lieux',
'memories.addPhotos': 'Ajouter des photos',
'memories.selectPhotos': 'Sélectionner des photos depuis Immich',
'memories.selectHint': 'Appuyez sur les photos pour les sélectionner.',
'memories.selected': 'sélectionné(s)',
'memories.addSelected': 'Ajouter {count} photos',
'memories.alreadyAdded': 'Ajouté',
'memories.private': 'Privé',
'memories.stopSharing': 'Arrêter le partage',
'memories.tripDates': 'Dates du voyage',
'memories.allPhotos': 'Toutes les photos',
'memories.confirmShareTitle': 'Partager avec les membres du voyage ?',
'memories.confirmShareHint': '{count} photos seront visibles par tous les membres de ce voyage. Vous pourrez rendre des photos individuelles privées plus tard.',
'memories.confirmShareButton': 'Partager les photos',
// Collab Addon
'collab.tabs.chat': 'Chat',

View File

@@ -139,6 +139,7 @@ const nl: Record<string, string> = {
'settings.temperature': 'Temperatuureenheid',
'settings.timeFormat': 'Tijdnotatie',
'settings.routeCalculation': 'Routeberekening',
'settings.blurBookingCodes': 'Boekingscodes vervagen',
'settings.on': 'Aan',
'settings.off': 'Uit',
'settings.account': 'Account',
@@ -475,6 +476,14 @@ const nl: Record<string, string> = {
'vacay.carriedOver': 'van {year}',
'vacay.blockWeekends': 'Weekenden blokkeren',
'vacay.blockWeekendsHint': 'Voorkom vakantie-invoeren op zaterdag en zondag',
'vacay.weekendDays': 'Weekenddagen',
'vacay.mon': 'Ma',
'vacay.tue': 'Di',
'vacay.wed': 'Wo',
'vacay.thu': 'Do',
'vacay.fri': 'Vr',
'vacay.sat': 'Za',
'vacay.sun': 'Zo',
'vacay.publicHolidays': 'Feestdagen',
'vacay.publicHolidaysHint': 'Markeer feestdagen in de kalender',
'vacay.selectCountry': 'Selecteer land',
@@ -618,9 +627,18 @@ const nl: Record<string, string> = {
'dayplan.pdf': 'PDF',
'dayplan.pdfTooltip': 'Dagplan exporteren als PDF',
'dayplan.pdfError': 'PDF-export mislukt',
'dayplan.cannotReorderTransport': 'Boekingen met een vast tijdstip kunnen niet worden verplaatst',
'dayplan.confirmRemoveTimeTitle': 'Tijd verwijderen?',
'dayplan.confirmRemoveTimeBody': 'Deze plek heeft een vast tijdstip ({time}). Verplaatsen verwijdert het tijdstip en maakt vrije sortering mogelijk.',
'dayplan.confirmRemoveTimeAction': 'Tijd verwijderen en verplaatsen',
'dayplan.cannotDropOnTimed': 'Items kunnen niet tussen tijdgebonden items worden geplaatst',
'dayplan.cannotBreakChronology': 'Dit zou de chronologische volgorde van geplande items en boekingen doorbreken',
// Places Sidebar
'places.addPlace': 'Plaats/activiteit toevoegen',
'places.importGpx': 'GPX importeren',
'places.gpxImported': '{count} plaatsen geïmporteerd uit GPX',
'places.gpxError': 'GPX-import mislukt',
'places.assignToDay': 'Aan welke dag toevoegen?',
'places.all': 'Alle',
'places.unplanned': 'Ongepland',
@@ -724,6 +742,8 @@ const nl: Record<string, string> = {
'reservations.type.tour': 'Rondleiding',
'reservations.type.other': 'Overig',
'reservations.confirm.delete': 'Weet je zeker dat je de reservering "{name}" wilt verwijderen?',
'reservations.confirm.deleteTitle': 'Boeking verwijderen?',
'reservations.confirm.deleteBody': '"{name}" wordt permanent verwijderd.',
'reservations.toast.updated': 'Reservering bijgewerkt',
'reservations.toast.removed': 'Reservering verwijderd',
'reservations.toast.fileUploaded': 'Bestand geüpload',
@@ -781,6 +801,9 @@ const nl: Record<string, string> = {
'budget.paid': 'Betaald',
'budget.open': 'Open',
'budget.noMembers': 'Geen leden toegewezen',
'budget.settlement': 'Afrekening',
'budget.settlementInfo': 'Klik op de avatar van een lid bij een budgetpost om deze groen te markeren — dit betekent dat diegene heeft betaald. De afrekening toont vervolgens wie wie hoeveel verschuldigd is.',
'budget.netBalances': 'Nettosaldi',
// Files
'files.title': 'Bestanden',
@@ -834,6 +857,15 @@ const nl: Record<string, string> = {
// Packing
'packing.title': 'Paklijst',
'packing.empty': 'Paklijst is leeg',
'packing.import': 'Importeren',
'packing.importTitle': 'Paklijst importeren',
'packing.importHint': 'Eén item per regel. Optioneel categorie en aantal gescheiden door komma, puntkomma of tab: Naam, Categorie, Aantal',
'packing.importPlaceholder': 'Tandenborstel\nZonnebrand, Hygiëne\nT-Shirts, Kleding, 5\nPaspoort, Documenten',
'packing.importCsv': 'CSV/TXT laden',
'packing.importAction': '{count} importeren',
'packing.importSuccess': '{count} items geïmporteerd',
'packing.importError': 'Import mislukt',
'packing.importEmpty': 'Geen items om te importeren',
'packing.progress': '{packed} van {total} ingepakt ({percent}%)',
'packing.clearChecked': '{count} aangevinkte verwijderen',
'packing.clearCheckedShort': '{count} verwijderen',
@@ -1137,6 +1169,19 @@ const nl: Record<string, string> = {
'memories.oldest': 'Oudste eerst',
'memories.newest': 'Nieuwste eerst',
'memories.allLocations': 'Alle locaties',
'memories.addPhotos': 'Foto\'s toevoegen',
'memories.selectPhotos': 'Selecteer foto\'s uit Immich',
'memories.selectHint': 'Tik op foto\'s om ze te selecteren.',
'memories.selected': 'geselecteerd',
'memories.addSelected': '{count} foto\'s toevoegen',
'memories.alreadyAdded': 'Toegevoegd',
'memories.private': 'Privé',
'memories.stopSharing': 'Delen stoppen',
'memories.tripDates': 'Reisdata',
'memories.allPhotos': 'Alle foto\'s',
'memories.confirmShareTitle': 'Delen met reisgenoten?',
'memories.confirmShareHint': '{count} foto\'s worden zichtbaar voor alle leden van deze reis. Je kunt individuele foto\'s later privé maken.',
'memories.confirmShareButton': 'Foto\'s delen',
// Collab Addon
'collab.tabs.chat': 'Chat',

View File

@@ -139,6 +139,7 @@ const ru: Record<string, string> = {
'settings.temperature': 'Единица температуры',
'settings.timeFormat': 'Формат времени',
'settings.routeCalculation': 'Расчёт маршрута',
'settings.blurBookingCodes': 'Скрыть коды бронирования',
'settings.on': 'Вкл.',
'settings.off': 'Выкл.',
'settings.account': 'Аккаунт',
@@ -475,6 +476,14 @@ const ru: Record<string, string> = {
'vacay.carriedOver': 'из {year}',
'vacay.blockWeekends': 'Блокировать выходные',
'vacay.blockWeekendsHint': 'Запретить записи об отпуске в субботу и воскресенье',
'vacay.weekendDays': 'Выходные дни',
'vacay.mon': 'Пн',
'vacay.tue': 'Вт',
'vacay.wed': 'Ср',
'vacay.thu': 'Чт',
'vacay.fri': 'Пт',
'vacay.sat': 'Сб',
'vacay.sun': 'Вс',
'vacay.publicHolidays': 'Государственные праздники',
'vacay.publicHolidaysHint': 'Отмечать государственные праздники в календаре',
'vacay.selectCountry': 'Выберите страну',
@@ -618,9 +627,18 @@ const ru: Record<string, string> = {
'dayplan.pdf': 'PDF',
'dayplan.pdfTooltip': 'Экспортировать план дня в PDF',
'dayplan.pdfError': 'Ошибка экспорта PDF',
'dayplan.cannotReorderTransport': 'Бронирования с фиксированным временем нельзя перемещать',
'dayplan.confirmRemoveTimeTitle': 'Удалить время?',
'dayplan.confirmRemoveTimeBody': 'У этого места фиксированное время ({time}). При перемещении время будет удалено, и станет доступна свободная сортировка.',
'dayplan.confirmRemoveTimeAction': 'Удалить время и переместить',
'dayplan.cannotDropOnTimed': 'Элементы нельзя размещать между записями с фиксированным временем',
'dayplan.cannotBreakChronology': 'Это нарушит хронологический порядок запланированных элементов и бронирований',
// Places Sidebar
'places.addPlace': 'Добавить место/активность',
'places.importGpx': 'Импорт GPX',
'places.gpxImported': '{count} мест импортировано из GPX',
'places.gpxError': 'Ошибка импорта GPX',
'places.assignToDay': 'Добавить в какой день?',
'places.all': 'Все',
'places.unplanned': 'Незапланированные',
@@ -724,6 +742,8 @@ const ru: Record<string, string> = {
'reservations.type.tour': 'Экскурсия',
'reservations.type.other': 'Другое',
'reservations.confirm.delete': 'Вы уверены, что хотите удалить бронирование «{name}»?',
'reservations.confirm.deleteTitle': 'Удалить бронирование?',
'reservations.confirm.deleteBody': '«{name}» будет удалено навсегда.',
'reservations.toast.updated': 'Бронирование обновлено',
'reservations.toast.removed': 'Бронирование удалено',
'reservations.toast.fileUploaded': 'Файл загружен',
@@ -781,6 +801,9 @@ const ru: Record<string, string> = {
'budget.paid': 'Оплачено',
'budget.open': 'Не оплачено',
'budget.noMembers': 'Участники не назначены',
'budget.settlement': 'Взаиморасчёт',
'budget.settlementInfo': 'Нажмите на аватар участника в строке бюджета, чтобы отметить его зелёным — это значит, что он заплатил. Взаиморасчёт покажет, кто кому и сколько должен.',
'budget.netBalances': 'Чистые балансы',
// Files
'files.title': 'Файлы',
@@ -834,6 +857,15 @@ const ru: Record<string, string> = {
// Packing
'packing.title': 'Список вещей',
'packing.empty': 'Список вещей пуст',
'packing.import': 'Импорт',
'packing.importTitle': 'Импорт списка вещей',
'packing.importHint': 'Один предмет на строку. Категория и количество — через запятую, точку с запятой или табуляцию: Название, Категория, Количество',
'packing.importPlaceholder': 'Зубная щётка\nСолнцезащитный крем, Гигиена\nФутболки, Одежда, 5\nПаспорт, Документы',
'packing.importCsv': 'Загрузить CSV/TXT',
'packing.importAction': 'Импортировать {count}',
'packing.importSuccess': '{count} предметов импортировано',
'packing.importError': 'Ошибка импорта',
'packing.importEmpty': 'Нет предметов для импорта',
'packing.progress': '{packed} из {total} собрано ({percent}%)',
'packing.clearChecked': 'Удалить {count} отмеченных',
'packing.clearCheckedShort': 'Удалить {count}',
@@ -1137,6 +1169,19 @@ const ru: Record<string, string> = {
'memories.oldest': 'Сначала старые',
'memories.newest': 'Сначала новые',
'memories.allLocations': 'Все места',
'memories.addPhotos': 'Добавить фото',
'memories.selectPhotos': 'Выбрать фото из Immich',
'memories.selectHint': 'Нажмите на фото, чтобы выбрать их.',
'memories.selected': 'выбрано',
'memories.addSelected': 'Добавить {count} фото',
'memories.alreadyAdded': 'Добавлено',
'memories.private': 'Приватное',
'memories.stopSharing': 'Прекратить доступ',
'memories.tripDates': 'Даты поездки',
'memories.allPhotos': 'Все фото',
'memories.confirmShareTitle': 'Поделиться с участниками поездки?',
'memories.confirmShareHint': '{count} фото станут видны всем участникам этой поездки. Вы сможете сделать отдельные фото приватными позже.',
'memories.confirmShareButton': 'Поделиться фото',
// Collab Addon
'collab.tabs.chat': 'Чат',

View File

@@ -139,6 +139,7 @@ const zh: Record<string, string> = {
'settings.temperature': '温度单位',
'settings.timeFormat': '时间格式',
'settings.routeCalculation': '路线计算',
'settings.blurBookingCodes': '模糊预订代码',
'settings.on': '开',
'settings.off': '关',
'settings.account': '账户',
@@ -475,6 +476,14 @@ const zh: Record<string, string> = {
'vacay.carriedOver': '从 {year} 结转',
'vacay.blockWeekends': '锁定周末',
'vacay.blockWeekendsHint': '禁止在周六和周日安排假期',
'vacay.weekendDays': '周末',
'vacay.mon': '周一',
'vacay.tue': '周二',
'vacay.wed': '周三',
'vacay.thu': '周四',
'vacay.fri': '周五',
'vacay.sat': '周六',
'vacay.sun': '周日',
'vacay.publicHolidays': '公共假日',
'vacay.publicHolidaysHint': '在日历中标记公共假日',
'vacay.selectCountry': '选择国家',
@@ -618,9 +627,18 @@ const zh: Record<string, string> = {
'dayplan.pdf': 'PDF',
'dayplan.pdfTooltip': '导出当天计划为 PDF',
'dayplan.pdfError': 'PDF 导出失败',
'dayplan.cannotReorderTransport': '有固定时间的预订无法重新排序',
'dayplan.confirmRemoveTimeTitle': '移除时间?',
'dayplan.confirmRemoveTimeBody': '此地点有固定时间({time})。移动后将移除时间并允许自由排序。',
'dayplan.confirmRemoveTimeAction': '移除时间并移动',
'dayplan.cannotDropOnTimed': '无法将项目放置在有固定时间的条目之间',
'dayplan.cannotBreakChronology': '这将打乱已计划项目和预订的时间顺序',
// Places Sidebar
'places.addPlace': '添加地点/活动',
'places.importGpx': '导入 GPX',
'places.gpxImported': '已从 GPX 导入 {count} 个地点',
'places.gpxError': 'GPX 导入失败',
'places.assignToDay': '添加到哪一天?',
'places.all': '全部',
'places.unplanned': '未规划',
@@ -724,6 +742,8 @@ const zh: Record<string, string> = {
'reservations.type.tour': '旅游团',
'reservations.type.other': '其他',
'reservations.confirm.delete': '确定要删除预订「{name}」吗?',
'reservations.confirm.deleteTitle': '删除预订?',
'reservations.confirm.deleteBody': '"{name}" 将被永久删除。',
'reservations.toast.updated': '预订已更新',
'reservations.toast.removed': '预订已删除',
'reservations.toast.fileUploaded': '文件已上传',
@@ -781,6 +801,9 @@ const zh: Record<string, string> = {
'budget.paid': '已支付',
'budget.open': '未支付',
'budget.noMembers': '未分配成员',
'budget.settlement': '结算',
'budget.settlementInfo': '点击预算项目上的成员头像将其标记为绿色——表示该成员已付款。结算会显示谁欠谁多少。',
'budget.netBalances': '净余额',
// Files
'files.title': '文件',
@@ -834,6 +857,15 @@ const zh: Record<string, string> = {
// Packing
'packing.title': '行李清单',
'packing.empty': '行李清单为空',
'packing.import': '导入',
'packing.importTitle': '导入装箱清单',
'packing.importHint': '每行一个物品。可选用逗号、分号或制表符分隔类别和数量:名称, 类别, 数量',
'packing.importPlaceholder': '牙刷\n防晒霜, 卫生\nT恤, 衣物, 5\n护照, 证件',
'packing.importCsv': '加载 CSV/TXT',
'packing.importAction': '导入 {count}',
'packing.importSuccess': '已导入 {count} 项',
'packing.importError': '导入失败',
'packing.importEmpty': '没有可导入的项目',
'packing.progress': '已打包 {packed}/{total}{percent}%',
'packing.clearChecked': '移除 {count} 个已勾选',
'packing.clearCheckedShort': '移除 {count} 个',
@@ -1137,6 +1169,19 @@ const zh: Record<string, string> = {
'memories.oldest': '最早优先',
'memories.newest': '最新优先',
'memories.allLocations': '所有地点',
'memories.addPhotos': '添加照片',
'memories.selectPhotos': '从 Immich 选择照片',
'memories.selectHint': '点击照片以选择。',
'memories.selected': '已选择',
'memories.addSelected': '添加 {count} 张照片',
'memories.alreadyAdded': '已添加',
'memories.private': '私密',
'memories.stopSharing': '停止分享',
'memories.tripDates': '旅行日期',
'memories.allPhotos': '所有照片',
'memories.confirmShareTitle': '与旅行成员分享?',
'memories.confirmShareHint': '{count} 张照片将对本次旅行的所有成员可见。你可以稍后将单张照片设为私密。',
'memories.confirmShareButton': '分享照片',
// Collab Addon
'collab.tabs.chat': '聊天',