mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-22 23:16:46 +00:00
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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user