feat(lists): reorder packing/to-do lists and private packing items (#969, #858)

Add drag-to-reorder to the packing and to-do lists, mirroring the budget
panel's native HTML5 drag pattern. A drag within a filtered/grouped view is
mapped back onto the global order so untouched items keep their place, and the
order persists optimistically via the existing reorder endpoints.

Packing items can now be marked private (#858): a private item is visible only
to its owner. createItem/bulkImport stamp the owner, listItems filters by the
viewer, and the WebSocket broadcasts are scoped to the owner so a private item
never reaches another member's screen — including the public/private toggle
transitions. Owners get a lock toggle and a private indicator on their items.
This commit is contained in:
Maurice
2026-06-30 13:55:03 +02:00
committed by Maurice
parent 7eac5a5a02
commit fac2393388
46 changed files with 726 additions and 76 deletions
@@ -27,10 +27,34 @@ interface KategorieGruppeProps {
bags?: PackingBag[]
onCreateBag: (name: string) => Promise<PackingBag | undefined>
canEdit?: boolean
// Drag-to-reorder (#969): the full ordered item list + a persist callback. The
// order is global, so a within-category drag is mapped back onto the full list.
allItems: PackingItem[]
onReorder: (orderedIds: number[]) => void
}
export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, onDeleteAll, onDeleteItem, onAddItem, assignees, tripMembers, onSetAssignees, bagTrackingEnabled, bags, onCreateBag, canEdit = true }: KategorieGruppeProps) {
export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, onDeleteAll, onDeleteItem, onAddItem, assignees, tripMembers, onSetAssignees, bagTrackingEnabled, bags, onCreateBag, canEdit = true, allItems, onReorder }: KategorieGruppeProps) {
const [offen, setOffen] = useState(true)
const [dragId, setDragId] = useState<number | null>(null)
const [overId, setOverId] = useState<number | null>(null)
const handleReorderDrop = (targetId: number) => {
const from = dragId
setDragId(null); setOverId(null)
if (from == null || from === targetId) return
const catOrder = items.map(i => i.id)
const fi = catOrder.indexOf(from)
const ti = catOrder.indexOf(targetId)
if (fi < 0 || ti < 0) return
catOrder.splice(fi, 1)
catOrder.splice(ti, 0, from)
// Slot the reordered category ids back into the positions this category's
// items occupy in the global list, leaving every other category untouched.
const catIds = new Set(items.map(i => i.id))
let ci = 0
const globalIds = allItems.map(i => (catIds.has(i.id) ? catOrder[ci++] : i.id))
onReorder(globalIds)
}
const [editingName, setEditingName] = useState(false)
const [editKatName, setEditKatName] = useState(kategorie)
const [showMenu, setShowMenu] = useState(false)
@@ -232,7 +256,15 @@ export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRen
{offen && (
<div style={{ padding: '4px 4px 6px' }}>
{items.map(item => (
<ArtikelZeile key={item.id} item={item} tripId={tripId} categories={allCategories} onCategoryChange={() => {}} onDelete={onDeleteItem} bagTrackingEnabled={bagTrackingEnabled} bags={bags} onCreateBag={onCreateBag} canEdit={canEdit} />
<ArtikelZeile key={item.id} item={item} tripId={tripId} categories={allCategories} onCategoryChange={() => {}} onDelete={onDeleteItem} bagTrackingEnabled={bagTrackingEnabled} bags={bags} onCreateBag={onCreateBag} canEdit={canEdit}
drag={canEdit ? {
isDragging: dragId === item.id,
isOver: overId === item.id && dragId !== null && dragId !== item.id,
onStart: (id) => { setDragId(id); setOverId(null) },
onOver: (id) => setOverId(id),
onEnd: () => { setDragId(null); setOverId(null) },
onDrop: handleReorderDrop,
} : undefined} />
))}
{/* Inline add item */}
{canEdit && (showAddItem ? (
@@ -1,9 +1,10 @@
import { useState } from 'react'
import { useTripStore } from '../../store/tripStore'
import { useAuthStore } from '../../store/authStore'
import { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n'
import {
CheckSquare, Square, Trash2, Plus, Pencil, Package,
CheckSquare, Square, Trash2, Plus, Pencil, Package, GripVertical, Lock, Unlock,
} from 'lucide-react'
import type { PackingItem, PackingBag } from '../../types'
import { katColor } from './packingListPanel.helpers'
@@ -20,9 +21,18 @@ interface ArtikelZeileProps {
bags?: PackingBag[]
onCreateBag: (name: string) => Promise<PackingBag | undefined>
canEdit?: boolean
// Drag-to-reorder (#969) — wired by the category group, which owns the order.
drag?: {
isDragging: boolean
isOver: boolean
onStart: (id: number) => void
onOver: (id: number) => void
onEnd: () => void
onDrop: (targetId: number) => void
}
}
export function ArtikelZeile({ item, tripId, categories, onCategoryChange, onDelete, bagTrackingEnabled, bags = [], onCreateBag, canEdit = true }: ArtikelZeileProps) {
export function ArtikelZeile({ item, tripId, categories, onCategoryChange, onDelete, bagTrackingEnabled, bags = [], onCreateBag, canEdit = true, drag }: ArtikelZeileProps) {
const isPlaceholder = item.name === PACKING_PLACEHOLDER_NAME
const [editing, setEditing] = useState(false)
const [editName, setEditName] = useState(isPlaceholder ? '' : item.name)
@@ -32,11 +42,23 @@ export function ArtikelZeile({ item, tripId, categories, onCategoryChange, onDel
const [bagInlineCreate, setBagInlineCreate] = useState(false)
const [bagInlineName, setBagInlineName] = useState('')
const { togglePackingItem, updatePackingItem, deletePackingItem } = useTripStore()
const currentUserId = useAuthStore(s => s.user?.id)
const toast = useToast()
const { t } = useTranslation()
const isPrivate = !!item.is_private
// Only the owner may toggle privacy (#858). A private item is only ever rendered
// to its owner, so the toggle on those is always the owner's; for shared items
// it shows only to the member who created them — you don't privatize others' items.
const canTogglePrivacy = canEdit && !isPlaceholder && currentUserId != null && item.owner_id === currentUserId
const handleToggle = () => togglePackingItem(tripId, item.id, !item.checked)
const handleTogglePrivacy = async () => {
try { await updatePackingItem(tripId, item.id, { is_private: isPrivate ? 0 : 1 }) }
catch { toast.error(t('packing.toast.saveError')) }
}
const handleSaveName = async () => {
if (!editName.trim()) { setEditing(false); setEditName(isPlaceholder ? '' : item.name); return }
try { await updatePackingItem(tripId, item.id, { name: editName.trim() }); setEditing(false) }
@@ -58,18 +80,36 @@ export function ArtikelZeile({ item, tripId, categories, onCategoryChange, onDel
catch { toast.error(t('common.error')) }
}
const canDrag = canEdit && !isPlaceholder && !!drag
return (
<div
className="group"
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => { setHovered(false); setShowCatPicker(false); setShowBagPicker(false) }}
onDragOver={canDrag ? (e => { if (drag!.isDragging || true) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; drag!.onOver(item.id) } }) : undefined}
onDragLeave={canDrag ? (e => { if (!e.currentTarget.contains(e.relatedTarget as Node)) drag!.onOver(-1) }) : undefined}
onDrop={canDrag ? (e => { e.preventDefault(); e.stopPropagation(); drag!.onDrop(item.id) }) : undefined}
style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '6px 10px', borderRadius: 10, position: 'relative',
background: hovered ? 'var(--bg-secondary)' : 'transparent',
transition: 'background 0.1s',
opacity: drag?.isDragging ? 0.4 : 1,
boxShadow: drag?.isOver ? 'inset 3px 0 0 0 var(--accent)' : 'none',
transition: 'background 0.1s, opacity 0.15s',
}}
>
{canDrag && (
<div
draggable
onDragStart={e => { e.stopPropagation(); e.dataTransfer.effectAllowed = 'move'; drag!.onStart(item.id) }}
onDragEnd={() => drag!.onEnd()}
title=""
style={{ cursor: 'grab', display: 'flex', alignItems: 'center', color: 'var(--text-faint)', flexShrink: 0, opacity: hovered ? 1 : 0.35, transition: 'opacity 0.15s' }}
>
<GripVertical size={13} />
</div>
)}
<button onClick={handleToggle} style={{
flexShrink: 0, background: 'none', border: 'none', cursor: 'pointer', padding: 0, position: 'relative',
width: 18, height: 18,
@@ -114,6 +154,13 @@ export function ArtikelZeile({ item, tripId, categories, onCategoryChange, onDel
</span>
)}
{/* Private indicator (#858) — shown to the owner so they know it's hidden from others */}
{isPrivate && (
<span title={t('packing.privateHint')} style={{ display: 'flex', alignItems: 'center', color: 'var(--accent)', flexShrink: 0 }}>
<Lock size={12} />
</span>
)}
{/* Quantity */}
{canEdit && <QuantityInput value={item.quantity || 1} onSave={qty => updatePackingItem(tripId, item.id, { quantity: qty })} />}
@@ -245,6 +292,15 @@ export function ArtikelZeile({ item, tripId, categories, onCategoryChange, onDel
)}
</div>
{canTogglePrivacy && (
<button onClick={handleTogglePrivacy} title={isPrivate ? t('packing.makePublic') : t('packing.makePrivate')}
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '3px 4px', borderRadius: 6, display: 'flex', color: isPrivate ? 'var(--accent)' : 'var(--text-faint)' }}
onMouseEnter={e => { if (!isPrivate) e.currentTarget.style.color = 'var(--text-secondary)' }}
onMouseLeave={e => { if (!isPrivate) e.currentTarget.style.color = 'var(--text-faint)' }}>
{isPrivate ? <Unlock size={13} /> : <Lock size={13} />}
</button>
)}
<button onClick={() => setEditing(true)} title={t('common.rename')} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '3px 4px', borderRadius: 6, display: 'flex', color: 'var(--text-faint)' }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-secondary)'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<Pencil size={13} />
@@ -6,7 +6,7 @@ export function PackingList(S: PackingState) {
const {
items, gruppiert, t, tripId, allCategories, handleRenameCategory, handleDeleteCategory, handleDeleteItem,
handleAddItemToCategory, categoryAssignees, tripMembers, handleSetAssignees,
bagTrackingEnabled, bags, handleCreateBagByName, canEdit,
bagTrackingEnabled, bags, handleCreateBagByName, canEdit, reorderPackingItems,
} = S
return (
<div style={{ flex: 1, overflowY: 'auto', padding: '10px 0 16px' }}>
@@ -40,6 +40,8 @@ export function PackingList(S: PackingState) {
bags={bags}
onCreateBag={handleCreateBagByName}
canEdit={canEdit}
allItems={items}
onReorder={(orderedIds) => reorderPackingItems(tripId, orderedIds)}
/>
))}
</div>
@@ -44,7 +44,7 @@ export function usePackingList({ tripId, items, openImportSignal = 0, clearCheck
const [filter, setFilter] = useState('alle') // 'alle' | 'offen' | 'erledigt'
const [addingCategory, setAddingCategory] = useState(false)
const [newCatName, setNewCatName] = useState('')
const { addPackingItem, updatePackingItem, deletePackingItem, togglePackingItem } = useTripStore()
const { addPackingItem, updatePackingItem, deletePackingItem, togglePackingItem, reorderPackingItems } = useTripStore()
const can = useCanDo()
const trip = useTripStore((s) => s.trip)
const canEdit = can('packing_edit', trip)
@@ -340,7 +340,7 @@ export function usePackingList({ tripId, items, openImportSignal = 0, clearCheck
const font = { fontFamily: "var(--font-system)" }
return {
tripId, items, inlineHeader, t, canEdit, isAdmin, font,
tripId, items, inlineHeader, t, canEdit, isAdmin, font, reorderPackingItems,
filter, setFilter, addingCategory, setAddingCategory, newCatName, setNewCatName,
tripMembers, categoryAssignees, handleSetAssignees, allCategories, gruppiert, abgehakt, fortschritt,
handleAddItemToCategory, handleAddNewCategory, handleRenameCategory, handleDeleteCategory, handleDeleteItem, handleClearChecked,