mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-13 07:40:59 +00:00
31859651d7
Every help screen checked against the v5 redesign. Tours is documented for the first time, as a screen of its own with twelve screens under it, and notifications get help of their own. All pictures are taken again on a realistic demo trip, with short loops where a still cannot show the motion. The English is plain and every language is written naturally rather than word for word. The switcher shows two levels at most, a screen's overview lists its sub-screens as cards above its guides, each wiki page is linked once, and the help sidebar is wider. Fixes found along the way: Save to Collection keeps a tour's route, a track dropped on the places column opens the Tours import, tour Share on desktop, the unsaved tour draft lives on the account, a route click no longer adds an end point, Trip places leaves the trip's tours out, Escape closes a stage's day menu, confirms before destructive deletes, DWD warnings sharing one id kept apart, a booking's date moves with it, and smaller fixes across notifications, bookings, the day plan, stays, Atlas, Collections, Vacay and the admin screens.
259 lines
18 KiB
TypeScript
259 lines
18 KiB
TypeScript
import { useTranslation } from '../../i18n'
|
||
import { Plus, Trash2, Pencil, Package, X, Check, ChevronRight, FolderPlus, Loader2 } from 'lucide-react'
|
||
import { fs } from '../shared/DialogShell'
|
||
import { Tooltip } from '../shared/Tooltip'
|
||
import ConfirmDialog from '../shared/ConfirmDialog'
|
||
import { COMPOSER } from '../Packing/packingPopoverStyles'
|
||
import { SettingRows, SettingsCard, SettingsHint, StatusPill, SETTINGS_BUTTON_PRIMARY, SETTINGS_ICON_BUTTON } from '../Settings/settingsKit'
|
||
import { type TemplateItem, usePackingTemplateAdmin } from './usePackingTemplateAdmin'
|
||
|
||
/** The planner's composer (#2541): a framed field on the card with its confirm and cancel beside it. */
|
||
const COMPOSER_ROW = 'flex items-center gap-2 rounded-[10px] border border-edge bg-surface-card py-1 ps-3 pe-1'
|
||
const COMPOSER_INPUT = 'min-w-0 flex-1 border-0 bg-transparent py-1 font-medium text-content outline-none placeholder:text-content-faint'
|
||
const COMPOSER_CANCEL = 'grid h-7 w-7 flex-none place-items-center rounded-[8px] text-content-faint hover:bg-surface-secondary hover:text-content'
|
||
/** A name being renamed in place, sized to the row it sits in. */
|
||
const ROW_INPUT = 'block min-w-0 rounded-[8px] border border-edge bg-surface-input px-2 py-1 text-content outline-none focus:ring-2 focus:ring-[color:var(--text-primary)]'
|
||
/** The quieter icon action of a category head or an item row, named by its tooltip. */
|
||
const SMALL_ICON_BUTTON = 'grid h-7 w-7 flex-none place-items-center rounded-[8px] text-content-faint transition-colors hover:bg-surface-card hover:text-content'
|
||
/** An item's actions show while the pointer or the focus is on its row. */
|
||
const REVEAL = 'opacity-0 focus-visible:opacity-100 group-hover:opacity-100 group-focus-within:opacity-100'
|
||
|
||
/** "2×, 300 g, Backpack": what a template item brings along besides its name (#1131). */
|
||
function templateItemMeta(item: TemplateItem): string {
|
||
const parts: string[] = []
|
||
if ((item.quantity ?? 1) > 1) parts.push(`${item.quantity}×`)
|
||
if (item.weight_grams) parts.push(item.weight_grams >= 1000 ? `${(item.weight_grams / 1000).toFixed(1)} kg` : `${item.weight_grams} g`)
|
||
if (item.bag_name) parts.push(item.bag_name)
|
||
return parts.join(', ')
|
||
}
|
||
|
||
export default function PackingTemplateManager() {
|
||
const {
|
||
templates, isLoading, showCreate, setShowCreate, createName, setCreateName, expandedId, categories, items,
|
||
editingTemplate, setEditingTemplate, editTemplateName, setEditTemplateName, editingCatId, setEditingCatId,
|
||
editCatName, setEditCatName, editingItemId, setEditingItemId, editItemName, setEditItemName, addingCategory,
|
||
setAddingCategory, newCatName, setNewCatName, addingItemToCatId, setAddingItemToCatId, newItemName,
|
||
setNewItemName, addItemRef, toggleExpand, handleCreateTemplate, deleteTarget, setDeleteTarget, requestDeleteTemplate, requestDeleteCategory, confirmDelete, handleRenameTemplate,
|
||
handleAddCategory, handleRenameCategory, handleAddItem, handleRenameItem, handleDeleteItem,
|
||
} = usePackingTemplateAdmin()
|
||
const { t } = useTranslation()
|
||
|
||
const composerConfirmClass = (enabled: boolean) =>
|
||
`grid h-7 w-7 flex-none place-items-center rounded-[8px] bg-accent text-accent-text transition-opacity disabled:cursor-default ${enabled ? 'hover:opacity-90' : 'opacity-40'}`
|
||
|
||
return (
|
||
<SettingsCard
|
||
icon={Package}
|
||
title={t('admin.packingTemplates.title')}
|
||
hint={t('admin.packingTemplates.subtitle')}
|
||
badge={!isLoading && templates.length > 0 ? <StatusPill>{templates.length}</StatusPill> : undefined}
|
||
action={
|
||
<button type="button" onClick={() => setShowCreate(true)} className={SETTINGS_BUTTON_PRIMARY} style={fs(13, 'body')}>
|
||
<Plus size={14} strokeWidth={2.2} /> <span className="hidden sm:inline">{t('admin.packingTemplates.create')}</span>
|
||
</button>
|
||
}
|
||
>
|
||
{/* Create template: the planner's composer, a framed field with its confirm beside it */}
|
||
{showCreate && (
|
||
<div className={COMPOSER_ROW} style={{ boxShadow: COMPOSER.boxShadow }}>
|
||
<Package size={15} className="flex-none text-content-faint" />
|
||
<input autoFocus value={createName} onChange={e => setCreateName(e.target.value)}
|
||
onKeyDown={e => { if (e.key === 'Enter') void handleCreateTemplate(); if (e.key === 'Escape') setShowCreate(false) }}
|
||
placeholder={t('admin.packingTemplates.namePlaceholder')} className={COMPOSER_INPUT} style={fs(13, 'body')} />
|
||
<Tooltip label={t('common.save')}>
|
||
<button type="button" onClick={handleCreateTemplate} aria-label={t('common.save')} className={composerConfirmClass(!!createName.trim())}>
|
||
<Check size={14} strokeWidth={2.5} />
|
||
</button>
|
||
</Tooltip>
|
||
<Tooltip label={t('common.cancel')}>
|
||
<button type="button" onClick={() => setShowCreate(false)} aria-label={t('common.cancel')} className={COMPOSER_CANCEL}>
|
||
<X size={14} />
|
||
</button>
|
||
</Tooltip>
|
||
</div>
|
||
)}
|
||
|
||
{/* Template list */}
|
||
{isLoading ? (
|
||
<div className="grid place-items-center py-8">
|
||
<Loader2 size={20} className="animate-spin text-content-faint" />
|
||
</div>
|
||
) : templates.length === 0 ? (
|
||
<div className="rounded-[12px] border border-dashed border-edge px-4 py-6 text-center">
|
||
<SettingsHint>{t('admin.packingTemplates.empty')}</SettingsHint>
|
||
</div>
|
||
) : (
|
||
<SettingRows>
|
||
{templates.map(tmpl => {
|
||
const expanded = expandedId === tmpl.id
|
||
return (
|
||
<div key={tmpl.id}>
|
||
{/* Template row */}
|
||
<div data-row="template" className={`flex items-center gap-2.5 px-3 py-2.5 transition-colors ${expanded ? 'bg-surface-secondary' : 'hover:bg-surface-secondary'}`}>
|
||
<Tooltip label={expanded ? t('common.collapse') : t('common.expand')}>
|
||
<button type="button" onClick={() => toggleExpand(tmpl.id)} aria-expanded={expanded}
|
||
aria-label={expanded ? t('common.collapse') : t('common.expand')}
|
||
className="grid h-7 w-7 flex-none place-items-center rounded-[8px] text-content-faint hover:bg-surface-tertiary hover:text-content">
|
||
<ChevronRight size={15} strokeWidth={2.2} className={`transition-transform duration-150 ${expanded ? 'rotate-90' : ''}`} />
|
||
</button>
|
||
</Tooltip>
|
||
<span className="grid h-8 w-8 flex-none place-items-center rounded-[10px] bg-surface-tertiary text-content-secondary">
|
||
<Package size={15} strokeWidth={1.9} />
|
||
</span>
|
||
{editingTemplate === tmpl.id ? (
|
||
<input autoFocus value={editTemplateName} onChange={e => setEditTemplateName(e.target.value)}
|
||
onBlur={() => handleRenameTemplate(tmpl.id)}
|
||
onKeyDown={e => { if (e.key === 'Enter') void handleRenameTemplate(tmpl.id); if (e.key === 'Escape') setEditingTemplate(null) }}
|
||
aria-label={t('common.rename')}
|
||
className={`${ROW_INPUT} flex-1 font-semibold`} style={fs(13, 'body')} />
|
||
) : (
|
||
<button type="button" onClick={() => toggleExpand(tmpl.id)} className="min-w-0 flex-1 truncate text-start font-semibold text-content" style={fs(13, 'body')}>{tmpl.name}</button>
|
||
)}
|
||
<span className="max-sm:hidden">
|
||
<StatusPill>{tmpl.category_count} {t('admin.packingTemplates.categories', { count: tmpl.category_count })}, {tmpl.item_count} {t('admin.packingTemplates.items', { count: tmpl.item_count })}</StatusPill>
|
||
</span>
|
||
<Tooltip label={t('common.rename')}>
|
||
<button type="button" onClick={() => { setEditingTemplate(tmpl.id); setEditTemplateName(tmpl.name) }}
|
||
aria-label={t('common.rename')} className={SETTINGS_ICON_BUTTON}><Pencil size={14} strokeWidth={2} /></button>
|
||
</Tooltip>
|
||
<Tooltip label={t('common.delete')}>
|
||
<button type="button" onClick={() => requestDeleteTemplate(tmpl.id)}
|
||
aria-label={t('common.delete')} className={`${SETTINGS_ICON_BUTTON} hover:!text-danger`}><Trash2 size={14} strokeWidth={2} /></button>
|
||
</Tooltip>
|
||
</div>
|
||
|
||
{/* Expanded content: the template's categories as the packing list draws them */}
|
||
{expanded && (
|
||
<div className="flex flex-col gap-2.5 border-t border-edge-faint bg-surface-secondary px-3 py-3 sm:ps-[52px]">
|
||
{categories.map(cat => {
|
||
const catItems = items.filter(i => i.category_id === cat.id)
|
||
return (
|
||
<div key={cat.id} className="overflow-hidden rounded-[12px] border border-edge-faint bg-surface-card">
|
||
{/* Category header */}
|
||
<div data-row="category" className="flex items-center gap-1.5 border-b border-edge-faint bg-surface-tertiary py-1.5 ps-3 pe-1.5">
|
||
{editingCatId === cat.id ? (
|
||
<input autoFocus value={editCatName} onChange={e => setEditCatName(e.target.value)}
|
||
onBlur={() => handleRenameCategory(cat.id)}
|
||
onKeyDown={e => { if (e.key === 'Enter') void handleRenameCategory(cat.id); if (e.key === 'Escape') setEditingCatId(null) }}
|
||
aria-label={t('common.rename')}
|
||
className={`${ROW_INPUT} flex-1 font-semibold`} style={fs(12.5, 'body')} />
|
||
) : (
|
||
<span className="min-w-0 flex-1 truncate font-geist font-bold uppercase tracking-[.08em] text-content-muted" style={fs(10.5)}>{cat.name}</span>
|
||
)}
|
||
<span className="min-w-[20px] flex-none rounded-full bg-surface-card px-1.5 py-px text-center font-geist font-bold tabular-nums text-content-faint" style={fs(10)}>{catItems.length}</span>
|
||
<Tooltip label={t('common.add')}>
|
||
<button type="button" onClick={() => { setAddingItemToCatId(addingItemToCatId === cat.id ? null : cat.id); setNewItemName(''); setTimeout(() => addItemRef.current?.focus(), 30) }}
|
||
aria-label={t('common.add')} className={SMALL_ICON_BUTTON}><Plus size={13} strokeWidth={2.2} /></button>
|
||
</Tooltip>
|
||
<Tooltip label={t('common.rename')}>
|
||
<button type="button" onClick={() => { setEditingCatId(cat.id); setEditCatName(cat.name) }}
|
||
aria-label={t('common.rename')} className={SMALL_ICON_BUTTON}><Pencil size={12} strokeWidth={2} /></button>
|
||
</Tooltip>
|
||
<Tooltip label={t('common.delete')}>
|
||
<button type="button" onClick={() => requestDeleteCategory(cat.id)}
|
||
aria-label={t('common.delete')} className={`${SMALL_ICON_BUTTON} hover:!text-danger`}><Trash2 size={12} strokeWidth={2} /></button>
|
||
</Tooltip>
|
||
</div>
|
||
|
||
{/* Items */}
|
||
{(catItems.length > 0 || addingItemToCatId === cat.id) && (
|
||
<div className="divide-y divide-edge-faint">
|
||
{catItems.map(item => (
|
||
<div key={item.id} data-row="item" className="group flex min-h-[40px] items-center gap-2 py-1.5 ps-3 pe-1.5">
|
||
{editingItemId === item.id ? (
|
||
<>
|
||
<input autoFocus value={editItemName} onChange={e => setEditItemName(e.target.value)}
|
||
onKeyDown={e => { if (e.key === 'Enter') void handleRenameItem(item.id); if (e.key === 'Escape') setEditingItemId(null) }}
|
||
aria-label={t('common.rename')}
|
||
className={`${ROW_INPUT} flex-1`} style={fs(12.5, 'body')} />
|
||
<Tooltip label={t('common.save')}>
|
||
<button type="button" onClick={() => handleRenameItem(item.id)} aria-label={t('common.save')} className={composerConfirmClass(true)}><Check size={13} strokeWidth={2.5} /></button>
|
||
</Tooltip>
|
||
<Tooltip label={t('common.cancel')}>
|
||
<button type="button" onClick={() => setEditingItemId(null)} aria-label={t('common.cancel')} className={COMPOSER_CANCEL}><X size={13} /></button>
|
||
</Tooltip>
|
||
</>
|
||
) : (
|
||
<>
|
||
<span aria-hidden className="h-[7px] w-[7px] flex-none rounded-full border-[1.5px] border-edge" />
|
||
<span className="min-w-0 flex-1 truncate text-content" style={fs(12.5, 'body')}>{item.name}</span>
|
||
{templateItemMeta(item) && (
|
||
<span className="max-w-[45%] truncate font-geist tabular-nums text-content-faint" style={fs(11)}>{templateItemMeta(item)}</span>
|
||
)}
|
||
<Tooltip label={t('common.rename')}>
|
||
<button type="button" onClick={() => { setEditingItemId(item.id); setEditItemName(item.name) }}
|
||
aria-label={t('common.rename')} className={`${SMALL_ICON_BUTTON} ${REVEAL}`}><Pencil size={12} strokeWidth={2} /></button>
|
||
</Tooltip>
|
||
<Tooltip label={t('common.delete')}>
|
||
<button type="button" onClick={() => handleDeleteItem(item.id)}
|
||
aria-label={t('common.delete')} className={`${SMALL_ICON_BUTTON} ${REVEAL} hover:!text-danger`}><Trash2 size={12} strokeWidth={2} /></button>
|
||
</Tooltip>
|
||
</>
|
||
)}
|
||
</div>
|
||
))}
|
||
|
||
{/* Add item inline */}
|
||
{addingItemToCatId === cat.id && (
|
||
<div className="flex items-center gap-2 py-1.5 ps-3 pe-1.5">
|
||
<input ref={addItemRef} value={newItemName} onChange={e => setNewItemName(e.target.value)}
|
||
onKeyDown={e => { if (e.key === 'Enter' && newItemName.trim()) void handleAddItem(cat.id); if (e.key === 'Escape') { setAddingItemToCatId(null); setNewItemName('') } }}
|
||
placeholder={t('admin.packingTemplates.itemName')}
|
||
className={`${ROW_INPUT} flex-1`} style={fs(12.5, 'body')} />
|
||
<Tooltip label={t('common.add')}>
|
||
<button type="button" onClick={() => handleAddItem(cat.id)} disabled={!newItemName.trim()}
|
||
aria-label={t('common.add')} className={composerConfirmClass(!!newItemName.trim())}><Plus size={13} strokeWidth={2.5} /></button>
|
||
</Tooltip>
|
||
<Tooltip label={t('common.cancel')}>
|
||
<button type="button" onClick={() => { setAddingItemToCatId(null); setNewItemName('') }}
|
||
aria-label={t('common.cancel')} className={COMPOSER_CANCEL}><X size={13} /></button>
|
||
</Tooltip>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
})}
|
||
|
||
{/* Add category */}
|
||
{addingCategory ? (
|
||
<div className={COMPOSER_ROW} style={{ boxShadow: COMPOSER.boxShadow }}>
|
||
<input autoFocus value={newCatName} onChange={e => setNewCatName(e.target.value)}
|
||
onKeyDown={e => { if (e.key === 'Enter') void handleAddCategory(); if (e.key === 'Escape') { setAddingCategory(false); setNewCatName('') } }}
|
||
placeholder={t('admin.packingTemplates.categoryName')}
|
||
className={COMPOSER_INPUT} style={fs(13, 'body')} />
|
||
<Tooltip label={t('common.save')}>
|
||
<button type="button" onClick={handleAddCategory} aria-label={t('common.save')} className={composerConfirmClass(!!newCatName.trim())}><Check size={14} strokeWidth={2.5} /></button>
|
||
</Tooltip>
|
||
<Tooltip label={t('common.cancel')}>
|
||
<button type="button" onClick={() => { setAddingCategory(false); setNewCatName('') }} aria-label={t('common.cancel')} className={COMPOSER_CANCEL}><X size={14} /></button>
|
||
</Tooltip>
|
||
</div>
|
||
) : (
|
||
<button type="button" onClick={() => setAddingCategory(true)}
|
||
className="flex w-full items-center justify-center gap-1.5 rounded-[10px] border border-dashed border-edge px-3 py-2 font-semibold text-content-muted transition-colors hover:border-content-faint hover:text-content"
|
||
style={fs(12, 'body')}>
|
||
<FolderPlus size={13} strokeWidth={2.2} /> {t('admin.packingTemplates.addCategory')}
|
||
</button>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
})}
|
||
</SettingRows>
|
||
)}
|
||
<ConfirmDialog
|
||
isOpen={deleteTarget !== null}
|
||
onClose={() => setDeleteTarget(null)}
|
||
onConfirm={() => void confirmDelete()}
|
||
title={t(deleteTarget?.kind === 'category' ? 'admin.packingTemplates.deleteCategoryTitle' : 'admin.packingTemplates.deleteTemplateTitle')}
|
||
message={t(deleteTarget?.kind === 'category' ? 'admin.packingTemplates.deleteCategoryConfirm' : 'admin.packingTemplates.deleteTemplateConfirm', { name: deleteTarget?.name ?? '' })}
|
||
confirmLabel={t('common.delete')}
|
||
danger
|
||
/>
|
||
</SettingsCard>
|
||
)
|
||
}
|