add Emil-style UI polish pass (animations, shared components, feel)

This commit is contained in:
Maurice
2026-04-18 17:39:15 +02:00
parent f79385cf2a
commit 4db6cbef22
33 changed files with 1103 additions and 320 deletions
@@ -0,0 +1,102 @@
import React, { useEffect, useRef, useState } from 'react'
import { Package } from 'lucide-react'
import { adminApi, packingApi } from '../../api/client'
import { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n'
interface Template {
id: number
name: string
item_count: number
}
interface ApplyTemplateButtonProps {
tripId: number
style: React.CSSProperties
className?: string
}
// Dropdown-Button um ein Packing-Template auf den aktuellen Trip anzuwenden.
// Rendert nichts wenn keine Templates existieren.
export default function ApplyTemplateButton({ tripId, style, className }: ApplyTemplateButtonProps): React.ReactElement | null {
const [templates, setTemplates] = useState<Template[]>([])
const [open, setOpen] = useState(false)
const [applying, setApplying] = useState(false)
const dropRef = useRef<HTMLDivElement>(null)
const toast = useToast()
const { t } = useTranslation()
useEffect(() => {
adminApi.packingTemplates().then(d => setTemplates(d.templates || [])).catch(() => {})
}, [tripId])
useEffect(() => {
if (!open) return
const handler = (e: MouseEvent) => {
if (dropRef.current && !dropRef.current.contains(e.target as Node)) setOpen(false)
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [open])
const handleApply = async (templateId: number) => {
setApplying(true)
try {
const data = await packingApi.applyTemplate(tripId, templateId)
toast.success(t('packing.templateApplied', { count: data.count }))
setOpen(false)
window.location.reload()
} catch {
toast.error(t('packing.templateError'))
} finally {
setApplying(false)
}
}
if (templates.length === 0) return null
return (
<div ref={dropRef} style={{ position: 'relative' }}>
<button
onClick={() => setOpen(v => !v)}
disabled={applying}
className={className ?? 'hover:opacity-[0.88]'}
style={style}
>
<Package size={14} strokeWidth={2.5} />
<span className="hidden sm:inline">{t('packing.applyTemplate')}</span>
</button>
{open && (
<div
className="trek-menu-enter"
style={{
position: 'absolute', right: 0, top: '100%', marginTop: 6, zIndex: 50,
background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 10,
boxShadow: '0 4px 16px rgba(0,0,0,0.12)', padding: 4, minWidth: 220,
transformOrigin: 'top right',
}}
>
{templates.map(tmpl => (
<button key={tmpl.id} onClick={() => handleApply(tmpl.id)}
style={{
display: 'flex', alignItems: 'center', gap: 8, width: '100%',
padding: '8px 12px', borderRadius: 8, border: 'none', cursor: 'pointer',
background: 'transparent', fontFamily: 'inherit', fontSize: 12, color: 'var(--text-primary)',
}}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-tertiary)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
>
<Package size={13} style={{ color: 'var(--text-faint)' }} />
<div style={{ flex: 1, textAlign: 'left' }}>
<div style={{ fontWeight: 600 }}>{tmpl.name}</div>
<div style={{ fontSize: 10, color: 'var(--text-faint)' }}>
{tmpl.item_count} {t('admin.packingTemplates.items')}
</div>
</div>
</button>
))}
</div>
)}
</div>
)
}
@@ -253,10 +253,23 @@ function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingE
}}
>
<button onClick={handleToggle} style={{
flexShrink: 0, background: 'none', border: 'none', cursor: 'pointer', padding: 0, display: 'flex',
color: item.checked ? '#10b981' : 'var(--text-faint)', transition: 'color 0.15s',
flexShrink: 0, background: 'none', border: 'none', cursor: 'pointer', padding: 0, position: 'relative',
width: 18, height: 18,
color: item.checked ? '#10b981' : 'var(--text-faint)',
transition: 'color 200ms cubic-bezier(0.23,1,0.32,1)',
}}>
{item.checked ? <CheckSquare size={18} /> : <Square size={18} />}
<Square size={18} style={{
position: 'absolute', inset: 0,
opacity: item.checked ? 0 : 1,
transform: item.checked ? 'scale(0.7)' : 'scale(1)',
transition: 'opacity 180ms cubic-bezier(0.23,1,0.32,1), transform 180ms cubic-bezier(0.23,1,0.32,1)',
}} />
<CheckSquare size={18} style={{
position: 'absolute', inset: 0,
opacity: item.checked ? 1 : 0,
transform: item.checked ? 'scale(1)' : 'scale(0.5)',
transition: 'opacity 200ms cubic-bezier(0.23,1,0.32,1), transform 220ms cubic-bezier(0.34,1.56,0.64,1)',
}} />
</button>
{editing && canEdit ? (
@@ -274,6 +287,7 @@ function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingE
flex: 1, fontSize: 13.5,
cursor: !canEdit || item.checked ? 'default' : 'text',
color: item.checked ? 'var(--text-faint)' : 'var(--text-primary)',
transition: 'color 200ms cubic-bezier(0.23,1,0.32,1)',
textDecoration: item.checked ? 'line-through' : 'none',
}}
>
@@ -730,10 +744,12 @@ interface PackingListPanelProps {
tripId: number
items: PackingItem[]
openImportSignal?: number
clearCheckedSignal?: number
saveTemplateSignal?: number
inlineHeader?: boolean
}
export default function PackingListPanel({ tripId, items, openImportSignal = 0, inlineHeader = true }: PackingListPanelProps) {
export default function PackingListPanel({ 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('')
@@ -899,6 +915,8 @@ export default function PackingListPanel({ tripId, items, openImportSignal = 0,
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) {
@@ -906,6 +924,21 @@ export default function PackingListPanel({ tripId, items, openImportSignal = 0,
}
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)
@@ -1020,14 +1053,22 @@ export default function PackingListPanel({ tripId, items, openImportSignal = 0,
</p>
)}
</div>
) : (
items.length > 0 ? (
<p style={{ margin: 0, fontSize: 12.5, color: 'var(--text-faint)' }}>
{t('packing.progress', { packed: abgehakt, total: items.length, percent: fortschritt })}
</p>
) : <span />
)}
<div style={{ display: 'flex', gap: 6 }}>
) : <span />}
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', justifyContent: 'flex-end' }}>
{canEdit && items.length > 0 && showSaveTemplate && (
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<input
type="text" autoFocus
value={saveTemplateName}
onChange={e => setSaveTemplateName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') handleSaveAsTemplate(); if (e.key === 'Escape') { setShowSaveTemplate(false); setSaveTemplateName('') } }}
placeholder={t('packing.templateName')}
style={{ fontSize: 12, padding: '5px 10px', borderRadius: 99, border: '1px solid var(--border-primary)', outline: 'none', fontFamily: 'inherit', width: 140, background: 'var(--bg-card)', color: 'var(--text-primary)' }}
/>
<button onClick={handleSaveAsTemplate} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 2, color: '#10b981' }}><Check size={14} /></button>
<button onClick={() => { setShowSaveTemplate(false); setSaveTemplateName('') }} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 2, color: 'var(--text-faint)' }}><X size={14} /></button>
</div>
)}
{inlineHeader && canEdit && (
<button onClick={() => setShowImportModal(true)} style={{
display: 'flex', alignItems: 'center', gap: 5, padding: '5px 11px', borderRadius: 99,
@@ -1037,7 +1078,7 @@ export default function PackingListPanel({ tripId, items, openImportSignal = 0,
<Upload size={12} /> <span className="hidden sm:inline">{t('packing.import')}</span>
</button>
)}
{canEdit && abgehakt > 0 && (
{inlineHeader && canEdit && abgehakt > 0 && (
<button onClick={handleClearChecked} style={{
fontSize: 11.5, padding: '5px 10px', borderRadius: 99, border: '1px solid rgba(239,68,68,0.3)',
background: 'rgba(239,68,68,0.1)', color: '#ef4444', cursor: 'pointer', fontFamily: 'inherit',
@@ -1046,7 +1087,7 @@ export default function PackingListPanel({ tripId, items, openImportSignal = 0,
<span className="sm:hidden">{t('packing.clearCheckedShort', { count: abgehakt })}</span>
</button>
)}
{canEdit && availableTemplates.length > 0 && (
{inlineHeader && canEdit && availableTemplates.length > 0 && (
<div ref={templateDropdownRef} style={{ position: 'relative' }}>
<button onClick={() => setShowTemplateDropdown(v => !v)} disabled={applyingTemplate} style={{
display: 'flex', alignItems: 'center', gap: 5, padding: '5px 11px', borderRadius: 99,
@@ -1085,31 +1126,14 @@ export default function PackingListPanel({ tripId, items, openImportSignal = 0,
)}
</div>
)}
{canEdit && items.length > 0 && (
<div style={{ position: 'relative' }}>
{showSaveTemplate ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<input
type="text" autoFocus
value={saveTemplateName}
onChange={e => setSaveTemplateName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') handleSaveAsTemplate(); if (e.key === 'Escape') { setShowSaveTemplate(false); setSaveTemplateName('') } }}
placeholder={t('packing.templateName')}
style={{ fontSize: 12, padding: '5px 10px', borderRadius: 99, border: '1px solid var(--border-primary)', outline: 'none', fontFamily: 'inherit', width: 140, background: 'var(--bg-card)', color: 'var(--text-primary)' }}
/>
<button onClick={handleSaveAsTemplate} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 2, color: '#10b981' }}><Check size={14} /></button>
<button onClick={() => { setShowSaveTemplate(false); setSaveTemplateName('') }} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 2, color: 'var(--text-faint)' }}><X size={14} /></button>
</div>
) : (
<button onClick={() => setShowSaveTemplate(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)',
}}>
<FolderPlus size={12} /> <span className="hidden sm:inline">{t('packing.saveAsTemplate')}</span>
</button>
)}
</div>
{inlineHeader && canEdit && items.length > 0 && !showSaveTemplate && (
<button onClick={() => setShowSaveTemplate(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)',
}}>
<FolderPlus size={12} /> <span className="hidden sm:inline">{t('packing.saveAsTemplate')}</span>
</button>
)}
{bagTrackingEnabled && (
<button onClick={() => setShowBagModal(true)} className="xl:!hidden"
@@ -1127,17 +1151,69 @@ export default function PackingListPanel({ tripId, items, openImportSignal = 0,
</div>
{items.length > 0 && (
<div style={{ marginBottom: 14 }}>
<div style={{ height: 5, background: 'var(--bg-tertiary)', borderRadius: 99, overflow: 'hidden' }}>
<div className="hidden sm:block" style={{ marginTop: 14, marginBottom: 14 }}>
<div className="flex items-center" style={{ gap: 14 }}>
{fortschritt === 100 ? (
<div style={{
display: 'flex', alignItems: 'center', gap: 8,
fontSize: 16, fontWeight: 700, color: '#10b981',
letterSpacing: '-0.01em', flexShrink: 0,
}}>
<CheckCheck size={18} strokeWidth={2.5} />
<span>{t('packing.allPacked')}</span>
</div>
) : (
<div style={{ display: 'flex', alignItems: 'baseline', gap: 8, flexShrink: 0 }}>
<div style={{ display: 'flex', alignItems: 'baseline' }}>
<span style={{
fontSize: 22, fontWeight: 700, color: 'var(--text-primary)',
fontVariantNumeric: 'tabular-nums', letterSpacing: '-0.02em',
lineHeight: 1,
}}>{abgehakt}</span>
<span style={{
fontSize: 14, fontWeight: 500, color: 'var(--text-faint)',
fontVariantNumeric: 'tabular-nums', lineHeight: 1, marginLeft: 1,
}}>/{items.length}</span>
</div>
<span style={{
fontSize: 11, fontWeight: 600, padding: '2px 7px',
borderRadius: 99, background: 'var(--bg-tertiary)',
color: 'var(--text-muted)',
fontVariantNumeric: 'tabular-nums',
lineHeight: 1.4,
}}>{fortschritt}%</span>
</div>
)}
<div style={{
height: '100%', borderRadius: 99, transition: 'width 0.4s ease',
background: fortschritt === 100 ? '#10b981' : 'linear-gradient(90deg, var(--text-primary) 0%, var(--text-muted) 100%)',
width: `${fortschritt}%`,
}} />
flex: 1,
height: 8,
background: 'var(--bg-tertiary)',
borderRadius: 99,
overflow: 'hidden',
position: 'relative',
width: '100%',
}}>
<div style={{
height: '100%',
borderRadius: 99,
transition: 'width 600ms cubic-bezier(0.23, 1, 0.32, 1), background 400ms ease, box-shadow 400ms ease',
background: fortschritt === 100
? 'linear-gradient(90deg, #10b981 0%, #34d399 100%)'
: 'var(--accent)',
width: `${fortschritt}%`,
boxShadow: fortschritt === 100 ? '0 0 14px rgba(16,185,129,0.45)' : 'none',
position: 'relative',
}}>
<div style={{
position: 'absolute', inset: 0,
background: 'linear-gradient(180deg, rgba(255,255,255,0.22) 0%, rgba(255,255,255,0) 55%)',
borderRadius: 99,
pointerEvents: 'none',
}} />
</div>
</div>
</div>
{fortschritt === 100 && (
<p style={{ fontSize: 11.5, color: '#10b981', marginTop: 4, fontWeight: 600, margin: '4px 0 0' }}>{t('packing.allPacked')}</p>
)}
</div>
)}