Files
TREK/client/src/components/Packing/usePackingListPanel.ts
T
jubnl 3c040fab11 fix: miscellaneous bug fixes (#1139)
* fix(share): serve place thumbnails in shared trip links (#1100)

Google-sourced place photos are stored as image_url pointing at the
JWT-guarded /api/maps/place-photo/:placeId/bytes endpoint, so they 401
for an unauthenticated shared-trip viewer and render as broken images.

Rewrite place image_url values in the shared payload to a public,
token-scoped proxy (/api/shared/:token/place-photo/:placeId/bytes) and
add an unguarded SharedController route that validates the token and that
the place belongs to its trip before streaming the cached bytes. Mirrors
the existing JourneyPublicController precedent. No client changes needed.

* fix(atlas): replace Natural Earth with geoBoundaries for up-to-date regions (#1119)

Atlas sourced country and sub-national boundaries from Natural Earth's GitHub
`master` at runtime. That data is stale (e.g. it still shows Norway's pre-2020
counties such as Oppland/Hordaland) and depicts some contested territory in
unwanted ways (nvkelso/natural-earth-vector#391), so Natural Earth is dropped
entirely.

- Country borders (admin0) now come from the geoBoundaries CGAZ composite;
  sub-national regions (admin1) from per-country gbOpen, which carries ISO 3166-2
  codes. A new script (server/scripts/build-atlas-geo.mjs) normalizes and quantizes
  them into committed gzipped bundles under server/assets/atlas, read server-side at
  runtime (no network at boot, no GitHub CSP allowlist entry).
- New GET /addons/atlas/countries/geo serves the country layer; the client fetches
  it from the API instead of GitHub.
- A migration reconciles manually-marked visited_regions against the new bundle
  (valid code -> keep; region name still matches -> re-code; curated merge crosswalk
  for renamed reforms; else leave intact), with UNIQUE-safe dedup. bucket_list and
  visited_countries hold only invariant alpha-2 country codes, so they are untouched.
- Attribution added (NOTICE.md + README) per geoBoundaries CC BY 4.0.

Closes #1119

* fix(packing): make templates admin-only to create, usable by members

Creating a packing-list template was gated only by trip access, so any
trip member could create one from the Lists feature, while applying a
template silently failed for non-admins because the apply dropdown was
populated from the AdminGuard-protected /api/admin/packing-templates
endpoint.

- save-as-template now returns 403 for non-admins; the Save-as-Template
  button is hidden unless the user is an admin (both the TripPlanner
  toolbar and the inline packing header).
- add member-accessible GET /api/trips/:tripId/packing/templates so the
  apply dropdown lists templates for any trip member; client fetches
  from it instead of the admin endpoint.

Closes #1120
Closes #1121

* fix(packing): show bag tracking to non-admin members

The global Bag Tracking toggle was only readable via the admin-gated
GET /api/admin/bag-tracking, so non-admin trip members got 403 and the
weight fields, bag circles, and BAGS sidebar never rendered (#1124).

Surface the flag through the already-authenticated GET /api/addons
(loaded into the client addon store on app start for every user); the
packing hook reads it from the store instead of the admin endpoint. The
admin write path stays admin-gated and unchanged.
2026-06-09 16:02:37 +02:00

322 lines
13 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useMemo, useRef, useEffect } from 'react'
import type { ChangeEvent } from 'react'
import { useTripStore } from '../../store/tripStore'
import { useCanDo } from '../../store/permissionsStore'
import { useAuthStore } from '../../store/authStore'
import { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n'
import { packingApi, tripsApi } from '../../api/client'
import { useAddonStore } from '../../store/addonStore'
import type { PackingItem, PackingBag } from '../../types'
import { BAG_COLORS } from './packingListPanel.constants'
import { parseImportLines } from './packingListPanel.helpers'
export interface TripMember {
id: number
username: string
avatar?: string | null
avatar_url?: string | null
}
export interface CategoryAssignee {
user_id: number
username: string
avatar?: string | null
}
export interface PackingListPanelProps {
tripId: number
items: PackingItem[]
openImportSignal?: number
clearCheckedSignal?: number
saveTemplateSignal?: number
inlineHeader?: boolean
}
/**
* Packing list state: trip members + per-category assignees, category grouping
* and progress, item/category CRUD, bag tracking (weights + members) and the
* template apply/save + bulk CSV import flows (driven by signal props). The
* sections below render header, filters, the grouped list, the bag sidebar/
* modal and the import dialog.
*/
export function usePackingList({ tripId, items, openImportSignal = 0, clearCheckedSignal = 0, saveTemplateSignal = 0, inlineHeader = true }: PackingListPanelProps) {
const [filter, setFilter] = useState('alle') // 'alle' | 'offen' | 'erledigt'
const [addingCategory, setAddingCategory] = useState(false)
const [newCatName, setNewCatName] = useState('')
const { addPackingItem, updatePackingItem, deletePackingItem } = useTripStore()
const can = useCanDo()
const trip = useTripStore((s) => s.trip)
const canEdit = can('packing_edit', trip)
const isAdmin = useAuthStore((s) => s.user?.role === 'admin')
const toast = useToast()
const { t } = useTranslation()
// Trip members & category assignees
const [tripMembers, setTripMembers] = useState<TripMember[]>([])
const [categoryAssignees, setCategoryAssignees] = useState<Record<string, CategoryAssignee[]>>({})
useEffect(() => {
tripsApi.getMembers(tripId).then(data => {
const all: TripMember[] = []
if (data.owner) all.push({ id: data.owner.id, username: data.owner.username, avatar: data.owner.avatar_url })
if (data.members) all.push(...data.members.map((m: any) => ({ id: m.id, username: m.username, avatar: m.avatar_url })))
setTripMembers(all)
}).catch(() => {})
packingApi.getCategoryAssignees(tripId).then(data => {
setCategoryAssignees(data.assignees || {})
}).catch(() => {})
}, [tripId])
const handleSetAssignees = async (category: string, userIds: number[]) => {
try {
const data = await packingApi.setCategoryAssignees(tripId, category, userIds)
setCategoryAssignees(prev => ({ ...prev, [category]: data.assignees || [] }))
} catch {
toast.error(t('packing.toast.saveError'))
}
}
const allCategories = useMemo(() => {
const seen: string[] = []
for (const item of items) {
const cat = item.category || t('packing.defaultCategory')
if (!seen.includes(cat)) seen.push(cat)
}
return seen
}, [items, t])
const gruppiert = useMemo(() => {
const filtered = items.filter(i => {
if (filter === 'offen') return !i.checked
if (filter === 'erledigt') return i.checked
return true
})
const groups: Record<string, PackingItem[]> = {}
for (const item of filtered) {
const kat = item.category || t('packing.defaultCategory')
if (!groups[kat]) groups[kat] = []
groups[kat].push(item)
}
return groups
}, [items, filter, t])
const abgehakt = items.filter(i => i.checked).length
const fortschritt = items.length > 0 ? Math.round((abgehakt / items.length) * 100) : 0
const handleAddItemToCategory = async (category: string, name: string) => {
try {
await addPackingItem(tripId, { name, category })
} catch { toast.error(t('packing.toast.addError')) }
}
const handleAddNewCategory = async () => {
if (!newCatName.trim()) return
let catName = newCatName.trim()
// Allow duplicate display names — append invisible zero-width spaces to make unique internally
while (allCategories.includes(catName)) {
catName += ''
}
try {
await addPackingItem(tripId, { name: '...', category: catName })
setNewCatName('')
setAddingCategory(false)
} catch { toast.error(t('packing.toast.addError')) }
}
const handleRenameCategory = async (oldName: string, newName: string) => {
const toUpdate = items.filter(i => (i.category || t('packing.defaultCategory')) === oldName)
for (const item of toUpdate) {
await updatePackingItem(tripId, item.id, { category: newName })
}
}
const handleDeleteCategory = async (catItems: PackingItem[]) => {
let failed = false
for (const item of catItems) {
try { await deletePackingItem(tripId, item.id) } catch { failed = true }
}
if (failed) toast.error(t('packing.toast.deleteError'))
}
const handleClearChecked = async () => {
if (!confirm(t('packing.confirm.clearChecked', { count: abgehakt }))) return
let failed = false
for (const item of items.filter(i => i.checked)) {
try { await deletePackingItem(tripId, item.id) } catch { failed = true }
}
if (failed) toast.error(t('packing.toast.deleteError'))
}
// Bag tracking — the global toggle is a packing sub-flag surfaced to every
// authenticated user via the addon store (loaded on app start), not the
// admin-only endpoint, so non-admin members see weights/bags too.
const bagTrackingEnabled = useAddonStore(s => s.bagTracking)
const addonsLoaded = useAddonStore(s => s.loaded)
const loadAddons = useAddonStore(s => s.loadAddons)
const [bags, setBags] = useState<PackingBag[]>([])
const [newBagName, setNewBagName] = useState('')
const [showAddBag, setShowAddBag] = useState(false)
const [showBagModal, setShowBagModal] = useState(false)
useEffect(() => {
if (!addonsLoaded) loadAddons()
}, [addonsLoaded, loadAddons])
useEffect(() => {
if (bagTrackingEnabled) packingApi.listBags(tripId).then(r => setBags(r.bags || [])).catch(() => {})
}, [tripId, bagTrackingEnabled])
const handleCreateBag = async () => {
if (!newBagName.trim()) return
try {
const data = await packingApi.createBag(tripId, { name: newBagName.trim(), color: BAG_COLORS[bags.length % BAG_COLORS.length] })
setBags(prev => [...prev, data.bag])
setNewBagName(''); setShowAddBag(false)
} catch { toast.error(t('packing.toast.saveError')) }
}
const handleCreateBagByName = async (name: string): Promise<PackingBag | undefined> => {
try {
const data = await packingApi.createBag(tripId, { name, color: BAG_COLORS[bags.length % BAG_COLORS.length] })
setBags(prev => [...prev, data.bag])
return data.bag
} catch { toast.error(t('packing.toast.saveError')); return undefined }
}
const handleDeleteBag = async (bagId: number) => {
try {
await packingApi.deleteBag(tripId, bagId)
setBags(prev => prev.filter(b => b.id !== bagId))
} catch { toast.error(t('packing.toast.deleteError')) }
}
const handleUpdateBag = async (bagId: number, data: Record<string, any>) => {
try {
const result = await packingApi.updateBag(tripId, bagId, data)
setBags(prev => prev.map(b => b.id === bagId ? { ...b, ...result.bag } : b))
} catch { toast.error(t('common.error')) }
}
const handleSetBagMembers = async (bagId: number, userIds: number[]) => {
try {
const result = await packingApi.setBagMembers(tripId, bagId, userIds)
setBags(prev => prev.map(b => b.id === bagId ? { ...b, members: result.members } : b))
} catch { toast.error(t('common.error')) }
}
// Templates
const [availableTemplates, setAvailableTemplates] = useState<{ id: number; name: string; item_count: number }[]>([])
const [showTemplateDropdown, setShowTemplateDropdown] = useState(false)
const [applyingTemplate, setApplyingTemplate] = useState(false)
const [showSaveTemplate, setShowSaveTemplate] = useState(false)
const [saveTemplateName, setSaveTemplateName] = useState('')
const [showImportModal, setShowImportModal] = useState(false)
const [importText, setImportText] = useState('')
const lastHandledImportSignal = useRef(openImportSignal)
const lastHandledClearSignal = useRef(clearCheckedSignal)
const lastHandledSaveSignal = useRef(saveTemplateSignal)
useEffect(() => {
if (openImportSignal !== lastHandledImportSignal.current && openImportSignal > 0) {
setShowImportModal(true)
}
lastHandledImportSignal.current = openImportSignal
}, [openImportSignal])
useEffect(() => {
if (clearCheckedSignal !== lastHandledClearSignal.current && clearCheckedSignal > 0) {
handleClearChecked()
}
lastHandledClearSignal.current = clearCheckedSignal
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [clearCheckedSignal])
useEffect(() => {
if (saveTemplateSignal !== lastHandledSaveSignal.current && saveTemplateSignal > 0) {
setShowSaveTemplate(true)
}
lastHandledSaveSignal.current = saveTemplateSignal
}, [saveTemplateSignal])
const csvInputRef = useRef<HTMLInputElement>(null)
const templateDropdownRef = useRef<HTMLDivElement>(null)
useEffect(() => {
packingApi.listTemplates(tripId).then(d => setAvailableTemplates(d.templates || [])).catch(() => {})
}, [tripId])
useEffect(() => {
if (!showTemplateDropdown) return
const handler = (e: MouseEvent) => {
if (templateDropdownRef.current && !templateDropdownRef.current.contains(e.target as Node)) setShowTemplateDropdown(false)
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [showTemplateDropdown])
const handleApplyTemplate = async (templateId: number) => {
setApplyingTemplate(true)
try {
const data = await packingApi.applyTemplate(tripId, templateId)
useTripStore.setState(s => ({ packingItems: [...s.packingItems, ...(data.items || [])] }))
toast.success(t('packing.templateApplied', { count: data.count }))
setShowTemplateDropdown(false)
} catch {
toast.error(t('packing.templateError'))
} finally {
setApplyingTemplate(false)
}
}
const handleSaveAsTemplate = async () => {
if (!saveTemplateName.trim()) return
try {
await packingApi.saveAsTemplate(tripId, saveTemplateName.trim())
toast.success(t('packing.templateSaved'))
setShowSaveTemplate(false)
setSaveTemplateName('')
packingApi.listTemplates(tripId).then(d => setAvailableTemplates(d.templates || [])).catch(() => {})
} catch {
toast.error(t('common.error'))
}
}
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)
useTripStore.setState(s => ({ packingItems: [...s.packingItems, ...(result.items || [])] }))
toast.success(t('packing.importSuccess', { count: result.count }))
setImportText('')
setShowImportModal(false)
} catch { toast.error(t('packing.importError')) }
}
const handleCsvFile = (e: 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: "var(--font-system)" }
return {
tripId, items, inlineHeader, t, canEdit, isAdmin, font,
filter, setFilter, addingCategory, setAddingCategory, newCatName, setNewCatName,
tripMembers, categoryAssignees, handleSetAssignees, allCategories, gruppiert, abgehakt, fortschritt,
handleAddItemToCategory, handleAddNewCategory, handleRenameCategory, handleDeleteCategory, handleClearChecked,
bagTrackingEnabled, bags, newBagName, setNewBagName, showAddBag, setShowAddBag, showBagModal, setShowBagModal,
handleCreateBag, handleCreateBagByName, handleDeleteBag, handleUpdateBag, handleSetBagMembers,
availableTemplates, showTemplateDropdown, setShowTemplateDropdown, applyingTemplate,
showSaveTemplate, setShowSaveTemplate, saveTemplateName, setSaveTemplateName,
showImportModal, setShowImportModal, importText, setImportText,
csvInputRef, templateDropdownRef, handleApplyTemplate, handleSaveAsTemplate, parseImportLines, handleBulkImport, handleCsvFile,
}
}
export type PackingState = ReturnType<typeof usePackingList>