mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a3f52ebd7b | |||
| 4974013995 | |||
| bc192d3106 | |||
| 4db6cbef22 | |||
| f79385cf2a | |||
| db2c11e4a5 | |||
| e57c6773fc | |||
| 4bdc032f97 | |||
| 777b68f87b | |||
| 66a7de09c1 | |||
| a19ae9e653 | |||
| 38f4c9aecb |
Generated
-75
@@ -2367,9 +2367,6 @@
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2387,9 +2384,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2407,9 +2401,6 @@
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2427,9 +2418,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2447,9 +2435,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2467,9 +2452,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2487,9 +2469,6 @@
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2513,9 +2492,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2539,9 +2515,6 @@
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2565,9 +2538,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2591,9 +2561,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2617,9 +2584,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3399,9 +3363,6 @@
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3416,9 +3377,6 @@
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3433,9 +3391,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3450,9 +3405,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3467,9 +3419,6 @@
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3484,9 +3433,6 @@
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3501,9 +3447,6 @@
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3518,9 +3461,6 @@
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3535,9 +3475,6 @@
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3552,9 +3489,6 @@
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3569,9 +3503,6 @@
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3586,9 +3517,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3603,9 +3531,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
||||
@@ -130,7 +130,7 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
|
||||
href="https://ko-fi.com/mauriceboe"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-all"
|
||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
|
||||
onMouseEnter={e => { e.currentTarget.style.borderColor = '#ff5e5b'; e.currentTarget.style.boxShadow = '0 0 0 1px #ff5e5b22' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
||||
@@ -148,7 +148,7 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
|
||||
href="https://buymeacoffee.com/mauriceboe"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-all"
|
||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
|
||||
onMouseEnter={e => { e.currentTarget.style.borderColor = '#ffdd00'; e.currentTarget.style.boxShadow = '0 0 0 1px #ffdd0022' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
||||
@@ -166,7 +166,7 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
|
||||
href="https://discord.gg/NhZBDSd4qW"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-all"
|
||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
|
||||
onMouseEnter={e => { e.currentTarget.style.borderColor = '#5865F2'; e.currentTarget.style.boxShadow = '0 0 0 1px #5865F222' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
||||
@@ -187,7 +187,7 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
|
||||
href="https://github.com/mauriceboe/TREK/issues/new?template=bug_report.yml"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-all"
|
||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
|
||||
onMouseEnter={e => { e.currentTarget.style.borderColor = '#ef4444'; e.currentTarget.style.boxShadow = '0 0 0 1px #ef444422' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
||||
@@ -205,7 +205,7 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
|
||||
href="https://github.com/mauriceboe/TREK/discussions/new?category=feature-requests"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-all"
|
||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
|
||||
onMouseEnter={e => { e.currentTarget.style.borderColor = '#f59e0b'; e.currentTarget.style.boxShadow = '0 0 0 1px #f59e0b22' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
||||
@@ -223,7 +223,7 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
|
||||
href="https://github.com/mauriceboe/TREK/wiki"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-all"
|
||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
|
||||
onMouseEnter={e => { e.currentTarget.style.borderColor = '#6366f1'; e.currentTarget.style.boxShadow = '0 0 0 1px #6366f122' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
||||
|
||||
@@ -107,10 +107,12 @@ export default function PermissionsPanel(): React.ReactElement {
|
||||
<button
|
||||
onClick={handleReset}
|
||||
disabled={saving}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm border border-slate-300 rounded-lg hover:bg-slate-50 disabled:opacity-40 transition-colors"
|
||||
title={t('perm.resetDefaults')}
|
||||
aria-label={t('perm.resetDefaults')}
|
||||
className="flex items-center justify-center gap-1.5 px-0 sm:px-3 py-1.5 text-sm w-8 sm:w-auto border border-slate-300 rounded-lg hover:bg-slate-50 disabled:opacity-40 transition-colors"
|
||||
>
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
{t('perm.resetDefaults')}
|
||||
<span className="hidden sm:inline">{t('perm.resetDefaults')}</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
|
||||
@@ -529,11 +529,14 @@ function PieChart({ segments, size = 200, totalLabel }: PieChartProps) {
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative', width: size, height: size, margin: '0 auto' }}>
|
||||
<div style={{
|
||||
width: size, height: size, borderRadius: '50%',
|
||||
background: `conic-gradient(${stops})`,
|
||||
boxShadow: '0 4px 24px rgba(0,0,0,0.08)',
|
||||
}} />
|
||||
<div
|
||||
className="trek-pie-reveal"
|
||||
style={{
|
||||
width: size, height: size, borderRadius: '50%',
|
||||
background: `conic-gradient(${stops})`,
|
||||
boxShadow: '0 4px 24px rgba(0,0,0,0.08)',
|
||||
}}
|
||||
/>
|
||||
<div style={{
|
||||
position: 'absolute', top: '50%', left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
|
||||
@@ -269,8 +269,14 @@ const JourneyMap = forwardRef<JourneyMapHandle, Props>(function JourneyMap(
|
||||
const timer = setTimeout(() => {
|
||||
highlightMarker(activeMarkerId)
|
||||
const marker = markersRef.current.get(activeMarkerId)
|
||||
if (marker && mapRef.current) {
|
||||
mapRef.current.flyTo(marker.getLatLng(), Math.max(mapRef.current.getZoom(), 12), { duration: 0.5 })
|
||||
if (!marker || !mapRef.current) return
|
||||
// fitBounds may still be pending when this fires — getZoom() throws
|
||||
// "Set map center and zoom first" until the map has a view. Guard it.
|
||||
try {
|
||||
const currentZoom = mapRef.current.getZoom()
|
||||
mapRef.current.flyTo(marker.getLatLng(), Math.max(currentZoom, 12), { duration: 0.5 })
|
||||
} catch {
|
||||
mapRef.current.setView(marker.getLatLng(), 12)
|
||||
}
|
||||
}, 50)
|
||||
return () => clearTimeout(timer)
|
||||
|
||||
@@ -30,13 +30,14 @@ function photoUrl(p: JourneyPhoto, size: 'thumbnail' | 'original' = 'original'):
|
||||
|
||||
interface Props {
|
||||
entry: JourneyEntry
|
||||
readOnly?: boolean
|
||||
onClose: () => void
|
||||
onEdit: () => void
|
||||
onDelete: () => void
|
||||
onPhotoClick: (photos: JourneyPhoto[], index: number) => void
|
||||
}
|
||||
|
||||
export default function MobileEntryView({ entry, onClose, onEdit, onDelete, onPhotoClick }: Props) {
|
||||
export default function MobileEntryView({ entry, readOnly, onClose, onEdit, onDelete, onPhotoClick }: Props) {
|
||||
const photos = entry.photos || []
|
||||
const mood = entry.mood ? MOOD_CONFIG[entry.mood] : null
|
||||
const weather = entry.weather ? WEATHER_CONFIG[entry.weather] : null
|
||||
@@ -57,21 +58,23 @@ export default function MobileEntryView({ entry, onClose, onEdit, onDelete, onPh
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
onClick={() => { onClose(); onEdit(); }}
|
||||
className="h-8 px-3 rounded-lg bg-zinc-100 dark:bg-zinc-800 text-zinc-700 dark:text-zinc-300 text-[12px] font-medium flex items-center gap-1.5 hover:bg-zinc-200 dark:hover:bg-zinc-700 transition-colors"
|
||||
>
|
||||
<Pencil size={13} />
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { onClose(); onDelete(); }}
|
||||
className="w-8 h-8 rounded-lg flex items-center justify-center text-zinc-400 hover:bg-red-50 hover:text-red-500 dark:hover:bg-red-900/20 dark:hover:text-red-400 transition-colors"
|
||||
>
|
||||
<Trash2 size={15} />
|
||||
</button>
|
||||
</div>
|
||||
{!readOnly && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
onClick={() => { onClose(); onEdit(); }}
|
||||
className="h-8 px-3 rounded-lg bg-zinc-100 dark:bg-zinc-800 text-zinc-700 dark:text-zinc-300 text-[12px] font-medium flex items-center gap-1.5 hover:bg-zinc-200 dark:hover:bg-zinc-700 transition-colors"
|
||||
>
|
||||
<Pencil size={13} />
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { onClose(); onDelete(); }}
|
||||
className="w-8 h-8 rounded-lg flex items-center justify-center text-zinc-400 hover:bg-red-50 hover:text-red-500 dark:hover:bg-red-900/20 dark:hover:text-red-400 transition-colors"
|
||||
>
|
||||
<Trash2 size={15} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Scrollable content */}
|
||||
|
||||
@@ -39,6 +39,8 @@ export default function MobileMapTimeline({
|
||||
const carouselRef = useRef<HTMLDivElement>(null)
|
||||
const [activeIndex, setActiveIndex] = useState(0)
|
||||
const cardRefs = useRef<Map<number, HTMLDivElement>>(new Map())
|
||||
const activeIndexRef = useRef(activeIndex)
|
||||
useEffect(() => { activeIndexRef.current = activeIndex }, [activeIndex])
|
||||
|
||||
// Sync map focus when carousel scrolls (with guard for uninitialized map)
|
||||
const syncMapToCarousel = useCallback((index: number) => {
|
||||
@@ -53,41 +55,78 @@ export default function MobileMapTimeline({
|
||||
}
|
||||
}, [entries, mapEntries])
|
||||
|
||||
// IntersectionObserver for instant snap detection
|
||||
// Pick the card that's currently closest to the carousel horizontal center.
|
||||
// More stable than IntersectionObserver thresholds when the active card can
|
||||
// drift toward the viewport edge with proximity snapping.
|
||||
const pickNearestCard = useCallback(() => {
|
||||
const el = carouselRef.current
|
||||
if (!el) return
|
||||
const containerCenter = el.getBoundingClientRect().left + el.clientWidth / 2
|
||||
let bestIdx = 0
|
||||
let bestDist = Infinity
|
||||
cardRefs.current.forEach((node, idx) => {
|
||||
const r = node.getBoundingClientRect()
|
||||
const cardCenter = r.left + r.width / 2
|
||||
const d = Math.abs(cardCenter - containerCenter)
|
||||
if (d < bestDist) { bestDist = d; bestIdx = idx }
|
||||
})
|
||||
setActiveIndex(prev => {
|
||||
if (prev !== bestIdx) syncMapToCarousel(bestIdx)
|
||||
return bestIdx
|
||||
})
|
||||
}, [syncMapToCarousel])
|
||||
|
||||
// Track scroll; debounce to re-center the active card when the user stops.
|
||||
useEffect(() => {
|
||||
const el = carouselRef.current
|
||||
if (!el || entries.length === 0) return
|
||||
let rafId: number | null = null
|
||||
let settleTimer: number | null = null
|
||||
const onScroll = () => {
|
||||
if (rafId != null) return
|
||||
rafId = requestAnimationFrame(() => {
|
||||
pickNearestCard()
|
||||
rafId = null
|
||||
})
|
||||
if (settleTimer != null) window.clearTimeout(settleTimer)
|
||||
settleTimer = window.setTimeout(() => {
|
||||
// Ensure the active card sits at the center once the user settles.
|
||||
const card = cardRefs.current.get(activeIndexRef.current)
|
||||
card?.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' })
|
||||
}, 180)
|
||||
}
|
||||
el.addEventListener('scroll', onScroll, { passive: true })
|
||||
return () => {
|
||||
el.removeEventListener('scroll', onScroll)
|
||||
if (rafId != null) cancelAnimationFrame(rafId)
|
||||
if (settleTimer != null) window.clearTimeout(settleTimer)
|
||||
}
|
||||
}, [entries.length, pickNearestCard])
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(observed) => {
|
||||
for (const o of observed) {
|
||||
if (o.isIntersecting) {
|
||||
const idx = Number(o.target.getAttribute('data-idx'))
|
||||
if (!isNaN(idx)) {
|
||||
setActiveIndex(idx)
|
||||
syncMapToCarousel(idx)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{ root: el, threshold: 0.6 },
|
||||
)
|
||||
|
||||
cardRefs.current.forEach(node => observer.observe(node))
|
||||
return () => observer.disconnect()
|
||||
}, [entries.length, syncMapToCarousel])
|
||||
// Scroll a given card into the horizontal center of the carousel
|
||||
const scrollCardIntoCenter = useCallback((idx: number) => {
|
||||
const card = cardRefs.current.get(idx)
|
||||
card?.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' })
|
||||
}, [])
|
||||
|
||||
// Scroll carousel to entry when map marker is clicked
|
||||
const handleMarkerClick = useCallback((id: string) => {
|
||||
const idx = entries.findIndex((e: any) => String(e.id) === id)
|
||||
if (idx === -1) return
|
||||
setActiveIndex(idx)
|
||||
scrollCardIntoCenter(idx)
|
||||
}, [entries, scrollCardIntoCenter])
|
||||
|
||||
const el = carouselRef.current
|
||||
if (!el) return
|
||||
const cardWidth = 272
|
||||
el.scrollTo({ left: idx * cardWidth, behavior: 'smooth' })
|
||||
}, [entries])
|
||||
// Tap on a card: if it's already active, open the edit view; otherwise
|
||||
// activate + center it first (don't jump straight into the editor).
|
||||
const handleCardTap = useCallback((entry: any, idx: number) => {
|
||||
if (idx === activeIndex) {
|
||||
onEntryClick(entry)
|
||||
} else {
|
||||
setActiveIndex(idx)
|
||||
scrollCardIntoCenter(idx)
|
||||
}
|
||||
}, [activeIndex, onEntryClick, scrollCardIntoCenter])
|
||||
|
||||
// Initial map focus — delay to let Leaflet initialize and fitBounds
|
||||
useEffect(() => {
|
||||
@@ -115,12 +154,12 @@ export default function MobileMapTimeline({
|
||||
fullScreen
|
||||
/>
|
||||
{!readOnly && onAddEntry && (
|
||||
<div className="fixed top-[calc(var(--nav-h,56px)+12px)] right-4 z-30">
|
||||
<div className="fixed right-4 z-30" style={{ bottom: 'calc(var(--bottom-nav-h, 84px) + 16px)' }}>
|
||||
<button
|
||||
onClick={onAddEntry}
|
||||
className="w-10 h-10 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 shadow-lg flex items-center justify-center hover:scale-105 active:scale-95 transition-transform"
|
||||
className="w-12 h-12 rounded-full bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 shadow-lg flex items-center justify-center hover:scale-105 active:scale-95 transition-transform"
|
||||
>
|
||||
<Plus size={18} />
|
||||
<Plus size={20} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -146,14 +185,14 @@ export default function MobileMapTimeline({
|
||||
|
||||
{/* Bottom carousel */}
|
||||
<div
|
||||
className="fixed bottom-20 left-0 right-0 z-40"
|
||||
style={{ touchAction: 'pan-x' }}
|
||||
className="fixed left-0 right-0 z-40"
|
||||
style={{ touchAction: 'pan-x', bottom: 'calc(var(--bottom-nav-h, 84px) + 8px)' }}
|
||||
>
|
||||
<div
|
||||
ref={carouselRef}
|
||||
className="flex gap-3 overflow-x-auto px-4 pb-3 pt-1 scroll-smooth"
|
||||
style={{
|
||||
scrollSnapType: 'x mandatory',
|
||||
scrollSnapType: 'x proximity',
|
||||
WebkitOverflowScrolling: 'touch',
|
||||
scrollbarWidth: 'none',
|
||||
msOverflowStyle: 'none',
|
||||
@@ -170,7 +209,7 @@ export default function MobileMapTimeline({
|
||||
entry={entry}
|
||||
index={i}
|
||||
isActive={i === activeIndex}
|
||||
onClick={() => onEntryClick(entry)}
|
||||
onClick={() => handleCardTap(entry, i)}
|
||||
publicPhotoUrl={publicPhotoUrl}
|
||||
/>
|
||||
</div>
|
||||
@@ -178,14 +217,17 @@ export default function MobileMapTimeline({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* FAB: add entry — top right */}
|
||||
{/* FAB: add entry — bottom right, above the timeline carousel */}
|
||||
{!readOnly && onAddEntry && (
|
||||
<div className="fixed top-[calc(var(--nav-h,56px)+12px)] right-4 z-30">
|
||||
<div
|
||||
className="fixed right-4 z-30"
|
||||
style={{ bottom: 'calc(var(--bottom-nav-h, 84px) + 168px)' }}
|
||||
>
|
||||
<button
|
||||
onClick={onAddEntry}
|
||||
className="w-10 h-10 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 shadow-lg flex items-center justify-center hover:scale-105 active:scale-95 transition-transform"
|
||||
className="w-12 h-12 rounded-full bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 shadow-lg flex items-center justify-center hover:scale-105 active:scale-95 transition-transform"
|
||||
>
|
||||
<Plus size={18} />
|
||||
<Plus size={20} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -34,9 +34,21 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const [userMenuOpen, setUserMenuOpen] = useState<boolean>(false)
|
||||
const [scrolled, setScrolled] = useState<boolean>(false)
|
||||
const darkMode = settings.dark_mode
|
||||
const dark = darkMode === true || darkMode === 'dark' || (darkMode === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||
|
||||
useEffect(() => {
|
||||
const onScroll = () => setScrolled(window.scrollY > 8 || (document.body.scrollTop || 0) > 8)
|
||||
onScroll()
|
||||
window.addEventListener('scroll', onScroll, { passive: true })
|
||||
document.body.addEventListener('scroll', onScroll, { passive: true })
|
||||
return () => {
|
||||
window.removeEventListener('scroll', onScroll)
|
||||
document.body.removeEventListener('scroll', onScroll)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Only show 'global' type addons in the navbar — 'integration' addons have no dedicated page
|
||||
const globalAddons = allAddons.filter((a: Addon) => a.type === 'global' && a.enabled)
|
||||
|
||||
@@ -50,7 +62,11 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
|
||||
}
|
||||
|
||||
const toggleDarkMode = () => {
|
||||
document.documentElement.classList.add('trek-theme-transitioning')
|
||||
updateSetting('dark_mode', dark ? 'light' : 'dark').catch(() => {})
|
||||
window.setTimeout(() => {
|
||||
document.documentElement.classList.remove('trek-theme-transitioning')
|
||||
}, 360)
|
||||
}
|
||||
|
||||
const getAddonName = (addon: Addon): string => {
|
||||
@@ -61,23 +77,29 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
|
||||
|
||||
return (
|
||||
<nav style={{
|
||||
background: dark ? 'rgba(9,9,11,0.95)' : 'rgba(255,255,255,0.95)',
|
||||
backdropFilter: 'blur(20px)', WebkitBackdropFilter: 'blur(20px)',
|
||||
background: dark
|
||||
? (scrolled ? 'rgba(9,9,11,0.78)' : 'rgba(9,9,11,0.95)')
|
||||
: (scrolled ? 'rgba(255,255,255,0.72)' : 'rgba(255,255,255,0.95)'),
|
||||
backdropFilter: scrolled ? 'blur(28px) saturate(180%)' : 'blur(20px)',
|
||||
WebkitBackdropFilter: scrolled ? 'blur(28px) saturate(180%)' : 'blur(20px)',
|
||||
borderBottom: `1px solid ${dark ? 'rgba(255,255,255,0.07)' : 'rgba(0,0,0,0.07)'}`,
|
||||
boxShadow: dark ? '0 1px 12px rgba(0,0,0,0.2)' : '0 1px 12px rgba(0,0,0,0.05)',
|
||||
boxShadow: scrolled
|
||||
? (dark ? '0 4px 24px rgba(0,0,0,0.35)' : '0 4px 24px rgba(0,0,0,0.08)')
|
||||
: (dark ? '0 1px 12px rgba(0,0,0,0.2)' : '0 1px 12px rgba(0,0,0,0.05)'),
|
||||
touchAction: 'manipulation',
|
||||
paddingTop: 'env(safe-area-inset-top, 0px)',
|
||||
height: 'var(--nav-h)',
|
||||
transition: 'background 240ms cubic-bezier(0.23,1,0.32,1), backdrop-filter 240ms cubic-bezier(0.23,1,0.32,1), box-shadow 240ms cubic-bezier(0.23,1,0.32,1)',
|
||||
}} className="hidden md:flex items-center px-4 gap-4 fixed top-0 left-0 right-0 z-[200]">
|
||||
{/* Left side */}
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
{showBack && (
|
||||
<button onClick={onBack}
|
||||
className="p-1.5 rounded-lg transition-colors flex items-center gap-1.5 text-sm flex-shrink-0"
|
||||
className="trek-back-btn p-1.5 rounded-lg transition-colors flex items-center gap-1.5 text-sm flex-shrink-0"
|
||||
style={{ color: 'var(--text-muted)' }}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
<ArrowLeft className="trek-back-icon w-4 h-4" />
|
||||
<span className="hidden sm:inline">{t('common.back')}</span>
|
||||
</button>
|
||||
)}
|
||||
@@ -161,11 +183,14 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
|
||||
|
||||
{/* Dark mode toggle (light ↔ dark, overrides auto) — hidden on mobile */}
|
||||
<button onClick={toggleDarkMode} title={dark ? t('nav.lightMode') : t('nav.darkMode')}
|
||||
className="p-2 rounded-lg transition-colors flex-shrink-0 hidden sm:flex"
|
||||
className="p-2 rounded-lg transition-colors flex-shrink-0 hidden sm:flex relative w-8 h-8 items-center justify-center"
|
||||
style={{ color: 'var(--text-muted)' }}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
|
||||
{dark ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />}
|
||||
<Sun className="w-4 h-4 absolute transition-[transform,opacity] duration-300 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||
style={{ opacity: dark ? 1 : 0, transform: dark ? 'rotate(0deg) scale(1)' : 'rotate(-90deg) scale(0.6)' }} />
|
||||
<Moon className="w-4 h-4 absolute transition-[transform,opacity] duration-300 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||
style={{ opacity: dark ? 0 : 1, transform: dark ? 'rotate(90deg) scale(0.6)' : 'rotate(0deg) scale(1)' }} />
|
||||
</button>
|
||||
|
||||
{/* Notification bell — only in trip view on mobile, everywhere on desktop */}
|
||||
@@ -196,7 +221,7 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
|
||||
{userMenuOpen && ReactDOM.createPortal(
|
||||
<>
|
||||
<div style={{ position: 'fixed', inset: 0, zIndex: 9998 }} onClick={() => setUserMenuOpen(false)} />
|
||||
<div className="w-52 rounded-xl shadow-xl border overflow-hidden" style={{ position: 'fixed', top: 'var(--nav-h)', right: 8, zIndex: 9999, background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
|
||||
<div className="trek-menu-enter w-52 rounded-xl shadow-xl border overflow-hidden" style={{ position: 'fixed', top: 'var(--nav-h)', right: 8, zIndex: 9999, background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
|
||||
<div className="px-4 py-3 border-b" style={{ borderColor: 'var(--border-secondary)' }}>
|
||||
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>{user.username}</p>
|
||||
<p className="text-xs truncate" style={{ color: 'var(--text-muted)' }}>{user.email}</p>
|
||||
|
||||
@@ -582,7 +582,8 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
|
||||
borderColor: !pickerDateFilter ? 'var(--text-primary)' : 'var(--border-primary)',
|
||||
color: !pickerDateFilter ? 'var(--bg-primary)' : 'var(--text-muted)',
|
||||
}}>
|
||||
{t('memories.allPhotos')}
|
||||
<span className="hidden sm:inline">{t('memories.allPhotos')}</span>
|
||||
<span className="sm:hidden">{t('common.all')}</span>
|
||||
</button>
|
||||
</div>
|
||||
{selectedIds.size > 0 && (
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -187,7 +187,7 @@ describe('DayPlanSidebar', () => {
|
||||
const assignments = { '10': [assignment] }
|
||||
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], places: [place], assignments })} />)
|
||||
// The chevron button immediately follows the "Add Note" button (which has a title attribute)
|
||||
const addNoteBtn = screen.getByTitle('Add Note')
|
||||
const addNoteBtn = screen.getByLabelText('Add Note')
|
||||
const chevron = addNoteBtn.nextElementSibling as HTMLButtonElement
|
||||
expect(chevron).toBeTruthy()
|
||||
await user.click(chevron)
|
||||
@@ -201,7 +201,7 @@ describe('DayPlanSidebar', () => {
|
||||
const assignment = buildAssignment({ id: 99, day_id: 10, order_index: 0, place })
|
||||
const assignments = { '10': [assignment] }
|
||||
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], places: [place], assignments })} />)
|
||||
const getChevron = () => screen.getByTitle('Add Note').nextElementSibling as HTMLButtonElement
|
||||
const getChevron = () => screen.getByLabelText('Add Note').nextElementSibling as HTMLButtonElement
|
||||
await user.click(getChevron()) // collapse
|
||||
expect(screen.queryByText('Eiffel Tower')).not.toBeInTheDocument()
|
||||
await user.click(getChevron()) // re-expand
|
||||
@@ -362,28 +362,14 @@ describe('DayPlanSidebar', () => {
|
||||
const user = userEvent.setup()
|
||||
const onUndo = vi.fn()
|
||||
render(<DayPlanSidebar {...makeDefaultProps({ canUndo: true, lastActionLabel: 'Removed place', onUndo })} />)
|
||||
// Find the undo button — it has width 30, height 30 and is not disabled
|
||||
const buttons = screen.getAllByRole('button')
|
||||
// The undo button is the one with the Undo2 icon and is not disabled
|
||||
const undoBtn = buttons.find(btn => {
|
||||
const style = btn.getAttribute('style') || ''
|
||||
return style.includes('width: 30px') || style.includes('width:30px') || (style.includes('30') && !btn.disabled)
|
||||
})
|
||||
if (undoBtn) {
|
||||
await user.click(undoBtn)
|
||||
expect(onUndo).toHaveBeenCalled()
|
||||
}
|
||||
const undoBtn = screen.getByLabelText('Undo')
|
||||
await user.click(undoBtn)
|
||||
expect(onUndo).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-024: undo button not present when onUndo not provided', () => {
|
||||
render(<DayPlanSidebar {...makeDefaultProps({ canUndo: false })} />)
|
||||
// When onUndo is not provided, the undo section is not rendered at all
|
||||
const buttons = screen.getAllByRole('button')
|
||||
const undoBtn = buttons.find(btn => {
|
||||
const style = btn.getAttribute('style') || ''
|
||||
return style.includes('width: 30px')
|
||||
})
|
||||
expect(undoBtn).toBeUndefined()
|
||||
expect(screen.queryByLabelText('Undo')).toBeNull()
|
||||
})
|
||||
|
||||
// ── PDF export ──────────────────────────────────────────────────────────
|
||||
@@ -931,7 +917,7 @@ describe('DayPlanSidebar', () => {
|
||||
const user = userEvent.setup()
|
||||
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
|
||||
render(<DayPlanSidebar {...makeDefaultProps({ days: [day] })} />)
|
||||
const addNoteBtn = screen.getByTitle('Add Note')
|
||||
const addNoteBtn = screen.getByLabelText('Add Note')
|
||||
await user.click(addNoteBtn)
|
||||
expect(mockDayNotesState.openAddNote).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -4,7 +4,7 @@ declare global { interface Window { __dragData: DragDataPayload | null } }
|
||||
|
||||
import React, { useState, useEffect, useRef, useMemo } from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
import { ChevronDown, ChevronRight, ChevronUp, Navigation, RotateCcw, ExternalLink, Clock, Pencil, GripVertical, Ticket, Plus, FileText, Check, Trash2, Info, MapPin, Star, Heart, Camera, Lightbulb, Flag, Bookmark, Train, Bus, Plane, Car, Ship, Coffee, ShoppingBag, AlertTriangle, FileDown, Lock, Hotel, Utensils, Users, Undo2, X, Route as RouteIcon } from 'lucide-react'
|
||||
import { ChevronDown, ChevronRight, ChevronUp, ChevronsDownUp, ChevronsUpDown, Navigation, RotateCcw, ExternalLink, Clock, Pencil, GripVertical, Ticket, Plus, FileText, Check, Trash2, Info, MapPin, Star, Heart, Camera, Lightbulb, Flag, Bookmark, Train, Bus, Plane, Car, Ship, Coffee, ShoppingBag, AlertTriangle, FileDown, Lock, Hotel, Utensils, Users, Undo2, X, Route as RouteIcon } from 'lucide-react'
|
||||
|
||||
const RES_ICONS = { flight: Plane, hotel: Hotel, restaurant: Utensils, train: Train, car: Car, cruise: Ship, event: Ticket, tour: Users, other: FileText }
|
||||
import { assignmentsApi, reservationsApi } from '../../api/client'
|
||||
@@ -23,6 +23,7 @@ import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { formatDate, formatTime, dayTotalCost, currencyDecimals } from '../../utils/formatters'
|
||||
import { useDayNotes } from '../../hooks/useDayNotes'
|
||||
import Tooltip from '../shared/Tooltip'
|
||||
import type { Trip, Day, Place, Category, Assignment, Reservation, AssignmentsMap, RouteResult } from '../../types'
|
||||
|
||||
const NOTE_ICONS = [
|
||||
@@ -939,18 +940,9 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', position: 'relative', fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }}>
|
||||
{/* Reise-Titel */}
|
||||
<div style={{ padding: '16px 16px 12px', borderBottom: '1px solid var(--border-faint)', flexShrink: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 8 }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 600, fontSize: 14, color: 'var(--text-primary)', lineHeight: '1.3' }}>{trip?.title}</div>
|
||||
{(trip?.start_date || trip?.end_date) && (
|
||||
<div style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: 3 }}>
|
||||
{[trip.start_date, trip.end_date].filter(Boolean).map(d => new Date(d + 'T00:00:00Z').toLocaleDateString(locale, { day: 'numeric', month: 'short', timeZone: 'UTC' })).join(' – ')}
|
||||
{days.length > 0 && ` · ${days.length} ${t('dayplan.days')}`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Toolbar */}
|
||||
<div style={{ padding: '12px 16px', borderBottom: '1px solid var(--border-faint)', flexShrink: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: 8 }}>
|
||||
<div style={{ position: 'relative', flexShrink: 0 }}>
|
||||
<button
|
||||
onClick={async () => {
|
||||
@@ -1032,11 +1024,57 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{(() => {
|
||||
const allExpanded = days.length > 0 && days.every(d => expandedDays.has(d.id))
|
||||
const label = allExpanded ? t('dayplan.collapseAll') : t('dayplan.expandAll')
|
||||
return (
|
||||
<Tooltip label={label} placement="bottom">
|
||||
<button
|
||||
onClick={() => {
|
||||
const next = allExpanded ? new Set() : new Set(days.map(d => d.id))
|
||||
setExpandedDays(next)
|
||||
try { sessionStorage.setItem(`day-expanded-${tripId}`, JSON.stringify([...next])) } catch {}
|
||||
}}
|
||||
aria-label={label}
|
||||
aria-pressed={allExpanded}
|
||||
style={{
|
||||
position: 'relative', flexShrink: 0,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
width: 30, height: 30, borderRadius: 8,
|
||||
border: '1px solid var(--border-primary)', background: 'none',
|
||||
color: 'var(--text-primary)', cursor: 'pointer', fontFamily: 'inherit', padding: 0,
|
||||
transition: 'color 0.15s, border-color 0.15s, background 0.15s',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.background = 'var(--bg-hover)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = 'transparent' }}
|
||||
>
|
||||
<span style={{
|
||||
position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
transition: 'opacity 0.2s ease, transform 0.25s cubic-bezier(0.34, 1.56, 0.64, 1)',
|
||||
opacity: allExpanded ? 0 : 1,
|
||||
transform: allExpanded ? 'translateY(-8px) scale(0.6)' : 'translateY(0) scale(1)',
|
||||
}}>
|
||||
<ChevronsUpDown size={14} strokeWidth={2} />
|
||||
</span>
|
||||
<span style={{
|
||||
position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
transition: 'opacity 0.2s ease, transform 0.25s cubic-bezier(0.34, 1.56, 0.64, 1)',
|
||||
opacity: allExpanded ? 1 : 0,
|
||||
transform: allExpanded ? 'translateY(0) scale(1)' : 'translateY(8px) scale(0.6)',
|
||||
}}>
|
||||
<ChevronsDownUp size={14} strokeWidth={2} />
|
||||
</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
)
|
||||
})()}
|
||||
{onUndo && (
|
||||
<div style={{ position: 'relative', flexShrink: 0 }}>
|
||||
<button
|
||||
onClick={onUndo}
|
||||
disabled={!canUndo}
|
||||
aria-label={t('undo.button')}
|
||||
onMouseEnter={() => setUndoHover(true)}
|
||||
onMouseLeave={() => setUndoHover(false)}
|
||||
style={{
|
||||
@@ -1068,7 +1106,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
</div>
|
||||
|
||||
{/* Tagesliste */}
|
||||
<div className="scroll-container" style={{ flex: 1, overflowY: 'auto', minHeight: 0 }}>
|
||||
<div className="scroll-container trek-stagger" style={{ flex: 1, overflowY: 'auto', minHeight: 0 }}>
|
||||
{days.map((day, index) => {
|
||||
const isSelected = selectedDayId === day.id
|
||||
const isExpanded = expandedDays.has(day.id)
|
||||
@@ -1143,9 +1181,10 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
<Pencil size={15} strokeWidth={1.8} color="var(--text-secondary)" />
|
||||
</button>}
|
||||
{canEditDays && onAddTransport && (
|
||||
<Tooltip label={t('transport.addTransport')} placement="top">
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); onAddTransport(day.id) }}
|
||||
title={t('transport.addTransport')}
|
||||
aria-label={t('transport.addTransport')}
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
background: 'none',
|
||||
@@ -1162,6 +1201,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
>
|
||||
<Plus size={15} strokeWidth={1.8} color="var(--text-secondary)" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{(() => {
|
||||
const dayAccs = accommodations.filter(a => day.id >= a.start_day_id && day.id <= a.end_day_id)
|
||||
@@ -1217,15 +1257,15 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{canEditDays && <button
|
||||
{canEditDays && <Tooltip label={t('dayplan.addNote')} placement="top"><button
|
||||
onClick={e => openAddNote(day.id, e)}
|
||||
title={t('dayplan.addNote')}
|
||||
aria-label={t('dayplan.addNote')}
|
||||
style={{ flexShrink: 0, background: 'none', border: 'none', padding: 6, cursor: 'pointer', display: 'flex', alignItems: 'center', color: 'var(--text-faint)' }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}
|
||||
>
|
||||
<FileText size={16} strokeWidth={2} />
|
||||
</button>}
|
||||
</button></Tooltip>}
|
||||
<button
|
||||
onClick={e => toggleDay(day.id, e)}
|
||||
style={{ flexShrink: 0, background: 'none', border: 'none', padding: 6, cursor: 'pointer', display: 'flex', alignItems: 'center', color: 'var(--text-faint)' }}
|
||||
@@ -1535,7 +1575,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
border: 'none',
|
||||
background: active ? '#3b82f6' : 'transparent',
|
||||
color: active ? '#fff' : 'var(--text-faint)',
|
||||
transition: 'all 0.12s',
|
||||
transition: 'color 120ms cubic-bezier(0.23,1,0.32,1), background 120ms cubic-bezier(0.23,1,0.32,1)',
|
||||
}}
|
||||
onMouseEnter={e => { if (!active) e.currentTarget.style.color = 'var(--text-primary)' }}
|
||||
onMouseLeave={e => { if (!active) e.currentTarget.style.color = 'var(--text-faint)' }}
|
||||
@@ -1558,7 +1598,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
display: 'grid', placeItems: 'center', cursor: 'pointer',
|
||||
border: 'none', background: 'transparent',
|
||||
color: 'var(--text-faint)',
|
||||
transition: 'all 0.12s',
|
||||
transition: 'color 120ms cubic-bezier(0.23,1,0.32,1), background 120ms cubic-bezier(0.23,1,0.32,1)',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.color = 'var(--text-primary)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.color = 'var(--text-faint)' }}
|
||||
@@ -1768,7 +1808,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
border: 'none',
|
||||
background: active ? color : 'transparent',
|
||||
color: active ? '#fff' : 'var(--text-faint)',
|
||||
transition: 'all 0.12s',
|
||||
transition: 'color 120ms cubic-bezier(0.23,1,0.32,1), background 120ms cubic-bezier(0.23,1,0.32,1)',
|
||||
}}
|
||||
onMouseEnter={e => { if (!active) e.currentTarget.style.color = 'var(--text-primary)' }}
|
||||
onMouseLeave={e => { if (!active) e.currentTarget.style.color = 'var(--text-faint)' }}
|
||||
|
||||
@@ -195,7 +195,7 @@ describe('Filter tabs', () => {
|
||||
const assignments = { '1': [buildAssignment({ place: planned, day_id: 1 })] };
|
||||
render(<PlacesSidebar {...defaultProps} places={[planned, unplanned]} assignments={assignments} />);
|
||||
await user.click(screen.getByRole('button', { name: /Unplanned/i }));
|
||||
await user.click(screen.getByRole('button', { name: /^All$/i }));
|
||||
await user.click(screen.getByRole('button', { name: /^All/i }));
|
||||
expect(screen.getByText('Planned Place')).toBeInTheDocument();
|
||||
expect(screen.getByText('Unplanned Place')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -13,6 +13,7 @@ import { useCanDo } from '../../store/permissionsStore'
|
||||
import type { Place, Category, Day, AssignmentsMap } from '../../types'
|
||||
import FileImportModal from './FileImportModal'
|
||||
import ConfirmDialog from '../shared/ConfirmDialog'
|
||||
import Tooltip from '../shared/Tooltip'
|
||||
|
||||
interface PlacesSidebarProps {
|
||||
tripId: number
|
||||
@@ -372,74 +373,66 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
|
||||
>
|
||||
<MapPin size={11} strokeWidth={2} /> {t(hasMultipleListImportProviders ? 'places.importList' : 'places.importGoogleList')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setSelectMode(v => !v); setSelectedIds(new Set()) }}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
|
||||
padding: '5px 10px', borderRadius: 8,
|
||||
border: `1px solid ${selectMode ? 'var(--accent)' : 'var(--border-primary)'}`,
|
||||
background: selectMode ? 'color-mix(in srgb, var(--accent) 12%, transparent)' : 'none',
|
||||
color: selectMode ? 'var(--accent)' : 'var(--text-faint)', fontSize: 11, fontWeight: 500,
|
||||
cursor: 'pointer', fontFamily: 'inherit', flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Check size={11} strokeWidth={2} /> {t('common.select')}
|
||||
</button>
|
||||
</div>
|
||||
{selectMode && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 8, padding: '6px 8px', borderRadius: 8, background: 'var(--bg-tertiary)', fontSize: 11 }}>
|
||||
<span style={{ flex: 1, color: 'var(--text-muted)', fontWeight: 500 }}>
|
||||
{t('places.selectionCount', { count: selectedIds.size })}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (selectedIds.size === filtered.length) {
|
||||
setSelectedIds(new Set())
|
||||
} else {
|
||||
setSelectedIds(new Set(filtered.map(p => p.id)))
|
||||
}
|
||||
}}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)', fontSize: 11, fontFamily: 'inherit', padding: '2px 4px', borderRadius: 4 }}
|
||||
>
|
||||
{selectedIds.size === filtered.length && filtered.length > 0 ? t('common.deselectAll') : t('common.selectAll')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (selectedIds.size === 0) return
|
||||
if (isMobile) {
|
||||
setPendingDeleteIds(Array.from(selectedIds))
|
||||
} else {
|
||||
onBulkDeletePlaces?.(Array.from(selectedIds))
|
||||
}
|
||||
}}
|
||||
disabled={selectedIds.size === 0}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 4, background: 'none', border: 'none',
|
||||
cursor: selectedIds.size > 0 ? 'pointer' : 'default',
|
||||
color: selectedIds.size > 0 ? '#ef4444' : 'var(--text-faint)',
|
||||
fontSize: 11, fontFamily: 'inherit', padding: '2px 4px', borderRadius: 4, fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
<Trash2 size={11} strokeWidth={2} /> {t('places.deleteSelected')}
|
||||
</button>
|
||||
<button onClick={exitSelectMode} style={{ background: 'none', border: 'none', cursor: 'pointer', display: 'flex', padding: 2 }}>
|
||||
<X size={12} strokeWidth={2} color="var(--text-faint)" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ height: 1, background: 'var(--border-primary)', margin: '2px 0 10px' }} />
|
||||
</>}
|
||||
|
||||
{/* Filter-Tabs */}
|
||||
<div style={{ display: 'flex', gap: 4, marginBottom: 8 }}>
|
||||
{([{ id: 'all', label: t('places.all') }, { id: 'unplanned', label: t('places.unplanned') }, hasTracks ? { id: 'tracks', label: t('places.filterTracks') } : null] as const).filter(Boolean).map(f => (
|
||||
<button key={f.id} onClick={() => { setFilter(f.id); onPlacesFilterChange?.(f.id); setSelectedIds(new Set()) }} style={{
|
||||
padding: '4px 10px', borderRadius: 20, border: 'none', cursor: 'pointer',
|
||||
fontSize: 11, fontWeight: 500, fontFamily: 'inherit',
|
||||
background: filter === f.id ? 'var(--accent)' : 'var(--bg-tertiary)',
|
||||
color: filter === f.id ? 'var(--accent-text)' : 'var(--text-muted)',
|
||||
}}>{f.label}</button>
|
||||
))}
|
||||
</div>
|
||||
{(() => {
|
||||
const baseFiltered = places.filter(p => {
|
||||
if (categoryFilters.size > 0) {
|
||||
if (p.category_id == null) {
|
||||
if (!categoryFilters.has('uncategorized')) return false
|
||||
} else if (!categoryFilters.has(String(p.category_id))) return false
|
||||
}
|
||||
if (search && !p.name.toLowerCase().includes(search.toLowerCase()) &&
|
||||
!(p.address || '').toLowerCase().includes(search.toLowerCase())) return false
|
||||
return true
|
||||
})
|
||||
const counts = {
|
||||
all: baseFiltered.length,
|
||||
unplanned: baseFiltered.filter(p => !plannedIds.has(p.id)).length,
|
||||
tracks: baseFiltered.filter(p => p.route_geometry).length,
|
||||
}
|
||||
const tabs = ([
|
||||
{ id: 'all', label: t('places.all') },
|
||||
{ id: 'unplanned', label: t('places.unplanned') },
|
||||
hasTracks ? { id: 'tracks', label: t('places.filterTracks') } : null,
|
||||
] as const).filter(Boolean) as Array<{ id: 'all' | 'unplanned' | 'tracks'; label: string }>
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: 6, marginBottom: 8, flexWrap: 'wrap' }}>
|
||||
{tabs.map(f => {
|
||||
const active = filter === f.id
|
||||
return (
|
||||
<button
|
||||
key={f.id}
|
||||
onClick={() => { setFilter(f.id); onPlacesFilterChange?.(f.id); setSelectedIds(new Set()) }}
|
||||
style={{
|
||||
appearance: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit',
|
||||
display: 'inline-flex', alignItems: 'center', gap: 5,
|
||||
padding: '4px 9px', borderRadius: 99,
|
||||
fontSize: 11, fontWeight: 500, whiteSpace: 'nowrap',
|
||||
background: active ? 'var(--accent)' : 'var(--bg-card)',
|
||||
color: active ? 'var(--accent-text)' : 'var(--text-primary)',
|
||||
boxShadow: active ? 'none' : '0 1px 2px rgba(0,0,0,0.06)',
|
||||
transition: 'background 0.15s, color 0.15s, box-shadow 0.15s',
|
||||
}}
|
||||
>
|
||||
{f.label}
|
||||
<span style={{
|
||||
fontSize: 9, fontWeight: 600, lineHeight: 1,
|
||||
background: active ? 'color-mix(in srgb, var(--accent-text) 22%, transparent)' : 'var(--bg-tertiary)',
|
||||
color: active ? 'var(--accent-text)' : 'var(--text-faint)',
|
||||
padding: '1px 5px', borderRadius: 99, minWidth: 14, textAlign: 'center',
|
||||
}}>
|
||||
{counts[f.id]}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* Suchfeld */}
|
||||
<div style={{ position: 'relative' }}>
|
||||
@@ -470,9 +463,9 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
|
||||
? (categoryFilters.has('uncategorized') ? t('places.noCategory') : categories.find(c => categoryFilters.has(String(c.id)))?.name || t('places.allCategories'))
|
||||
: `${categoryFilters.size} ${t('places.categoriesSelected')}`
|
||||
return (
|
||||
<div style={{ marginTop: 6, position: 'relative' }}>
|
||||
<div style={{ marginTop: 6, position: 'relative', display: 'flex', gap: 6, alignItems: 'stretch' }}>
|
||||
<button onClick={() => setCatDropOpen(v => !v)} style={{
|
||||
width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
flex: 1, minWidth: 0, display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
padding: '6px 10px', borderRadius: 8, border: '1px solid var(--border-primary)',
|
||||
background: 'var(--bg-card)', fontSize: 12, color: 'var(--text-primary)',
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
@@ -480,6 +473,41 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{label}</span>
|
||||
<ChevronDown size={12} style={{ flexShrink: 0, color: 'var(--text-faint)', transform: catDropOpen ? 'rotate(180deg)' : 'none', transition: 'transform 0.15s' }} />
|
||||
</button>
|
||||
{canEditPlaces && (
|
||||
<Tooltip label={t('common.select')} placement="bottom">
|
||||
<button
|
||||
onClick={() => { setSelectMode(v => !v); setSelectedIds(new Set()) }}
|
||||
aria-label={t('common.select')}
|
||||
aria-pressed={selectMode}
|
||||
style={{
|
||||
position: 'relative', width: 30, flexShrink: 0, borderRadius: 8,
|
||||
border: `1px solid ${selectMode ? 'var(--accent)' : 'var(--border-primary)'}`,
|
||||
background: selectMode ? 'color-mix(in srgb, var(--accent) 14%, transparent)' : 'var(--bg-card)',
|
||||
color: selectMode ? 'var(--accent)' : 'var(--text-faint)',
|
||||
cursor: 'pointer', fontFamily: 'inherit', padding: 0,
|
||||
transition: 'background 0.18s, color 0.18s, border-color 0.18s',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<span style={{
|
||||
position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
transition: 'opacity 0.18s ease, transform 0.22s cubic-bezier(0.34, 1.56, 0.64, 1)',
|
||||
opacity: selectMode ? 0 : 1,
|
||||
transform: selectMode ? 'rotate(-90deg) scale(0.6)' : 'rotate(0) scale(1)',
|
||||
}}>
|
||||
<Check size={13} strokeWidth={2.4} />
|
||||
</span>
|
||||
<span style={{
|
||||
position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
transition: 'opacity 0.18s ease, transform 0.22s cubic-bezier(0.34, 1.56, 0.64, 1)',
|
||||
opacity: selectMode ? 1 : 0,
|
||||
transform: selectMode ? 'rotate(0) scale(1)' : 'rotate(90deg) scale(0.6)',
|
||||
}}>
|
||||
<X size={13} strokeWidth={2.4} />
|
||||
</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{catDropOpen && (
|
||||
<div style={{
|
||||
position: 'absolute', top: '100%', left: 0, right: 0, zIndex: 50, marginTop: 4,
|
||||
@@ -550,13 +578,65 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{/* Anzahl */}
|
||||
<div style={{ padding: '6px 16px', flexShrink: 0 }}>
|
||||
<span style={{ fontSize: 11, color: 'var(--text-faint)' }}>{filtered.length === 1 ? t('places.countSingular') : t('places.count', { count: filtered.length })}</span>
|
||||
</div>
|
||||
{/* Anzahl / Auswahl-Leiste */}
|
||||
{selectMode ? (
|
||||
<div style={{
|
||||
margin: '6px 16px', padding: '5px 8px 5px 10px', borderRadius: 8,
|
||||
background: 'color-mix(in srgb, var(--accent) 10%, transparent)',
|
||||
display: 'flex', alignItems: 'center', gap: 4, flexShrink: 0, fontSize: 11,
|
||||
}}>
|
||||
<span style={{ flex: 1, color: 'var(--accent)', fontWeight: 600, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{t('places.selectionCount', { count: selectedIds.size })}
|
||||
</span>
|
||||
<Tooltip label={selectedIds.size === filtered.length && filtered.length > 0 ? t('common.deselectAll') : t('common.selectAll')} placement="bottom">
|
||||
<button
|
||||
onClick={() => {
|
||||
if (selectedIds.size === filtered.length) setSelectedIds(new Set())
|
||||
else setSelectedIds(new Set(filtered.map(p => p.id)))
|
||||
}}
|
||||
aria-label={selectedIds.size === filtered.length && filtered.length > 0 ? t('common.deselectAll') : t('common.selectAll')}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
width: 24, height: 24, borderRadius: 6, border: 'none',
|
||||
background: 'transparent', color: 'var(--text-muted)', cursor: 'pointer', padding: 0,
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.background = 'var(--bg-hover)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = 'transparent' }}
|
||||
>
|
||||
<Check size={13} strokeWidth={2.2} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label={t('places.deleteSelected')} placement="bottom">
|
||||
<button
|
||||
onClick={() => {
|
||||
if (selectedIds.size === 0) return
|
||||
if (isMobile) setPendingDeleteIds(Array.from(selectedIds))
|
||||
else onBulkDeletePlaces?.(Array.from(selectedIds))
|
||||
}}
|
||||
disabled={selectedIds.size === 0}
|
||||
aria-label={t('places.deleteSelected')}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
width: 24, height: 24, borderRadius: 6, border: 'none',
|
||||
background: 'transparent',
|
||||
color: selectedIds.size > 0 ? '#ef4444' : 'var(--text-faint)',
|
||||
cursor: selectedIds.size > 0 ? 'pointer' : 'default', padding: 0,
|
||||
}}
|
||||
onMouseEnter={e => { if (selectedIds.size > 0) e.currentTarget.style.background = 'color-mix(in srgb, #ef4444 14%, transparent)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = 'transparent' }}
|
||||
>
|
||||
<Trash2 size={13} strokeWidth={2} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ padding: '6px 16px', flexShrink: 0 }}>
|
||||
<span style={{ fontSize: 11, color: 'var(--text-faint)' }}>{filtered.length === 1 ? t('places.countSingular') : t('places.count', { count: filtered.length })}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Liste */}
|
||||
<div style={{ flex: 1, overflowY: 'auto', minHeight: 0 }}>
|
||||
<div className="trek-stagger" style={{ flex: 1, overflowY: 'auto', minHeight: 0 }}>
|
||||
{filtered.length === 0 ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', padding: '40px 16px', gap: 8 }}>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-faint)' }}>
|
||||
|
||||
@@ -254,7 +254,7 @@ export default function AboutTab({ appVersion }: Props): React.ReactElement {
|
||||
href="https://ko-fi.com/mauriceboe"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-all"
|
||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
|
||||
onMouseEnter={e => { e.currentTarget.style.borderColor = '#ff5e5b'; e.currentTarget.style.boxShadow = '0 0 0 1px #ff5e5b22' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
||||
@@ -272,7 +272,7 @@ export default function AboutTab({ appVersion }: Props): React.ReactElement {
|
||||
href="https://buymeacoffee.com/mauriceboe"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-all"
|
||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
|
||||
onMouseEnter={e => { e.currentTarget.style.borderColor = '#ffdd00'; e.currentTarget.style.boxShadow = '0 0 0 1px #ffdd0022' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
||||
@@ -290,7 +290,7 @@ export default function AboutTab({ appVersion }: Props): React.ReactElement {
|
||||
href="https://discord.gg/NhZBDSd4qW"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-all"
|
||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
|
||||
onMouseEnter={e => { e.currentTarget.style.borderColor = '#5865F2'; e.currentTarget.style.boxShadow = '0 0 0 1px #5865F222' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
||||
@@ -311,7 +311,7 @@ export default function AboutTab({ appVersion }: Props): React.ReactElement {
|
||||
href="https://github.com/mauriceboe/TREK/issues/new?template=bug_report.yml"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-all"
|
||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
|
||||
onMouseEnter={e => { e.currentTarget.style.borderColor = '#ef4444'; e.currentTarget.style.boxShadow = '0 0 0 1px #ef444422' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
||||
@@ -329,7 +329,7 @@ export default function AboutTab({ appVersion }: Props): React.ReactElement {
|
||||
href="https://github.com/mauriceboe/TREK/discussions/new?category=feature-requests"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-all"
|
||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
|
||||
onMouseEnter={e => { e.currentTarget.style.borderColor = '#f59e0b'; e.currentTarget.style.boxShadow = '0 0 0 1px #f59e0b22' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
||||
@@ -347,7 +347,7 @@ export default function AboutTab({ appVersion }: Props): React.ReactElement {
|
||||
href="https://github.com/mauriceboe/TREK/wiki"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-all"
|
||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
|
||||
onMouseEnter={e => { e.currentTarget.style.borderColor = '#6366f1'; e.currentTarget.style.boxShadow = '0 0 0 1px #6366f122' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
||||
|
||||
@@ -42,7 +42,7 @@ describe('DisplaySettingsTab', () => {
|
||||
|
||||
it('FE-COMP-DISPLAY-005: shows Auto mode button', () => {
|
||||
render(<DisplaySettingsTab />);
|
||||
expect(screen.getByText('Auto')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Auto/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-DISPLAY-006: shows Language section', () => {
|
||||
@@ -95,16 +95,16 @@ describe('DisplaySettingsTab', () => {
|
||||
const updateSetting = vi.fn().mockResolvedValue(undefined);
|
||||
seedStore(useSettingsStore, { settings: buildSettings({ dark_mode: 'light' }), updateSetting });
|
||||
render(<DisplaySettingsTab />);
|
||||
await user.click(screen.getByText('Auto'));
|
||||
await user.click(screen.getByRole('button', { name: /Auto/i }));
|
||||
expect(updateSetting).toHaveBeenCalledWith('dark_mode', 'auto');
|
||||
});
|
||||
|
||||
it('FE-COMP-DISPLAY-014: active color mode button has border with var(--text-primary)', () => {
|
||||
seedStore(useSettingsStore, { settings: buildSettings({ dark_mode: 'dark' }) });
|
||||
render(<DisplaySettingsTab />);
|
||||
const darkBtn = screen.getByText('Dark').closest('button')!;
|
||||
const lightBtn = screen.getByText('Light').closest('button')!;
|
||||
const autoBtn = screen.getByText('Auto').closest('button')!;
|
||||
const darkBtn = screen.getByRole('button', { name: /^Dark$/i });
|
||||
const lightBtn = screen.getByRole('button', { name: /^Light$/i });
|
||||
const autoBtn = screen.getByRole('button', { name: /Auto/i });
|
||||
expect(darkBtn.style.border).toContain('var(--text-primary)');
|
||||
expect(lightBtn.style.border).toContain('var(--border-primary)');
|
||||
expect(autoBtn.style.border).toContain('var(--border-primary)');
|
||||
@@ -122,8 +122,11 @@ describe('DisplaySettingsTab', () => {
|
||||
it('FE-COMP-DISPLAY-016: active language button is visually highlighted', () => {
|
||||
seedStore(useSettingsStore, { settings: buildSettings({ language: 'en' }) });
|
||||
render(<DisplaySettingsTab />);
|
||||
const englishBtn = screen.getByText('English').closest('button')!;
|
||||
expect(englishBtn.style.border).toContain('var(--text-primary)');
|
||||
// Multiple elements contain "English" (desktop grid button + mobile dropdown trigger).
|
||||
// The desktop grid button is the one with the active border style.
|
||||
const englishMatches = screen.getAllByText('English').map(el => el.closest('button')!).filter(Boolean);
|
||||
const activeBtn = englishMatches.find(btn => (btn.style.border || '').includes('var(--text-primary)'));
|
||||
expect(activeBtn).toBeDefined();
|
||||
});
|
||||
|
||||
it('FE-COMP-DISPLAY-017: shows Temperature section label', () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { Palette, Sun, Moon, Monitor } from 'lucide-react'
|
||||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import { Palette, Sun, Moon, Monitor, ChevronDown, Check } from 'lucide-react'
|
||||
import { SUPPORTED_LANGUAGES, useTranslation } from '../../i18n'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useToast } from '../shared/Toast'
|
||||
@@ -10,6 +10,17 @@ export default function DisplaySettingsTab(): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const toast = useToast()
|
||||
const [tempUnit, setTempUnit] = useState<string>(settings.temperature_unit || 'celsius')
|
||||
const [langOpen, setLangOpen] = useState(false)
|
||||
const langDropdownRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!langOpen) return
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (langDropdownRef.current && !langDropdownRef.current.contains(e.target as Node)) setLangOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', handler)
|
||||
return () => document.removeEventListener('mousedown', handler)
|
||||
}, [langOpen])
|
||||
|
||||
useEffect(() => {
|
||||
setTempUnit(settings.temperature_unit || 'celsius')
|
||||
@@ -46,8 +57,13 @@ export default function DisplaySettingsTab(): React.ReactElement {
|
||||
transition: 'all 0.15s',
|
||||
}}
|
||||
>
|
||||
<opt.icon size={16} />
|
||||
{opt.label}
|
||||
<span className="hidden sm:inline-flex"><opt.icon size={16} /></span>
|
||||
{opt.value === 'auto' ? (
|
||||
<>
|
||||
<span className="hidden sm:inline">{opt.label}</span>
|
||||
<span className="sm:hidden">Auto</span>
|
||||
</>
|
||||
) : opt.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
@@ -57,7 +73,8 @@ export default function DisplaySettingsTab(): React.ReactElement {
|
||||
{/* Language */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>{t('settings.language')}</label>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{/* Desktop: Button grid */}
|
||||
<div className="hidden sm:flex flex-wrap gap-3">
|
||||
{SUPPORTED_LANGUAGES.map(opt => (
|
||||
<button
|
||||
key={opt.value}
|
||||
@@ -79,6 +96,60 @@ export default function DisplaySettingsTab(): React.ReactElement {
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{/* Mobile: Custom dropdown */}
|
||||
<div ref={langDropdownRef} className="sm:hidden" style={{ position: 'relative' }}>
|
||||
{(() => {
|
||||
const current = SUPPORTED_LANGUAGES.find(o => o.value === settings.language) || SUPPORTED_LANGUAGES[0]
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLangOpen(v => !v)}
|
||||
style={{
|
||||
width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
padding: '10px 14px', borderRadius: 10,
|
||||
border: '2px solid var(--border-primary)',
|
||||
background: 'var(--bg-card)', color: 'var(--text-primary)',
|
||||
fontSize: 14, fontWeight: 500, fontFamily: 'inherit', cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{current?.label}</span>
|
||||
<ChevronDown size={14} style={{ flexShrink: 0, color: 'var(--text-faint)', transform: langOpen ? 'rotate(180deg)' : 'none', transition: 'transform 0.15s' }} />
|
||||
</button>
|
||||
)
|
||||
})()}
|
||||
{langOpen && (
|
||||
<div style={{
|
||||
position: 'absolute', top: 'calc(100% + 4px)', left: 0, right: 0, zIndex: 50,
|
||||
background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 10,
|
||||
boxShadow: '0 8px 24px rgba(0,0,0,0.15)', padding: 4, maxHeight: 280, overflowY: 'auto',
|
||||
}}>
|
||||
{SUPPORTED_LANGUAGES.map(opt => {
|
||||
const active = settings.language === opt.value
|
||||
return (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
setLangOpen(false)
|
||||
try { await updateSetting('language', opt.value) }
|
||||
catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.error')) }
|
||||
}}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8, width: '100%',
|
||||
padding: '9px 12px', borderRadius: 6, border: 'none', cursor: 'pointer',
|
||||
background: active ? 'var(--bg-hover)' : 'transparent',
|
||||
fontFamily: 'inherit', fontSize: 14, color: 'var(--text-primary)',
|
||||
textAlign: 'left', fontWeight: active ? 600 : 500,
|
||||
}}
|
||||
>
|
||||
<span style={{ flex: 1 }}>{opt.label}</span>
|
||||
{active && <Check size={14} strokeWidth={2.5} color="var(--accent)" />}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Temperature */}
|
||||
|
||||
@@ -94,7 +94,7 @@ export default function VacayCalendar() {
|
||||
<div className="flex items-center gap-1.5 sm:gap-2 px-2 sm:px-3 py-1.5 sm:py-2 rounded-xl border" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', boxShadow: '0 8px 32px rgba(0,0,0,0.12)' }}>
|
||||
<button
|
||||
onClick={() => setCompanyMode(false)}
|
||||
className="flex items-center gap-1 sm:gap-1.5 px-2 sm:px-3 py-1.5 rounded-lg text-[11px] sm:text-xs font-medium transition-all"
|
||||
className="flex items-center gap-1 sm:gap-1.5 px-2 sm:px-3 py-1.5 rounded-lg text-[11px] sm:text-xs font-medium transition-[background-color,color,border-color] duration-150 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||
style={{
|
||||
background: !companyMode ? 'var(--text-primary)' : 'transparent',
|
||||
color: !companyMode ? 'var(--bg-card)' : 'var(--text-muted)',
|
||||
@@ -107,7 +107,7 @@ export default function VacayCalendar() {
|
||||
{companyHolidaysEnabled && (
|
||||
<button
|
||||
onClick={() => setCompanyMode(true)}
|
||||
className="flex items-center gap-1 sm:gap-1.5 px-2 sm:px-3 py-1.5 rounded-lg text-[11px] sm:text-xs font-medium transition-all"
|
||||
className="flex items-center gap-1 sm:gap-1.5 px-2 sm:px-3 py-1.5 rounded-lg text-[11px] sm:text-xs font-medium transition-[background-color,color,border-color] duration-150 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||
style={{
|
||||
background: companyMode ? '#d97706' : 'transparent',
|
||||
color: companyMode ? '#fff' : 'var(--text-muted)',
|
||||
|
||||
@@ -121,9 +121,9 @@ export default function VacayPersons() {
|
||||
|
||||
{/* Invite Modal — Portal to body to avoid z-index issues */}
|
||||
{showInvite && ReactDOM.createPortal(
|
||||
<div className="fixed inset-0 flex items-center justify-center px-4" style={{ zIndex: 99990, backgroundColor: 'rgba(15,23,42,0.5)', paddingTop: 70 }}
|
||||
<div className="fixed inset-0 flex items-center justify-center px-4 trek-backdrop-enter" style={{ zIndex: 99990, backgroundColor: 'rgba(15,23,42,0.5)', paddingTop: 70 }}
|
||||
onClick={() => setShowInvite(false)}>
|
||||
<div className="rounded-2xl shadow-2xl w-full max-w-sm" style={{ background: 'var(--bg-card)', animation: 'modalIn 0.2s ease-out' }}
|
||||
<div className="trek-modal-enter rounded-2xl shadow-2xl w-full max-w-sm" style={{ background: 'var(--bg-card)' }}
|
||||
onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between p-5" style={{ borderBottom: '1px solid var(--border-secondary)' }}>
|
||||
<h2 className="text-base font-semibold" style={{ color: 'var(--text-primary)' }}>{t('vacay.inviteUser')}</h2>
|
||||
@@ -164,9 +164,9 @@ export default function VacayPersons() {
|
||||
|
||||
{/* Color Picker Modal — Portal to body */}
|
||||
{showColorPicker && ReactDOM.createPortal(
|
||||
<div className="fixed inset-0 flex items-center justify-center px-4" style={{ zIndex: 99990, backgroundColor: 'rgba(15,23,42,0.5)', paddingTop: 70 }}
|
||||
<div className="fixed inset-0 flex items-center justify-center px-4 trek-backdrop-enter" style={{ zIndex: 99990, backgroundColor: 'rgba(15,23,42,0.5)', paddingTop: 70 }}
|
||||
onClick={() => { setShowColorPicker(false); setColorEditUserId(null) }}>
|
||||
<div className="rounded-2xl shadow-2xl w-full max-w-xs" style={{ background: 'var(--bg-card)', animation: 'modalIn 0.2s ease-out' }}
|
||||
<div className="trek-modal-enter rounded-2xl shadow-2xl w-full max-w-xs" style={{ background: 'var(--bg-card)' }}
|
||||
onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between p-5" style={{ borderBottom: '1px solid var(--border-secondary)' }}>
|
||||
<h2 className="text-base font-semibold" style={{ color: 'var(--text-primary)' }}>{t('vacay.changeColor')}</h2>
|
||||
@@ -178,7 +178,7 @@ export default function VacayPersons() {
|
||||
<div className="flex flex-wrap gap-2 justify-center">
|
||||
{PRESET_COLORS.map(c => (
|
||||
<button key={c} onClick={() => handleColorChange(c)}
|
||||
className={`w-8 h-8 rounded-full transition-all ${editingUserColor === c ? 'ring-2 ring-offset-2 scale-110' : 'hover:scale-110'}`}
|
||||
className={`w-8 h-8 rounded-full transition-transform duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] ${editingUserColor === c ? 'ring-2 ring-offset-2 scale-110' : 'hover:scale-110'}`}
|
||||
style={{ backgroundColor: c }} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -87,7 +87,10 @@ function StatCard({ stat: s, isMe, canEdit, selectedYear, onSave, t }: StatCardP
|
||||
<span className="text-[10px] tabular-nums" style={{ color: 'var(--text-faint)' }}>{s.used}/{s.total_available}</span>
|
||||
</div>
|
||||
<div className="h-1.5 rounded-full overflow-hidden" style={{ background: 'var(--bg-secondary)' }}>
|
||||
<div className="h-full rounded-full transition-all duration-500" style={{ width: `${pct}%`, backgroundColor: s.person_color }} />
|
||||
<div
|
||||
className="trek-bar-fill h-full rounded-full transition-[width] duration-500 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||
style={{ width: `${pct}%`, backgroundColor: s.person_color }}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-1.5">
|
||||
{/* Days — editable */}
|
||||
|
||||
@@ -40,16 +40,13 @@ export default function ConfirmDialog({
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[10000] flex items-center justify-center px-4"
|
||||
className="fixed inset-0 z-[10000] flex items-center justify-center px-4 trek-backdrop-enter"
|
||||
style={{ backgroundColor: 'rgba(15, 23, 42, 0.5)' }}
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="rounded-2xl shadow-2xl w-full max-w-sm p-6"
|
||||
style={{
|
||||
animation: 'modalIn 0.2s ease-out forwards',
|
||||
background: 'var(--bg-card)',
|
||||
}}
|
||||
className="trek-modal-enter rounded-2xl shadow-2xl w-full max-w-sm p-6"
|
||||
style={{ background: 'var(--bg-card)' }}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
@@ -90,12 +87,6 @@ export default function ConfirmDialog({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>{`
|
||||
@keyframes modalIn {
|
||||
from { opacity: 0; transform: scale(0.95) translateY(-10px); }
|
||||
to { opacity: 1; transform: scale(1) translateY(0); }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@ export function ContextMenu({ menu, onClose }: ContextMenuProps) {
|
||||
if (!menu) return null
|
||||
|
||||
return ReactDOM.createPortal(
|
||||
<div ref={ref} style={{
|
||||
<div ref={ref} className="trek-popover-enter" style={{
|
||||
position: 'fixed', left: menu.x, top: menu.y, zIndex: 999999,
|
||||
background: 'var(--bg-card)', borderRadius: 10, padding: '4px',
|
||||
border: '1px solid var(--border-primary)',
|
||||
@@ -73,7 +73,7 @@ export function ContextMenu({ menu, onClose }: ContextMenuProps) {
|
||||
backdropFilter: 'blur(20px)', WebkitBackdropFilter: 'blur(20px)',
|
||||
minWidth: 160,
|
||||
fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif",
|
||||
animation: 'ctxIn 0.1s ease-out',
|
||||
transformOrigin: 'top left',
|
||||
}}>
|
||||
{menu.items.filter(Boolean).map((item, i) => {
|
||||
if (item.divider) return <div key={i} style={{ height: 1, background: 'var(--border-faint)', margin: '3px 6px' }} />
|
||||
@@ -95,7 +95,6 @@ export function ContextMenu({ menu, onClose }: ContextMenuProps) {
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
<style>{`@keyframes ctxIn { from { opacity: 0; transform: scale(0.95) } to { opacity: 1; transform: scale(1) } }`}</style>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import React, { useCallback, useState } from 'react'
|
||||
import { Copy, Check } from 'lucide-react'
|
||||
|
||||
interface CopyButtonProps {
|
||||
value: string
|
||||
size?: number
|
||||
title?: string
|
||||
className?: string
|
||||
onCopy?: () => void
|
||||
}
|
||||
|
||||
// Button that morphs between copy icon and check icon for 1.5s after click.
|
||||
export function CopyButton({ value, size = 14, title, className, onCopy }: CopyButtonProps): React.ReactElement {
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const handleClick = useCallback(async (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
try {
|
||||
await navigator.clipboard.writeText(value)
|
||||
setCopied(true)
|
||||
onCopy?.()
|
||||
window.setTimeout(() => setCopied(false), 1500)
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
}, [value, onCopy])
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
title={title}
|
||||
className={className}
|
||||
style={{
|
||||
position: 'relative',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: size + 12,
|
||||
height: size + 12,
|
||||
border: 'none',
|
||||
background: 'transparent',
|
||||
color: copied ? '#22c55e' : 'var(--text-muted)',
|
||||
cursor: 'pointer',
|
||||
borderRadius: 6,
|
||||
}}
|
||||
>
|
||||
<Copy size={size} style={{
|
||||
position: 'absolute',
|
||||
transition: 'opacity 200ms cubic-bezier(0.23,1,0.32,1), transform 200ms cubic-bezier(0.23,1,0.32,1)',
|
||||
opacity: copied ? 0 : 1,
|
||||
transform: copied ? 'scale(0.6) rotate(-45deg)' : 'scale(1) rotate(0)',
|
||||
}} />
|
||||
<Check size={size} style={{
|
||||
position: 'absolute',
|
||||
transition: 'opacity 200ms cubic-bezier(0.23,1,0.32,1), transform 200ms cubic-bezier(0.23,1,0.32,1)',
|
||||
opacity: copied ? 1 : 0,
|
||||
transform: copied ? 'scale(1) rotate(0)' : 'scale(0.6) rotate(45deg)',
|
||||
strokeWidth: 2.5,
|
||||
}} />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export default CopyButton
|
||||
@@ -104,7 +104,7 @@ export default function CustomSelect({
|
||||
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', color: selected ? 'var(--text-primary)' : 'var(--text-faint)' }}>
|
||||
{selected ? selected.label : placeholder}
|
||||
</span>
|
||||
<ChevronDown size={sm ? 12 : 14} style={{ flexShrink: 0, color: 'var(--text-faint)', transition: 'transform 0.2s', transform: open ? 'rotate(180deg)' : 'none' }} />
|
||||
<ChevronDown size={sm ? 12 : 14} style={{ flexShrink: 0, color: 'var(--text-faint)', transition: 'transform 200ms cubic-bezier(0.23,1,0.32,1)', transform: open ? 'rotate(180deg)' : 'none' }} />
|
||||
</button>
|
||||
|
||||
{/* Dropdown */}
|
||||
@@ -128,7 +128,9 @@ export default function CustomSelect({
|
||||
borderRadius: 10,
|
||||
boxShadow: '0 8px 32px rgba(0,0,0,0.12)',
|
||||
overflow: 'hidden',
|
||||
animation: 'selectIn 0.15s ease-out',
|
||||
animation: 'trek-menu-enter 200ms cubic-bezier(0.23, 1, 0.32, 1)',
|
||||
transformOrigin: 'top center',
|
||||
willChange: 'transform, opacity',
|
||||
}}>
|
||||
{/* Search */}
|
||||
{searchable && (
|
||||
@@ -194,12 +196,6 @@ export default function CustomSelect({
|
||||
document.body
|
||||
)}
|
||||
|
||||
<style>{`
|
||||
@keyframes selectIn {
|
||||
from { opacity: 0; transform: translateY(-4px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import React, { useState, type ImgHTMLAttributes } from 'react'
|
||||
|
||||
interface LoadingImageProps extends ImgHTMLAttributes<HTMLImageElement> {
|
||||
containerClassName?: string
|
||||
containerStyle?: React.CSSProperties
|
||||
}
|
||||
|
||||
// Image with shimmer-placeholder until loaded. Drops the shimmer once native load fires.
|
||||
export function LoadingImage({
|
||||
containerClassName, containerStyle, className, style, onLoad, ...imgProps
|
||||
}: LoadingImageProps): React.ReactElement {
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
return (
|
||||
<div className={containerClassName} style={{ position: 'relative', overflow: 'hidden', ...containerStyle }}>
|
||||
{!loaded && (
|
||||
<div
|
||||
className="trek-skeleton"
|
||||
style={{ position: 'absolute', inset: 0, borderRadius: 0 }}
|
||||
aria-hidden
|
||||
/>
|
||||
)}
|
||||
<img
|
||||
{...imgProps}
|
||||
className={className}
|
||||
style={{
|
||||
...style,
|
||||
opacity: loaded ? 1 : 0,
|
||||
transition: 'opacity 300ms cubic-bezier(0.23, 1, 0.32, 1)',
|
||||
}}
|
||||
onLoad={e => { setLoaded(true); onLoad?.(e) }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default LoadingImage
|
||||
@@ -50,7 +50,7 @@ export default function Modal({
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[200] flex items-start sm:items-center justify-center px-4 modal-backdrop"
|
||||
className="fixed inset-0 z-[200] flex items-start sm:items-center justify-center px-4 modal-backdrop trek-backdrop-enter"
|
||||
style={{ backgroundColor: 'rgba(15, 23, 42, 0.5)', paddingTop: 70, paddingBottom: 'calc(20px + var(--bottom-nav-h))', overflow: 'hidden' }}
|
||||
onMouseDown={e => { mouseDownTarget.current = e.target }}
|
||||
onClick={e => {
|
||||
@@ -60,14 +60,11 @@ export default function Modal({
|
||||
>
|
||||
<div
|
||||
className={`
|
||||
trek-modal-enter
|
||||
rounded-2xl shadow-2xl w-full ${sizeClasses[size] || sizeClasses.md}
|
||||
flex flex-col max-h-[calc(100vh-180px)] md:max-h-[calc(100vh-90px)]
|
||||
animate-in fade-in zoom-in-95 duration-200
|
||||
`}
|
||||
style={{
|
||||
animation: 'modalIn 0.2s ease-out forwards',
|
||||
background: 'var(--bg-card)',
|
||||
}}
|
||||
style={{ background: 'var(--bg-card)' }}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
@@ -96,12 +93,6 @@ export default function Modal({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<style>{`
|
||||
@keyframes modalIn {
|
||||
from { opacity: 0; transform: scale(0.95) translateY(-10px); }
|
||||
to { opacity: 1; transform: scale(1) translateY(0); }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import React from 'react'
|
||||
|
||||
// Simple skeleton placeholder with shimmer. Size via className or props.
|
||||
export function Skeleton({
|
||||
width, height, radius, className, style,
|
||||
}: {
|
||||
width?: number | string
|
||||
height?: number | string
|
||||
radius?: number | string
|
||||
className?: string
|
||||
style?: React.CSSProperties
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<div
|
||||
className={`trek-skeleton ${className ?? ''}`.trim()}
|
||||
style={{
|
||||
width,
|
||||
height: height ?? 14,
|
||||
borderRadius: radius,
|
||||
...style,
|
||||
}}
|
||||
aria-hidden
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Trip card skeleton matching SpotlightCard layout
|
||||
export function SpotlightSkeleton(): React.ReactElement {
|
||||
return (
|
||||
<div
|
||||
className="relative rounded-3xl overflow-hidden mb-8"
|
||||
style={{ minHeight: 340, background: 'var(--bg-tertiary)' }}
|
||||
>
|
||||
<div className="trek-skeleton absolute inset-0" style={{ borderRadius: 24 }} />
|
||||
<div className="relative p-6 flex flex-col justify-end" style={{ minHeight: 340 }}>
|
||||
<Skeleton width={160} height={40} radius={8} style={{ marginBottom: 8 }} />
|
||||
<Skeleton width={220} height={16} radius={4} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Trip list item skeleton
|
||||
export function TripCardSkeleton(): React.ReactElement {
|
||||
return (
|
||||
<div
|
||||
className="rounded-2xl border border-zinc-200 dark:border-zinc-700 overflow-hidden"
|
||||
style={{ background: 'var(--bg-card)' }}
|
||||
>
|
||||
<Skeleton height={140} radius={0} />
|
||||
<div className="p-4 flex flex-col gap-2">
|
||||
<Skeleton width="60%" height={18} />
|
||||
<Skeleton width="40%" height={12} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Day sidebar skeleton row
|
||||
export function DaySkeleton(): React.ReactElement {
|
||||
return (
|
||||
<div style={{ padding: '12px 16px', display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<Skeleton width={120} height={16} />
|
||||
<Skeleton width="80%" height={12} />
|
||||
<Skeleton width="60%" height={12} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Skeleton
|
||||
@@ -0,0 +1,126 @@
|
||||
import React, { useLayoutEffect, useRef, useState, type CSSProperties } from 'react'
|
||||
|
||||
export interface SlidingTab<T extends string> {
|
||||
id: T
|
||||
label: React.ReactNode
|
||||
title?: string
|
||||
icon?: React.ComponentType<{ size?: number; className?: string }>
|
||||
count?: number
|
||||
}
|
||||
|
||||
interface SlidingTabsProps<T extends string> {
|
||||
tabs: readonly SlidingTab<T>[]
|
||||
activeTab: T
|
||||
onChange: (id: T) => void
|
||||
size?: 'sm' | 'md'
|
||||
fullWidth?: boolean
|
||||
className?: string
|
||||
indicatorColor?: string
|
||||
indicatorTextColor?: string
|
||||
}
|
||||
|
||||
// Stripe-style sliding indicator — der aktive Pill gleitet zwischen Tabs.
|
||||
// Nutzt gemessene Offsets der Buttons + CSS transform.
|
||||
export function SlidingTabs<T extends string>({
|
||||
tabs, activeTab, onChange, size = 'md', fullWidth, className,
|
||||
indicatorColor = 'var(--accent)', indicatorTextColor = 'var(--accent-text)',
|
||||
}: SlidingTabsProps<T>): React.ReactElement {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const tabRefs = useRef<Map<T, HTMLButtonElement | null>>(new Map())
|
||||
const [indicator, setIndicator] = useState<{ left: number; width: number; ready: boolean }>({ left: 0, width: 0, ready: false })
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const active = tabRefs.current.get(activeTab)
|
||||
const container = containerRef.current
|
||||
if (!active || !container) return
|
||||
const containerRect = container.getBoundingClientRect()
|
||||
const activeRect = active.getBoundingClientRect()
|
||||
setIndicator({
|
||||
left: activeRect.left - containerRect.left + container.scrollLeft,
|
||||
width: activeRect.width,
|
||||
ready: true,
|
||||
})
|
||||
}, [activeTab, tabs.length])
|
||||
|
||||
const padding = size === 'sm' ? '5px 12px' : '6px 14px'
|
||||
const fontSize = size === 'sm' ? 12 : 13
|
||||
const borderRadius = size === 'sm' ? 18 : 20
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={className}
|
||||
style={{
|
||||
position: 'relative', display: 'flex', alignItems: 'center',
|
||||
gap: 2, overflowX: 'auto', scrollbarWidth: 'none', msOverflowStyle: 'none',
|
||||
width: fullWidth ? '100%' : undefined,
|
||||
}}
|
||||
>
|
||||
{/* Sliding indicator */}
|
||||
{indicator.ready && (
|
||||
<div
|
||||
aria-hidden
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: indicator.left,
|
||||
width: indicator.width,
|
||||
height: size === 'sm' ? 26 : 30,
|
||||
background: indicatorColor,
|
||||
borderRadius,
|
||||
transform: 'translateY(-50%)',
|
||||
transition: 'left 320ms cubic-bezier(0.77, 0, 0.175, 1), width 320ms cubic-bezier(0.77, 0, 0.175, 1)',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 0,
|
||||
willChange: 'left, width',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{tabs.map(tab => {
|
||||
const isActive = tab.id === activeTab
|
||||
const Icon = tab.icon
|
||||
const btnStyle: CSSProperties = {
|
||||
position: 'relative', zIndex: 1,
|
||||
flexShrink: 0,
|
||||
padding,
|
||||
borderRadius,
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
fontSize,
|
||||
fontWeight: isActive ? 600 : 500,
|
||||
background: 'transparent',
|
||||
color: isActive ? indicatorTextColor : 'var(--text-muted)',
|
||||
fontFamily: 'inherit',
|
||||
transition: 'color 220ms cubic-bezier(0.23, 1, 0.32, 1)',
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
flex: fullWidth ? 1 : undefined,
|
||||
justifyContent: 'center',
|
||||
whiteSpace: 'nowrap',
|
||||
}
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
ref={el => { tabRefs.current.set(tab.id, el) }}
|
||||
onClick={() => onChange(tab.id)}
|
||||
style={btnStyle}
|
||||
title={tab.title ?? (typeof tab.label === 'string' ? tab.label : undefined)}
|
||||
>
|
||||
{Icon && <Icon size={size === 'sm' ? 13 : 15} />}
|
||||
{tab.label}
|
||||
{tab.count != null && (
|
||||
<span style={{
|
||||
fontSize: 10, fontWeight: 600,
|
||||
padding: '1px 6px', borderRadius: 99, minWidth: 16,
|
||||
background: isActive ? 'rgba(255,255,255,0.22)' : 'var(--bg-tertiary)',
|
||||
color: isActive ? 'inherit' : 'var(--text-faint)',
|
||||
textAlign: 'center',
|
||||
}}>{tab.count}</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SlidingTabs
|
||||
@@ -0,0 +1,100 @@
|
||||
import React, { useState, useRef, useEffect } from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
|
||||
type Placement = 'top' | 'bottom' | 'left' | 'right'
|
||||
|
||||
interface TooltipProps {
|
||||
label: string
|
||||
placement?: Placement
|
||||
delay?: number
|
||||
disabled?: boolean
|
||||
children: React.ReactElement
|
||||
}
|
||||
|
||||
export function Tooltip({ label, placement = 'bottom', delay = 250, disabled, children }: TooltipProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [coords, setCoords] = useState<{ top: number; left: number } | null>(null)
|
||||
const triggerRef = useRef<HTMLElement | null>(null)
|
||||
const tooltipRef = useRef<HTMLDivElement | null>(null)
|
||||
const timerRef = useRef<number | null>(null)
|
||||
|
||||
const show = () => {
|
||||
if (disabled || !label) return
|
||||
if (timerRef.current) window.clearTimeout(timerRef.current)
|
||||
timerRef.current = window.setTimeout(() => setOpen(true), delay)
|
||||
}
|
||||
const hide = () => {
|
||||
if (timerRef.current) window.clearTimeout(timerRef.current)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
useEffect(() => () => { if (timerRef.current) window.clearTimeout(timerRef.current) }, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !triggerRef.current) return
|
||||
const r = triggerRef.current.getBoundingClientRect()
|
||||
const tipW = tooltipRef.current?.offsetWidth ?? 0
|
||||
const tipH = tooltipRef.current?.offsetHeight ?? 0
|
||||
const gap = 6
|
||||
let top = 0, left = 0
|
||||
if (placement === 'top') { top = r.top - tipH - gap; left = r.left + r.width / 2 - tipW / 2 }
|
||||
else if (placement === 'bottom') { top = r.bottom + gap; left = r.left + r.width / 2 - tipW / 2 }
|
||||
else if (placement === 'left') { top = r.top + r.height / 2 - tipH / 2; left = r.left - tipW - gap }
|
||||
else { top = r.top + r.height / 2 - tipH / 2; left = r.right + gap }
|
||||
const pad = 6
|
||||
left = Math.max(pad, Math.min(left, window.innerWidth - tipW - pad))
|
||||
top = Math.max(pad, Math.min(top, window.innerHeight - tipH - pad))
|
||||
setCoords({ top, left })
|
||||
}, [open, placement, label])
|
||||
|
||||
const child = React.Children.only(children)
|
||||
const trigger = React.cloneElement(child, {
|
||||
ref: (node: HTMLElement | null) => {
|
||||
triggerRef.current = node
|
||||
const r = (child as any).ref
|
||||
if (typeof r === 'function') r(node)
|
||||
else if (r && typeof r === 'object') r.current = node
|
||||
},
|
||||
onMouseEnter: (e: any) => { show(); child.props.onMouseEnter?.(e) },
|
||||
onMouseLeave: (e: any) => { hide(); child.props.onMouseLeave?.(e) },
|
||||
onFocus: (e: any) => { show(); child.props.onFocus?.(e) },
|
||||
onBlur: (e: any) => { hide(); child.props.onBlur?.(e) },
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
{trigger}
|
||||
{open && ReactDOM.createPortal(
|
||||
<div
|
||||
ref={tooltipRef}
|
||||
role="tooltip"
|
||||
className="trek-popover-enter"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: coords?.top ?? -9999,
|
||||
left: coords?.left ?? -9999,
|
||||
visibility: coords ? 'visible' : 'hidden',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 100000,
|
||||
background: 'var(--bg-card, #ffffff)',
|
||||
color: 'var(--text-primary, #111827)',
|
||||
fontSize: 11,
|
||||
fontWeight: 500,
|
||||
padding: '5px 10px',
|
||||
borderRadius: 8,
|
||||
whiteSpace: 'nowrap',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
|
||||
border: '1px solid var(--border-faint, #e5e7eb)',
|
||||
fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif",
|
||||
transformOrigin: placement === 'top' ? 'bottom center' : placement === 'bottom' ? 'top center' : placement === 'left' ? 'center right' : 'center left',
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default Tooltip
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
// Zählt beim Mount von 0 auf target hoch. Feste Dauer mit ease-out-quint.
|
||||
export function useCountUp(target: number, duration = 800): number {
|
||||
const [value, setValue] = useState(0)
|
||||
const startRef = useRef<number | null>(null)
|
||||
const frameRef = useRef<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const reduced = window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches ?? false
|
||||
const isJsdom = typeof navigator !== 'undefined' && /jsdom/i.test(navigator.userAgent ?? '')
|
||||
if (reduced || isJsdom || target <= 0) { setValue(target); return }
|
||||
|
||||
startRef.current = null
|
||||
const step = (now: number) => {
|
||||
if (startRef.current == null) startRef.current = now
|
||||
const elapsed = now - startRef.current
|
||||
const t = Math.min(elapsed / duration, 1)
|
||||
// ease-out-quint
|
||||
const eased = 1 - Math.pow(1 - t, 5)
|
||||
setValue(Math.round(target * eased))
|
||||
if (t < 1) frameRef.current = requestAnimationFrame(step)
|
||||
}
|
||||
frameRef.current = requestAnimationFrame(step)
|
||||
return () => { if (frameRef.current) cancelAnimationFrame(frameRef.current) }
|
||||
}, [target, duration])
|
||||
|
||||
return value
|
||||
}
|
||||
@@ -1577,6 +1577,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
'memories.providerPassword': 'كلمة المرور',
|
||||
'memories.providerOTP': 'رمز MFA (إذا كان مفعلاً)',
|
||||
'memories.skipSSLVerification': 'تخطي التحقق من شهادة SSL',
|
||||
'memories.immichAutoUpload': 'نسخ صور الرحلة إلى Immich عند الرفع',
|
||||
'memories.providerUrlHintSynology': 'أدرج مسار تطبيق Photos في URL، مثل https://nas:5001/photo',
|
||||
'memories.testConnection': 'اختبار الاتصال',
|
||||
'memories.testFirst': 'اختبر الاتصال أولاً',
|
||||
@@ -1655,6 +1656,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.invite.inviting': 'جارٍ الدعوة...',
|
||||
|
||||
// Journey Entry Editor
|
||||
'journey.editor.discardChangesConfirm': 'لديك تغييرات غير محفوظة. هل تريد تجاهلها؟',
|
||||
'journey.editor.uploadPhotos': 'رفع صور',
|
||||
'journey.editor.uploading': '...جارٍ الرفع',
|
||||
'journey.editor.fromGallery': 'من المعرض',
|
||||
|
||||
@@ -1616,6 +1616,7 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
'memories.providerPassword': 'Senha',
|
||||
'memories.providerOTP': 'Código MFA (se habilitado)',
|
||||
'memories.skipSSLVerification': 'Pular verificação de certificado SSL',
|
||||
'memories.immichAutoUpload': 'Espelhar fotos da jornada no Immich ao enviar',
|
||||
'memories.providerUrlHintSynology': 'Inclua o caminho do aplicativo Photos na URL, ex. https://nas:5001/photo',
|
||||
'memories.testConnection': 'Testar conexão',
|
||||
'memories.testFirst': 'Teste a conexão primeiro',
|
||||
@@ -2025,6 +2026,7 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.verdict.couldBeBetter': 'Poderia ser melhor',
|
||||
'journey.synced.places': 'lugares',
|
||||
'journey.synced.synced': 'sincronizado',
|
||||
'journey.editor.discardChangesConfirm': 'Você tem alterações não salvas. Descartá-las?',
|
||||
'journey.editor.uploadPhotos': 'Enviar fotos',
|
||||
'journey.editor.uploading': 'Enviando...',
|
||||
'journey.editor.fromGallery': 'Da galeria',
|
||||
|
||||
@@ -1575,6 +1575,7 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
||||
'memories.providerPassword': 'Heslo',
|
||||
'memories.providerOTP': 'MFA kód (pokud je povoleno)',
|
||||
'memories.skipSSLVerification': 'Přeskočit ověření SSL certifikátu',
|
||||
'memories.immichAutoUpload': 'Zrcadlit fotky journey při nahrávání také do Immich',
|
||||
'memories.providerUrlHintSynology': 'Zahrňte cestu aplikace Photos do URL, např. https://nas:5001/photo',
|
||||
'memories.testConnection': 'Otestovat připojení',
|
||||
'memories.testFirst': 'Nejprve otestujte připojení',
|
||||
@@ -2030,6 +2031,7 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.verdict.couldBeBetter': 'Mohlo by být lepší',
|
||||
'journey.synced.places': 'místa',
|
||||
'journey.synced.synced': 'synchronizováno',
|
||||
'journey.editor.discardChangesConfirm': 'Máte neuložené změny. Zahodit?',
|
||||
'journey.editor.uploadPhotos': 'Nahrát fotky',
|
||||
'journey.editor.uploading': 'Nahrávání...',
|
||||
'journey.editor.fromGallery': 'Z galerie',
|
||||
|
||||
@@ -148,7 +148,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'settings.subtitle': 'Konfigurieren Sie Ihre persönlichen Einstellungen',
|
||||
'settings.tabs.display': 'Anzeige',
|
||||
'settings.tabs.map': 'Karte',
|
||||
'settings.tabs.notifications': 'Benachrichtigungen',
|
||||
'settings.tabs.notifications': 'Mitteilungen',
|
||||
'settings.tabs.integrations': 'Integrationen',
|
||||
'settings.tabs.account': 'Konto',
|
||||
'settings.tabs.offline': 'Offline',
|
||||
@@ -182,7 +182,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'settings.bookingLabels': 'Orts-Labels auf Buchungsrouten',
|
||||
'settings.bookingLabelsHint': 'Zeigt Bahnhofs-/Flughafennamen auf der Karte. Wenn aus, wird nur das Icon angezeigt.',
|
||||
'settings.blurBookingCodes': 'Buchungscodes verbergen',
|
||||
'settings.notifications': 'Benachrichtigungen',
|
||||
'settings.notifications': 'Mitteilungen',
|
||||
'settings.notifyTripInvite': 'Trip-Einladungen',
|
||||
'settings.notifyBookingChange': 'Buchungsänderungen',
|
||||
'settings.notifyTripReminder': 'Trip-Erinnerungen',
|
||||
@@ -873,7 +873,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
|
||||
// Trip Planner
|
||||
'trip.tabs.plan': 'Karte',
|
||||
'trip.tabs.transports': 'Transporte',
|
||||
'trip.tabs.transports': 'Transport',
|
||||
'trip.tabs.reservations': 'Buchungen',
|
||||
'trip.tabs.reservationsShort': 'Buchung',
|
||||
'trip.tabs.packing': 'Liste',
|
||||
@@ -908,6 +908,8 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'dayplan.cannotDropOnTimed': 'Orte können nicht zwischen zeitgebundene Einträge geschoben werden',
|
||||
'dayplan.cannotBreakChronology': 'Die zeitliche Reihenfolge von Uhrzeiten und Buchungen darf nicht verletzt werden',
|
||||
'dayplan.addNote': 'Notiz hinzufügen',
|
||||
'dayplan.expandAll': 'Alle Tage ausklappen',
|
||||
'dayplan.collapseAll': 'Alle Tage einklappen',
|
||||
'dayplan.editNote': 'Notiz bearbeiten',
|
||||
'dayplan.noteAdd': 'Notiz hinzufügen',
|
||||
'dayplan.noteEdit': 'Notiz bearbeiten',
|
||||
@@ -932,7 +934,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
|
||||
// Places Sidebar
|
||||
'places.addPlace': 'Ort/Aktivität hinzufügen',
|
||||
'places.importFile': 'Datei importieren',
|
||||
'places.importFile': 'Dateimport',
|
||||
'places.sidebarDrop': 'Ablegen zum Importieren',
|
||||
'places.importFileHint': '.gpx-, .kml- oder .kmz-Dateien aus Tools wie Google My Maps, Google Earth oder einem GPS-Tracker importieren.',
|
||||
'places.importFileDropHere': 'Datei auswählen oder hierher ziehen und ablegen',
|
||||
@@ -1577,6 +1579,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'memories.providerPassword': 'Passwort',
|
||||
'memories.providerOTP': 'MFA-Code (falls aktiviert)',
|
||||
'memories.skipSSLVerification': 'SSL-Zertifikatsprüfung überspringen',
|
||||
'memories.immichAutoUpload': 'Journey-Fotos beim Upload auch zu Immich spiegeln',
|
||||
'memories.providerUrlHintSynology': 'Füge den Fotos-App-Pfad in die URL ein, z.B. https://nas:5001/photo',
|
||||
'memories.testConnection': 'Verbindung testen',
|
||||
'memories.testFirst': 'Verbindung zuerst testen',
|
||||
@@ -2031,6 +2034,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.verdict.couldBeBetter': 'Verbesserungswürdig',
|
||||
'journey.synced.places': 'Orte',
|
||||
'journey.synced.synced': 'synchronisiert',
|
||||
'journey.editor.discardChangesConfirm': 'Du hast ungespeicherte Änderungen. Verwerfen?',
|
||||
'journey.editor.uploadPhotos': 'Fotos hochladen',
|
||||
'journey.editor.uploading': 'Hochladen...',
|
||||
'journey.editor.fromGallery': 'Aus Galerie',
|
||||
@@ -2081,6 +2085,10 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.contributors.role': 'Rolle',
|
||||
'journey.contributors.added': 'Mitwirkender hinzugefügt',
|
||||
'journey.contributors.addFailed': 'Hinzufügen fehlgeschlagen',
|
||||
'journey.contributors.remove': 'Mitwirkenden entfernen',
|
||||
'journey.contributors.removeConfirm': '{username} aus dieser Journey entfernen?',
|
||||
'journey.contributors.removed': 'Mitwirkender entfernt',
|
||||
'journey.contributors.removeFailed': 'Entfernen fehlgeschlagen',
|
||||
'journey.share.publicShare': 'Öffentlicher Link',
|
||||
'journey.share.createLink': 'Link erstellen',
|
||||
'journey.share.linkCreated': 'Link erstellt',
|
||||
|
||||
@@ -965,6 +965,8 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'dayplan.cannotDropOnTimed': 'Items cannot be placed between time-bound entries',
|
||||
'dayplan.cannotBreakChronology': 'This would break the chronological order of timed items and bookings',
|
||||
'dayplan.addNote': 'Add Note',
|
||||
'dayplan.expandAll': 'Expand all days',
|
||||
'dayplan.collapseAll': 'Collapse all days',
|
||||
'dayplan.editNote': 'Edit Note',
|
||||
'dayplan.noteAdd': 'Add Note',
|
||||
'dayplan.noteEdit': 'Edit Note',
|
||||
@@ -1636,6 +1638,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'memories.providerPassword': 'Password',
|
||||
'memories.providerOTP': 'MFA code (if enabled)',
|
||||
'memories.skipSSLVerification': 'Skip SSL certificate verification',
|
||||
'memories.immichAutoUpload': 'Mirror journey photos to Immich on upload',
|
||||
'memories.providerUrlHintSynology': 'Include the Photos app path in the URL, e.g. https://nas:5001/photo',
|
||||
'memories.testConnection': 'Test connection',
|
||||
'memories.testFirst': 'Test connection first',
|
||||
@@ -2043,6 +2046,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.synced.synced': 'synced',
|
||||
|
||||
// Journey Entry Editor
|
||||
'journey.editor.discardChangesConfirm': 'You have unsaved changes. Discard them?',
|
||||
'journey.editor.uploadPhotos': 'Upload photos',
|
||||
'journey.editor.uploading': 'Uploading...',
|
||||
'journey.editor.fromGallery': 'From Gallery',
|
||||
@@ -2101,6 +2105,10 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.contributors.role': 'Role',
|
||||
'journey.contributors.added': 'Contributor added',
|
||||
'journey.contributors.addFailed': 'Failed to add contributor',
|
||||
'journey.contributors.remove': 'Remove contributor',
|
||||
'journey.contributors.removeConfirm': 'Remove {username} from this journey?',
|
||||
'journey.contributors.removed': 'Contributor removed',
|
||||
'journey.contributors.removeFailed': 'Failed to remove contributor',
|
||||
|
||||
// Journey — Share
|
||||
'journey.share.publicShare': 'Public Share',
|
||||
|
||||
@@ -1516,6 +1516,7 @@ const es: Record<string, string> = {
|
||||
'memories.providerPassword': 'Contraseña',
|
||||
'memories.providerOTP': 'Código MFA (si está habilitado)',
|
||||
'memories.skipSSLVerification': 'Omitir verificación del certificado SSL',
|
||||
'memories.immichAutoUpload': 'Duplicar las fotos del journey en Immich al subirlas',
|
||||
'memories.providerUrlHintSynology': 'Incluye la ruta de la aplicación Photos en la URL, p.ej. https://nas:5001/photo',
|
||||
'memories.testConnection': 'Probar conexión',
|
||||
'memories.testFirst': 'Probar conexión primero',
|
||||
@@ -2032,6 +2033,7 @@ const es: Record<string, string> = {
|
||||
'journey.verdict.couldBeBetter': 'Podría mejorar',
|
||||
'journey.synced.places': 'lugares',
|
||||
'journey.synced.synced': 'sincronizado',
|
||||
'journey.editor.discardChangesConfirm': 'Tienes cambios sin guardar. ¿Descartarlos?',
|
||||
'journey.editor.uploadPhotos': 'Subir fotos',
|
||||
'journey.editor.uploading': 'Subiendo...',
|
||||
'journey.editor.fromGallery': 'Desde galería',
|
||||
|
||||
@@ -1573,6 +1573,7 @@ const fr: Record<string, string> = {
|
||||
'memories.providerPassword': 'Mot de passe',
|
||||
'memories.providerOTP': 'Code MFA (si activé)',
|
||||
'memories.skipSSLVerification': 'Ignorer la vérification du certificat SSL',
|
||||
'memories.immichAutoUpload': 'Répliquer les photos du journey vers Immich au téléversement',
|
||||
'memories.providerUrlHintSynology': 'Incluez le chemin de l\'application Photos dans l\'URL, ex. https://nas:5001/photo',
|
||||
'memories.testConnection': 'Tester la connexion',
|
||||
'memories.testFirst': 'Testez la connexion avant de sauvegarder',
|
||||
@@ -2026,6 +2027,7 @@ const fr: Record<string, string> = {
|
||||
'journey.verdict.couldBeBetter': 'Pourrait être mieux',
|
||||
'journey.synced.places': 'lieux',
|
||||
'journey.synced.synced': 'synchronisé',
|
||||
'journey.editor.discardChangesConfirm': 'Vous avez des modifications non enregistrées. Les ignorer ?',
|
||||
'journey.editor.uploadPhotos': 'Téléverser des photos',
|
||||
'journey.editor.uploading': 'Envoi...',
|
||||
'journey.editor.fromGallery': 'Depuis la galerie',
|
||||
|
||||
@@ -1644,6 +1644,7 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
||||
'memories.providerPassword': 'Jelszó',
|
||||
'memories.providerOTP': 'MFA kód (ha engedélyezve van)',
|
||||
'memories.skipSSLVerification': 'SSL tanúsítvány ellenőrzésének kihagyása',
|
||||
'memories.immichAutoUpload': 'Journey-fotók feltöltésekor másolat Immich-be is',
|
||||
'memories.providerUrlHintSynology': 'Adja meg a Photos alkalmazás elérési útját az URL-ben, pl. https://nas:5001/photo',
|
||||
'memories.testConnection': 'Kapcsolat tesztelése',
|
||||
'memories.testFirst': 'Először teszteld a kapcsolatot',
|
||||
@@ -2027,6 +2028,7 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.verdict.couldBeBetter': 'Lehetne jobb',
|
||||
'journey.synced.places': 'helyszín',
|
||||
'journey.synced.synced': 'szinkronizálva',
|
||||
'journey.editor.discardChangesConfirm': 'Mentetlen módosításaid vannak. Elveted?',
|
||||
'journey.editor.uploadPhotos': 'Fotók feltöltése',
|
||||
'journey.editor.uploading': 'Feltöltés...',
|
||||
'journey.editor.fromGallery': 'Galériából',
|
||||
|
||||
@@ -1636,6 +1636,7 @@ const id: Record<string, string | { name: string; category: string }[]> = {
|
||||
'memories.providerPassword': 'Kata sandi',
|
||||
'memories.providerOTP': 'Kode MFA (jika diaktifkan)',
|
||||
'memories.skipSSLVerification': 'Lewati verifikasi sertifikat SSL',
|
||||
'memories.immichAutoUpload': 'Salin foto journey ke Immich saat diunggah',
|
||||
'memories.providerUrlHintSynology': 'Sertakan path aplikasi Photos di URL, mis. https://nas:5001/photo',
|
||||
'memories.testConnection': 'Uji koneksi',
|
||||
'memories.testFirst': 'Uji koneksi terlebih dahulu',
|
||||
@@ -2042,6 +2043,7 @@ const id: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.synced.synced': 'tersinkron',
|
||||
|
||||
// Journey Entry Editor
|
||||
'journey.editor.discardChangesConfirm': 'Anda memiliki perubahan yang belum disimpan. Buang?',
|
||||
'journey.editor.uploadPhotos': 'Unggah foto',
|
||||
'journey.editor.uploading': 'Mengunggah...',
|
||||
'journey.editor.fromGallery': 'Dari Galeri',
|
||||
|
||||
@@ -1574,6 +1574,7 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
||||
'memories.providerPassword': 'Password',
|
||||
'memories.providerOTP': 'Codice MFA (se abilitato)',
|
||||
'memories.skipSSLVerification': 'Ignora la verifica del certificato SSL',
|
||||
'memories.immichAutoUpload': 'Rispecchia le foto del journey su Immich al caricamento',
|
||||
'memories.providerUrlHintSynology': 'Includi il percorso dell\'app Foto nell\'URL, es. https://nas:5001/photo',
|
||||
'memories.testConnection': 'Test connessione',
|
||||
'memories.testFirst': 'Testa prima la connessione',
|
||||
@@ -2027,6 +2028,7 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.verdict.couldBeBetter': 'Potrebbe essere meglio',
|
||||
'journey.synced.places': 'luoghi',
|
||||
'journey.synced.synced': 'sincronizzato',
|
||||
'journey.editor.discardChangesConfirm': 'Hai modifiche non salvate. Vuoi scartarle?',
|
||||
'journey.editor.uploadPhotos': 'Carica foto',
|
||||
'journey.editor.uploading': 'Caricamento...',
|
||||
'journey.editor.fromGallery': 'Dalla galleria',
|
||||
|
||||
@@ -1573,6 +1573,7 @@ const nl: Record<string, string> = {
|
||||
'memories.providerPassword': 'Wachtwoord',
|
||||
'memories.providerOTP': 'MFA-code (indien ingeschakeld)',
|
||||
'memories.skipSSLVerification': 'SSL-certificaatverificatie overslaan',
|
||||
'memories.immichAutoUpload': 'Journey-foto\'s bij upload ook naar Immich spiegelen',
|
||||
'memories.providerUrlHintSynology': 'Voeg het pad van de Photos-app toe aan de URL, bijv. https://nas:5001/photo',
|
||||
'memories.testConnection': 'Verbinding testen',
|
||||
'memories.testFirst': 'Test eerst de verbinding',
|
||||
@@ -2026,6 +2027,7 @@ const nl: Record<string, string> = {
|
||||
'journey.verdict.couldBeBetter': 'Kan beter',
|
||||
'journey.synced.places': 'plaatsen',
|
||||
'journey.synced.synced': 'gesynchroniseerd',
|
||||
'journey.editor.discardChangesConfirm': 'Je hebt niet-opgeslagen wijzigingen. Verwerpen?',
|
||||
'journey.editor.uploadPhotos': 'Foto\'s uploaden',
|
||||
'journey.editor.uploading': 'Uploaden...',
|
||||
'journey.editor.fromGallery': 'Uit galerij',
|
||||
|
||||
@@ -1525,6 +1525,7 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
||||
'memories.providerPassword': 'Hasło',
|
||||
'memories.providerOTP': 'Kod MFA (jeśli włączony)',
|
||||
'memories.skipSSLVerification': 'Pomiń weryfikację certyfikatu SSL',
|
||||
'memories.immichAutoUpload': 'Przy przesyłaniu kopiuj zdjęcia journey także do Immich',
|
||||
'memories.providerUrlHintSynology': 'Uwzględnij ścieżkę aplikacji Photos w URL, np. https://nas:5001/photo',
|
||||
'memories.testConnection': 'Test',
|
||||
'memories.connected': 'Połączono',
|
||||
@@ -2019,6 +2020,7 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.verdict.couldBeBetter': 'Mogłoby być lepiej',
|
||||
'journey.synced.places': 'miejsca',
|
||||
'journey.synced.synced': 'zsynchronizowane',
|
||||
'journey.editor.discardChangesConfirm': 'Masz niezapisane zmiany. Odrzucić?',
|
||||
'journey.editor.uploadPhotos': 'Prześlij zdjęcia',
|
||||
'journey.editor.uploading': 'Przesyłanie...',
|
||||
'journey.editor.fromGallery': 'Z galerii',
|
||||
|
||||
@@ -1573,6 +1573,7 @@ const ru: Record<string, string> = {
|
||||
'memories.providerPassword': 'Пароль',
|
||||
'memories.providerOTP': 'Код MFA (если включён)',
|
||||
'memories.skipSSLVerification': 'Пропустить проверку SSL-сертификата',
|
||||
'memories.immichAutoUpload': 'Дублировать фото journey в Immich при загрузке',
|
||||
'memories.providerUrlHintSynology': 'Включите путь приложения Photos в URL, например https://nas:5001/photo',
|
||||
'memories.testConnection': 'Проверить подключение',
|
||||
'memories.testFirst': 'Сначала проверьте подключение',
|
||||
@@ -2026,6 +2027,7 @@ const ru: Record<string, string> = {
|
||||
'journey.verdict.couldBeBetter': 'Могло быть лучше',
|
||||
'journey.synced.places': 'мест',
|
||||
'journey.synced.synced': 'синхронизировано',
|
||||
'journey.editor.discardChangesConfirm': 'У вас есть несохранённые изменения. Отменить?',
|
||||
'journey.editor.uploadPhotos': 'Загрузить фото',
|
||||
'journey.editor.uploading': 'Загрузка...',
|
||||
'journey.editor.fromGallery': 'Из галереи',
|
||||
|
||||
@@ -1573,6 +1573,7 @@ const zh: Record<string, string> = {
|
||||
'memories.providerPassword': '密码',
|
||||
'memories.providerOTP': 'MFA 验证码(如已启用)',
|
||||
'memories.skipSSLVerification': '跳过 SSL 证书验证',
|
||||
'memories.immichAutoUpload': '上传 Journey 照片时同步到 Immich',
|
||||
'memories.providerUrlHintSynology': '在 URL 中包含照片应用路径,例如 https://nas:5001/photo',
|
||||
'memories.testConnection': '测试连接',
|
||||
'memories.testFirst': '请先测试连接',
|
||||
@@ -2026,6 +2027,7 @@ const zh: Record<string, string> = {
|
||||
'journey.verdict.couldBeBetter': '有待改进',
|
||||
'journey.synced.places': '个地点',
|
||||
'journey.synced.synced': '已同步',
|
||||
'journey.editor.discardChangesConfirm': '您有未保存的更改。要放弃吗?',
|
||||
'journey.editor.uploadPhotos': '上传照片',
|
||||
'journey.editor.uploading': '上传中...',
|
||||
'journey.editor.fromGallery': '从相册',
|
||||
|
||||
@@ -1633,6 +1633,7 @@ const zhTw: Record<string, string> = {
|
||||
'memories.providerPassword': '密碼',
|
||||
'memories.providerOTP': 'MFA 驗證碼(如已啟用)',
|
||||
'memories.skipSSLVerification': '跳過 SSL 憑證驗證',
|
||||
'memories.immichAutoUpload': '上傳 Journey 照片時同步到 Immich',
|
||||
'memories.providerUrlHintSynology': '在網址中包含照片應用程式路徑,例如 https://nas:5001/photo',
|
||||
'memories.testConnection': '測試連線',
|
||||
'memories.testFirst': '請先測試連線',
|
||||
@@ -1986,6 +1987,7 @@ const zhTw: Record<string, string> = {
|
||||
'journey.verdict.couldBeBetter': '有待改進',
|
||||
'journey.synced.places': '個地點',
|
||||
'journey.synced.synced': '已同步',
|
||||
'journey.editor.discardChangesConfirm': '您有未儲存的變更。要放棄嗎?',
|
||||
'journey.editor.uploadPhotos': '上傳照片',
|
||||
'journey.editor.uploading': '上傳中...',
|
||||
'journey.editor.fromGallery': '從相簿',
|
||||
|
||||
@@ -6,6 +6,30 @@ html { height: 100%; overflow: hidden; background-color: var(--bg-primary); }
|
||||
body { height: 100%; overflow: auto; overscroll-behavior: none; -webkit-overflow-scrolling: touch; }
|
||||
|
||||
|
||||
/* Leaflet Popups — Enter-Animation vom Anchor-Tip */
|
||||
.leaflet-popup {
|
||||
animation: trek-popover-enter 220ms cubic-bezier(0.23, 1, 0.32, 1);
|
||||
transform-origin: bottom center;
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
.leaflet-popup-content-wrapper {
|
||||
border-radius: 14px !important;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.18) !important;
|
||||
background: var(--bg-card) !important;
|
||||
color: var(--text-primary) !important;
|
||||
border: 1px solid var(--border-faint);
|
||||
}
|
||||
.leaflet-popup-tip {
|
||||
background: var(--bg-card) !important;
|
||||
}
|
||||
.leaflet-popup-close-button {
|
||||
transition: color 150ms cubic-bezier(0.23, 1, 0.32, 1), transform 150ms cubic-bezier(0.23, 1, 0.32, 1) !important;
|
||||
}
|
||||
.leaflet-popup-close-button:hover {
|
||||
transform: scale(1.15);
|
||||
color: var(--text-primary) !important;
|
||||
}
|
||||
|
||||
.atlas-tooltip {
|
||||
background: rgba(10, 10, 20, 0.6) !important;
|
||||
backdrop-filter: blur(20px) saturate(180%) !important;
|
||||
@@ -137,8 +161,268 @@ html.dark .bg-slate-50\/60, html.dark [class*="bg-slate-50/"] { background-color
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* ── Press-Feedback + bessere Easings (Emil Kowalski) ─────────── */
|
||||
/* Buttons sollen antworten wenn sie gedrückt werden. */
|
||||
button:not(:disabled):not([data-no-press]),
|
||||
[role="button"]:not([aria-disabled="true"]):not([data-no-press]) {
|
||||
transition-property: transform, color, background-color, border-color, box-shadow, opacity, filter !important;
|
||||
transition-duration: 180ms;
|
||||
transition-timing-function: cubic-bezier(0.23, 1, 0.32, 1);
|
||||
}
|
||||
button:not(:disabled):not([data-no-press]):active,
|
||||
[role="button"]:not([aria-disabled="true"]):not([data-no-press]):active {
|
||||
transform: scale(0.97);
|
||||
transition-duration: 80ms;
|
||||
}
|
||||
|
||||
/* Tailwind-Default-Easing durch ease-out-quint ersetzen.
|
||||
Eingebaute CSS-Easings sind kraftlos; ease-out-quint hat Punch. */
|
||||
.transition,
|
||||
.transition-all,
|
||||
.transition-colors,
|
||||
.transition-opacity,
|
||||
.transition-transform,
|
||||
.transition-shadow {
|
||||
transition-timing-function: cubic-bezier(0.23, 1, 0.32, 1);
|
||||
}
|
||||
|
||||
/* Input-Focus transitions — border + ring faden weich ein */
|
||||
input, textarea, select {
|
||||
transition: border-color 150ms cubic-bezier(0.23, 1, 0.32, 1),
|
||||
box-shadow 150ms cubic-bezier(0.23, 1, 0.32, 1),
|
||||
background-color 150ms cubic-bezier(0.23, 1, 0.32, 1);
|
||||
}
|
||||
|
||||
/* Back-Button Icon-Slide on hover */
|
||||
.trek-back-btn .trek-back-icon {
|
||||
transition: transform 200ms cubic-bezier(0.23, 1, 0.32, 1);
|
||||
}
|
||||
.trek-back-btn:hover .trek-back-icon {
|
||||
transform: translateX(-2px);
|
||||
}
|
||||
|
||||
/* Global focus-visible ring — konsistent überall */
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
button:focus-visible, [role="button"]:focus-visible, a:focus-visible {
|
||||
outline-offset: 3px;
|
||||
}
|
||||
input:focus-visible, textarea:focus-visible, select:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Theme crossfade — beim Dark/Light switch, Hauptflächen + Text faden ihre Farben.
|
||||
Sparingly: nur background-color und color bekommen eine Transition. */
|
||||
html.trek-theme-transitioning,
|
||||
html.trek-theme-transitioning body,
|
||||
html.trek-theme-transitioning *:not(img):not(video):not(canvas):not([class*="trek-skeleton"]):not(.leaflet-layer) {
|
||||
transition:
|
||||
background-color 320ms cubic-bezier(0.23, 1, 0.32, 1),
|
||||
color 320ms cubic-bezier(0.23, 1, 0.32, 1),
|
||||
border-color 320ms cubic-bezier(0.23, 1, 0.32, 1),
|
||||
fill 320ms cubic-bezier(0.23, 1, 0.32, 1) !important;
|
||||
}
|
||||
|
||||
/* Touch-Geräte: iOS-Tap-Highlight weg (wir haben eigenes Press-Feedback) */
|
||||
@media (hover: none) {
|
||||
button, [role="button"], a {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
}
|
||||
html, body {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
/* Tabular-nums global für Time/Date/Currency/Counter */
|
||||
time, .tabular-nums, [data-tabular],
|
||||
input[type="number"], input[type="time"], input[type="date"], input[type="datetime-local"] {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
/* Wenn Element explizit ease-in-out nutzt (z.B. Accordions), nicht überschreiben.
|
||||
Tailwind setzt ease-in-out via eigener Klasse — die gewinnt durch letzte Deklaration. */
|
||||
|
||||
/* Press-Scale für clickbare Divs (Cards, Tiles) — sanfter als Buttons */
|
||||
[data-press]:active {
|
||||
transform: scale(0.985);
|
||||
transition-duration: 80ms;
|
||||
}
|
||||
|
||||
/* ── Popover/Dropdown Enter-Animationen ─────────────────────────
|
||||
Emil: Popovers sollen von ihrem Trigger aus scalen, nicht vom Center.
|
||||
Start bei scale(0.95) — nichts in der echten Welt poppt aus dem Nichts. */
|
||||
@keyframes trek-menu-enter {
|
||||
from { opacity: 0; transform: scale(0.95) translateY(-4px); }
|
||||
to { opacity: 1; transform: scale(1) translateY(0); }
|
||||
}
|
||||
@keyframes trek-popover-enter {
|
||||
from { opacity: 0; transform: scale(0.96); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
@keyframes trek-modal-enter {
|
||||
from { opacity: 0; transform: scale(0.97); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
@keyframes trek-backdrop-enter {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
@keyframes trek-toast-enter {
|
||||
from { opacity: 0; transform: translateY(8px) scale(0.96); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
@keyframes trek-progress-fill {
|
||||
from { width: 0%; }
|
||||
to { width: var(--trek-progress-to, 0%); }
|
||||
}
|
||||
|
||||
/* Pie-Chart Reveal — rotate + fade-in, gibt dem Kreisdiagramm ein "Draw"-Gefühl */
|
||||
@keyframes trek-pie-reveal {
|
||||
from { opacity: 0; transform: rotate(-90deg) scale(0.85); }
|
||||
to { opacity: 1; transform: rotate(0deg) scale(1); }
|
||||
}
|
||||
.trek-pie-reveal {
|
||||
animation: trek-pie-reveal 900ms cubic-bezier(0.23, 1, 0.32, 1) both;
|
||||
transform-origin: center;
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
|
||||
/* Bar-Chart Reveal — horizontaler Fill von links */
|
||||
@keyframes trek-bar-fill {
|
||||
from { transform: scaleX(0); }
|
||||
to { transform: scaleX(1); }
|
||||
}
|
||||
.trek-bar-fill {
|
||||
animation: trek-bar-fill 700ms cubic-bezier(0.23, 1, 0.32, 1) both;
|
||||
transform-origin: left center;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
/* Page-Transition — subtiler Fade-Up beim Mount */
|
||||
@keyframes trek-page-enter {
|
||||
from { opacity: 0; transform: translateY(6px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
.trek-page-enter {
|
||||
animation: trek-page-enter 220ms cubic-bezier(0.23, 1, 0.32, 1) both;
|
||||
}
|
||||
|
||||
/* Skeleton shimmer — ein fließender Gradient-Strip überquert den Platzhalter */
|
||||
@keyframes trek-shimmer {
|
||||
from { background-position: -200% 0; }
|
||||
to { background-position: 200% 0; }
|
||||
}
|
||||
.trek-skeleton {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--bg-tertiary) 0%,
|
||||
var(--bg-hover) 50%,
|
||||
var(--bg-tertiary) 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: trek-shimmer 1.6s linear infinite;
|
||||
border-radius: 8px;
|
||||
color: transparent;
|
||||
user-select: none;
|
||||
}
|
||||
.dark .trek-skeleton {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(255,255,255,0.04) 0%,
|
||||
rgba(255,255,255,0.08) 50%,
|
||||
rgba(255,255,255,0.04) 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
}
|
||||
.trek-menu-enter {
|
||||
animation: trek-menu-enter 200ms cubic-bezier(0.23, 1, 0.32, 1);
|
||||
transform-origin: top right;
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
.trek-menu-enter-left {
|
||||
animation: trek-menu-enter 200ms cubic-bezier(0.23, 1, 0.32, 1);
|
||||
transform-origin: top left;
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
.trek-popover-enter {
|
||||
animation: trek-popover-enter 180ms cubic-bezier(0.23, 1, 0.32, 1);
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
.trek-modal-enter {
|
||||
animation: trek-modal-enter 220ms cubic-bezier(0.23, 1, 0.32, 1);
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
|
||||
/* Mobile-Drawer-Feel — Modal slidet von unten rein, wird unten am Screen angedockt */
|
||||
@keyframes trek-drawer-enter {
|
||||
from { opacity: 0; transform: translateY(100%); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
@media (max-width: 639px) {
|
||||
.trek-modal-enter {
|
||||
animation: trek-drawer-enter 320ms cubic-bezier(0.32, 0.72, 0, 1);
|
||||
border-bottom-left-radius: 0 !important;
|
||||
border-bottom-right-radius: 0 !important;
|
||||
margin-top: auto !important;
|
||||
align-self: flex-end;
|
||||
}
|
||||
}
|
||||
.trek-backdrop-enter {
|
||||
animation: trek-backdrop-enter 180ms cubic-bezier(0.23, 1, 0.32, 1);
|
||||
}
|
||||
.trek-toast-enter {
|
||||
animation: trek-toast-enter 260ms cubic-bezier(0.23, 1, 0.32, 1);
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
|
||||
/* Stagger-Helpers für Listen — Enter-Animation mit Offset */
|
||||
@keyframes trek-fade-up {
|
||||
from { opacity: 0; transform: translateY(6px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
.trek-stagger > * {
|
||||
animation: trek-fade-up 280ms cubic-bezier(0.23, 1, 0.32, 1) both;
|
||||
}
|
||||
.trek-stagger > *:nth-child(1) { animation-delay: 0ms; }
|
||||
.trek-stagger > *:nth-child(2) { animation-delay: 40ms; }
|
||||
.trek-stagger > *:nth-child(3) { animation-delay: 80ms; }
|
||||
.trek-stagger > *:nth-child(4) { animation-delay: 120ms; }
|
||||
.trek-stagger > *:nth-child(5) { animation-delay: 160ms; }
|
||||
.trek-stagger > *:nth-child(6) { animation-delay: 200ms; }
|
||||
.trek-stagger > *:nth-child(7) { animation-delay: 240ms; }
|
||||
.trek-stagger > *:nth-child(8) { animation-delay: 280ms; }
|
||||
.trek-stagger > *:nth-child(n+9) { animation-delay: 320ms; }
|
||||
|
||||
/* Reduced motion — Emil's Accessibility-Regel: fewer and gentler, not zero */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.trek-menu-enter, .trek-menu-enter-left, .trek-popover-enter,
|
||||
.trek-modal-enter, .trek-toast-enter, .trek-stagger > * {
|
||||
animation: trek-backdrop-enter 120ms ease-out;
|
||||
}
|
||||
.trek-skeleton {
|
||||
animation: none;
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
button:not(:disabled):not([data-no-press]):active,
|
||||
[role="button"]:not([aria-disabled="true"]):not([data-no-press]):active,
|
||||
[data-press]:active {
|
||||
transform: none;
|
||||
}
|
||||
/* Parallax & lift disablen */
|
||||
.group:hover img,
|
||||
.group:hover .cover-img { transform: none !important; }
|
||||
*:hover { translate: none !important; }
|
||||
}
|
||||
|
||||
/* ── Design tokens ─────────────────────────────── */
|
||||
:root {
|
||||
/* Easing curves — stärker als die CSS-Defaults, siehe easing.dev */
|
||||
--ease-out-quint: cubic-bezier(0.23, 1, 0.32, 1);
|
||||
--ease-in-out-quint: cubic-bezier(0.77, 0, 0.175, 1);
|
||||
--ease-drawer: cubic-bezier(0.32, 0.72, 0, 1);
|
||||
|
||||
--safe-top: env(safe-area-inset-top, 0px);
|
||||
--nav-h: 0px;
|
||||
--bottom-nav-h: 0px;
|
||||
|
||||
@@ -11,6 +11,7 @@ import { getApiErrorMessage } from '../types'
|
||||
import Navbar from '../components/Layout/Navbar'
|
||||
import Modal from '../components/shared/Modal'
|
||||
import { useToast } from '../components/shared/Toast'
|
||||
import { useCountUp } from '../hooks/useCountUp'
|
||||
import CategoryManager from '../components/Admin/CategoryManager'
|
||||
import BackupPanel from '../components/Admin/BackupPanel'
|
||||
import GitHubPanel from '../components/Admin/GitHubPanel'
|
||||
@@ -161,6 +162,21 @@ function AdminNotificationsPanel({ t, toast }: { t: (k: string) => string; toast
|
||||
)
|
||||
}
|
||||
|
||||
function AdminStatCard({ label, value, icon: Icon }: { label: string; value: number; icon: React.ComponentType<{ className?: string; style?: React.CSSProperties }> }): React.ReactElement {
|
||||
const animated = useCountUp(value, 900)
|
||||
return (
|
||||
<div className="rounded-xl border p-4" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
|
||||
<div className="flex items-center gap-4">
|
||||
<Icon className="w-5 h-5" style={{ color: 'var(--text-primary)' }} />
|
||||
<div>
|
||||
<p className="text-xl font-bold tabular-nums" style={{ color: 'var(--text-primary)' }}>{animated}</p>
|
||||
<p className="text-xs" style={{ color: 'var(--text-muted)' }}>{label}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AdminPage(): React.ReactElement {
|
||||
const { demoMode, serverTimezone } = useAuthStore()
|
||||
const { t, locale } = useTranslation()
|
||||
@@ -565,15 +581,7 @@ export default function AdminPage(): React.ReactElement {
|
||||
{ label: t('admin.stats.places'), value: stats.totalPlaces, icon: Map },
|
||||
{ label: t('admin.stats.files'), value: stats.totalFiles || 0, icon: FileText },
|
||||
].map(({ label, value, icon: Icon }) => (
|
||||
<div key={label} className="rounded-xl border p-4" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
|
||||
<div className="flex items-center gap-4">
|
||||
<Icon className="w-5 h-5" style={{ color: 'var(--text-primary)' }} />
|
||||
<div>
|
||||
<p className="text-xl font-bold" style={{ color: 'var(--text-primary)' }}>{value}</p>
|
||||
<p className="text-xs" style={{ color: 'var(--text-muted)' }}>{label}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<AdminStatCard key={label} label={label} value={value} icon={Icon} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -629,7 +637,7 @@ export default function AdminPage(): React.ReactElement {
|
||||
<th className="px-5 py-3 text-right">{t('admin.table.actions')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
<tbody className="divide-y divide-slate-100 trek-stagger">
|
||||
{users.map(u => (
|
||||
<tr key={u.id} className={`hover:bg-slate-50 transition-colors ${u.id === currentUser?.id ? 'bg-slate-50/60' : ''}`}>
|
||||
<td className="px-5 py-3">
|
||||
@@ -903,7 +911,7 @@ export default function AdminPage(): React.ReactElement {
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleToggleAuthSetting('oidc_registration', !oidcRegistration, setOidcRegistration)}
|
||||
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
||||
className="relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors"
|
||||
style={{ background: oidcRegistration ? 'var(--text-primary)' : 'var(--border-primary)' }}
|
||||
>
|
||||
<span
|
||||
@@ -930,7 +938,7 @@ export default function AdminPage(): React.ReactElement {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleToggleRequireMfa(!requireMfa)}
|
||||
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
||||
className="relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors"
|
||||
style={{ background: requireMfa ? 'var(--text-primary)' : 'var(--border-primary)' }}
|
||||
>
|
||||
<span
|
||||
@@ -1036,7 +1044,7 @@ export default function AdminPage(): React.ReactElement {
|
||||
</div>
|
||||
|
||||
{/* Place Photos Toggle */}
|
||||
<div className="flex items-center justify-between py-3 border-t border-slate-100">
|
||||
<div className="flex items-center justify-between gap-4 py-3 border-t border-slate-100">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-700">{t('admin.placesPhotos.title')}</p>
|
||||
<p className="text-xs text-slate-400 mt-0.5">{t('admin.placesPhotos.subtitle')}</p>
|
||||
@@ -1048,7 +1056,7 @@ export default function AdminPage(): React.ReactElement {
|
||||
setPlacesPhotosEnabled(next)
|
||||
try { await adminApi.updatePlacesPhotos(next) } catch { setPlacesPhotosEnabledState(!next); setPlacesPhotosEnabled(!next) }
|
||||
}}
|
||||
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
||||
className="relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors"
|
||||
style={{ background: placesPhotosEnabled ? 'var(--text-primary)' : 'var(--border-primary)' }}
|
||||
>
|
||||
<span className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200" style={{ transform: placesPhotosEnabled ? 'translateX(20px)' : 'translateX(0)' }} />
|
||||
@@ -1056,7 +1064,7 @@ export default function AdminPage(): React.ReactElement {
|
||||
</div>
|
||||
|
||||
{/* Place Autocomplete Toggle */}
|
||||
<div className="flex items-center justify-between py-3 border-t border-slate-100">
|
||||
<div className="flex items-center justify-between gap-4 py-3 border-t border-slate-100">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-700">{t('admin.placesAutocomplete.title')}</p>
|
||||
<p className="text-xs text-slate-400 mt-0.5">{t('admin.placesAutocomplete.subtitle')}</p>
|
||||
@@ -1068,7 +1076,7 @@ export default function AdminPage(): React.ReactElement {
|
||||
setPlacesAutocompleteEnabled(next)
|
||||
try { await adminApi.updatePlacesAutocomplete(next) } catch { setPlacesAutocompleteEnabledState(!next); setPlacesAutocompleteEnabled(!next) }
|
||||
}}
|
||||
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
||||
className="relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors"
|
||||
style={{ background: placesAutocompleteEnabled ? 'var(--text-primary)' : 'var(--border-primary)' }}
|
||||
>
|
||||
<span className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200" style={{ transform: placesAutocompleteEnabled ? 'translateX(20px)' : 'translateX(0)' }} />
|
||||
@@ -1076,7 +1084,7 @@ export default function AdminPage(): React.ReactElement {
|
||||
</div>
|
||||
|
||||
{/* Place Details Toggle */}
|
||||
<div className="flex items-center justify-between py-3 border-t border-slate-100">
|
||||
<div className="flex items-center justify-between gap-4 py-3 border-t border-slate-100">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-700">{t('admin.placesDetails.title')}</p>
|
||||
<p className="text-xs text-slate-400 mt-0.5">{t('admin.placesDetails.subtitle')}</p>
|
||||
@@ -1088,7 +1096,7 @@ export default function AdminPage(): React.ReactElement {
|
||||
setPlacesDetailsEnabled(next)
|
||||
try { await adminApi.updatePlacesDetails(next) } catch { setPlacesDetailsEnabledState(!next); setPlacesDetailsEnabled(!next) }
|
||||
}}
|
||||
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
||||
className="relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors"
|
||||
style={{ background: placesDetailsEnabled ? 'var(--text-primary)' : 'var(--border-primary)' }}
|
||||
>
|
||||
<span className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200" style={{ transform: placesDetailsEnabled ? 'translateX(20px)' : 'translateX(0)' }} />
|
||||
@@ -1328,7 +1336,7 @@ export default function AdminPage(): React.ReactElement {
|
||||
const newVal = smtpValues.smtp_skip_tls_verify === 'true' ? 'false' : 'true'
|
||||
setSmtpValues(prev => ({ ...prev, smtp_skip_tls_verify: newVal }))
|
||||
}}
|
||||
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
||||
className="relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors"
|
||||
style={{ background: smtpValues.smtp_skip_tls_verify === 'true' ? 'var(--text-primary)' : 'var(--border-primary)' }}>
|
||||
<span className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
|
||||
style={{ transform: smtpValues.smtp_skip_tls_verify === 'true' ? 'translateX(20px)' : 'translateX(0)' }} />
|
||||
|
||||
@@ -938,7 +938,7 @@ export default function AtlasPage(): React.ReactElement {
|
||||
ref={panelRef}
|
||||
onMouseMove={handlePanelMouseMove}
|
||||
onMouseLeave={handlePanelMouseLeave}
|
||||
className="hidden md:flex flex-col absolute z-10 overflow-hidden transition-all duration-300"
|
||||
className="hidden md:flex flex-col absolute z-10 overflow-hidden transition-[width,height,transform,box-shadow] duration-300 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||
style={{
|
||||
bottom: 16,
|
||||
left: '50%',
|
||||
|
||||
@@ -13,6 +13,7 @@ import TimezoneWidget from '../components/Dashboard/TimezoneWidget'
|
||||
import TripFormModal from '../components/Trips/TripFormModal'
|
||||
import ConfirmDialog from '../components/shared/ConfirmDialog'
|
||||
import { useToast } from '../components/shared/Toast'
|
||||
import { useCountUp } from '../hooks/useCountUp'
|
||||
import {
|
||||
Plus, Calendar, Trash2, Edit2, Map, ChevronDown, ChevronUp,
|
||||
Archive, ArchiveRestore, Clock, MapPin, Settings, X, ArrowRightLeft, Users,
|
||||
@@ -152,6 +153,28 @@ interface TripCardProps {
|
||||
dark?: boolean
|
||||
}
|
||||
|
||||
function SpotlightStats({ trip, totalDays, t }: { trip: DashboardTrip; totalDays: number; t: TripCardProps['t'] }): React.ReactElement {
|
||||
const days = useCountUp(trip.day_count || totalDays)
|
||||
const places = useCountUp(trip.place_count || 0)
|
||||
const buddies = useCountUp(trip.shared_count || 0)
|
||||
return (
|
||||
<div className="grid grid-cols-3 gap-2.5 p-3.5 bg-black/25 backdrop-blur-sm border border-white/10 rounded-2xl">
|
||||
<div className="text-center">
|
||||
<p className="text-[22px] font-extrabold tracking-[-0.02em] leading-none tabular-nums">{days}</p>
|
||||
<p className="text-[9px] uppercase tracking-[0.1em] opacity-70 font-semibold mt-1">{t('dashboard.mobile.days')}</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-[22px] font-extrabold tracking-[-0.02em] leading-none tabular-nums">{places}</p>
|
||||
<p className="text-[9px] uppercase tracking-[0.1em] opacity-70 font-semibold mt-1">{t('dashboard.mobile.places')}</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-[22px] font-extrabold tracking-[-0.02em] leading-none tabular-nums">{buddies}</p>
|
||||
<p className="text-[9px] uppercase tracking-[0.1em] opacity-70 font-semibold mt-1">{t('dashboard.mobile.buddies')}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SpotlightCard({ trip, onEdit, onCopy, onDelete, onArchive, onClick, t, locale }: TripCardProps): React.ReactElement {
|
||||
const status = getTripStatus(trip)
|
||||
const isLive = status === 'ongoing'
|
||||
@@ -173,16 +196,16 @@ function SpotlightCard({ trip, onEdit, onCopy, onDelete, onArchive, onClick, t,
|
||||
return (
|
||||
<div
|
||||
onClick={() => onClick(trip)}
|
||||
className="group relative rounded-3xl overflow-hidden cursor-pointer mb-8"
|
||||
style={{ minHeight: 340, boxShadow: '0 8px 40px rgba(0,0,0,0.13)' }}
|
||||
className="group relative rounded-3xl overflow-hidden cursor-pointer mb-8 transition-[transform,box-shadow] duration-300 ease-[cubic-bezier(0.23,1,0.32,1)] hover:-translate-y-1 hover:shadow-[0_16px_60px_rgba(0,0,0,0.22)] active:scale-[0.995]"
|
||||
style={{ minHeight: 340, boxShadow: '0 8px 40px rgba(0,0,0,0.13)', isolation: 'isolate' }}
|
||||
>
|
||||
{/* Background */}
|
||||
<div className="absolute inset-0" style={{
|
||||
<div className="absolute inset-0 overflow-hidden rounded-3xl" style={{
|
||||
background: trip.cover_image ? undefined : tripGradient(trip.id),
|
||||
}}>
|
||||
{trip.cover_image && (
|
||||
<>
|
||||
<img src={trip.cover_image} className="w-full h-full object-cover" alt="" />
|
||||
<img src={trip.cover_image} className="w-full h-full object-cover transition-transform duration-[1200ms] ease-[cubic-bezier(0.23,1,0.32,1)] group-hover:scale-[1.06]" alt="" />
|
||||
<div className="absolute inset-0" style={{ background: 'linear-gradient(180deg, rgba(0,0,0,0.2) 0%, rgba(0,0,0,0.6) 100%)' }} />
|
||||
</>
|
||||
)}
|
||||
@@ -233,7 +256,14 @@ function SpotlightCard({ trip, onEdit, onCopy, onDelete, onArchive, onClick, t,
|
||||
<span className="opacity-70">{t('dashboard.mobile.daysLeft', { count: daysLeft })}</span>
|
||||
</div>
|
||||
<div className="h-1.5 bg-white/15 rounded-full overflow-hidden">
|
||||
<div className="h-full bg-white rounded-full relative" style={{ width: `${progress}%` }}>
|
||||
<div
|
||||
className="h-full bg-white rounded-full relative"
|
||||
style={{
|
||||
width: `${progress}%`,
|
||||
animation: 'trek-progress-fill 900ms cubic-bezier(0.23,1,0.32,1) both',
|
||||
['--trek-progress-to' as string]: `${progress}%`,
|
||||
}}
|
||||
>
|
||||
<span className="absolute right-0 top-1/2 -translate-y-1/2 w-3 h-3 bg-white rounded-full shadow-[0_0_12px_rgba(255,255,255,0.9)]" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -241,20 +271,7 @@ function SpotlightCard({ trip, onEdit, onCopy, onDelete, onArchive, onClick, t,
|
||||
)}
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-3 gap-2.5 p-3.5 bg-black/25 backdrop-blur-sm border border-white/10 rounded-2xl">
|
||||
<div className="text-center">
|
||||
<p className="text-[22px] font-extrabold tracking-[-0.02em] leading-none">{trip.day_count || totalDays}</p>
|
||||
<p className="text-[9px] uppercase tracking-[0.1em] opacity-70 font-semibold mt-1">{t('dashboard.mobile.days')}</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-[22px] font-extrabold tracking-[-0.02em] leading-none">{trip.place_count || 0}</p>
|
||||
<p className="text-[9px] uppercase tracking-[0.1em] opacity-70 font-semibold mt-1">{t('dashboard.mobile.places')}</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-[22px] font-extrabold tracking-[-0.02em] leading-none">{trip.shared_count || 0}</p>
|
||||
<p className="text-[9px] uppercase tracking-[0.1em] opacity-70 font-semibold mt-1">{t('dashboard.mobile.buddies')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<SpotlightStats trip={trip} totalDays={totalDays} t={t} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -278,13 +295,13 @@ function MobileTripCard({ trip, onEdit, onCopy, onDelete, onArchive, onClick, t,
|
||||
return (
|
||||
<div
|
||||
onClick={() => onClick?.(trip)}
|
||||
className="rounded-2xl border border-zinc-200 dark:border-zinc-700 overflow-hidden cursor-pointer transition-all hover:-translate-y-0.5 hover:shadow-md"
|
||||
style={{ background: 'var(--bg-card)' }}
|
||||
className="group rounded-2xl border border-zinc-200 dark:border-zinc-700 overflow-hidden cursor-pointer transition-[transform,box-shadow,border-color] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] hover:-translate-y-0.5 hover:shadow-md"
|
||||
style={{ background: 'var(--bg-card)', isolation: 'isolate' }}
|
||||
>
|
||||
{/* Cover */}
|
||||
<div className="relative h-[120px] overflow-hidden" style={{ background: trip.cover_image ? undefined : tripGradient(trip.id) }}>
|
||||
{trip.cover_image && (
|
||||
<img src={trip.cover_image} className="absolute inset-0 w-full h-full object-cover" alt="" />
|
||||
<img src={trip.cover_image} className="absolute inset-0 w-full h-full object-cover transition-transform duration-[800ms] ease-[cubic-bezier(0.23,1,0.32,1)] group-hover:scale-[1.08]" alt="" />
|
||||
)}
|
||||
<div className="absolute inset-0" style={{ background: 'linear-gradient(180deg, transparent 30%, rgba(0,0,0,0.5) 100%)' }} />
|
||||
|
||||
@@ -370,13 +387,13 @@ function TripCard({ trip, onEdit, onCopy, onDelete, onArchive, onClick, t, local
|
||||
return (
|
||||
<div
|
||||
onClick={() => onClick(trip)}
|
||||
className="group rounded-2xl border border-zinc-200 dark:border-zinc-700 overflow-hidden cursor-pointer transition-all hover:-translate-y-0.5 hover:shadow-lg hover:border-zinc-300 dark:hover:border-zinc-600"
|
||||
style={{ background: 'var(--bg-card)' }}
|
||||
className="group rounded-2xl border border-zinc-200 dark:border-zinc-700 overflow-hidden cursor-pointer transition-[transform,box-shadow,border-color] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] hover:-translate-y-0.5 hover:shadow-lg hover:border-zinc-300 dark:hover:border-zinc-600"
|
||||
style={{ background: 'var(--bg-card)', isolation: 'isolate' }}
|
||||
>
|
||||
{/* Cover */}
|
||||
<div className="relative h-[140px] overflow-hidden" style={{ background: trip.cover_image ? undefined : tripGradient(trip.id) }}>
|
||||
{trip.cover_image && (
|
||||
<img src={trip.cover_image} className="absolute inset-0 w-full h-full object-cover" alt="" />
|
||||
<img src={trip.cover_image} className="absolute inset-0 w-full h-full object-cover transition-transform duration-[800ms] ease-[cubic-bezier(0.23,1,0.32,1)] group-hover:scale-[1.08]" alt="" />
|
||||
)}
|
||||
<div className="absolute inset-0" style={{ background: 'linear-gradient(180deg, transparent 30%, rgba(0,0,0,0.55) 100%)' }} />
|
||||
|
||||
@@ -658,11 +675,14 @@ function IconBtn({ onClick, title, danger, loading, children }: { onClick: () =>
|
||||
// ── Skeleton ─────────────────────────────────────────────────────────────────
|
||||
function SkeletonCard(): React.ReactElement {
|
||||
return (
|
||||
<div style={{ background: 'white', borderRadius: 16, overflow: 'hidden', border: '1px solid #f3f4f6' }}>
|
||||
<div style={{ height: 120, background: '#f3f4f6', animation: 'pulse 1.5s ease-in-out infinite' }} />
|
||||
<div style={{ padding: '12px 14px 14px' }}>
|
||||
<div style={{ height: 14, background: '#f3f4f6', borderRadius: 6, marginBottom: 8, width: '70%' }} />
|
||||
<div style={{ height: 11, background: '#f3f4f6', borderRadius: 6, width: '50%' }} />
|
||||
<div
|
||||
className="rounded-2xl border border-zinc-200 dark:border-zinc-700 overflow-hidden"
|
||||
style={{ background: 'var(--bg-card)' }}
|
||||
>
|
||||
<div className="trek-skeleton" style={{ height: 120, borderRadius: 0 }} />
|
||||
<div style={{ padding: '12px 14px 14px', display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
<div className="trek-skeleton" style={{ height: 14, width: '70%' }} />
|
||||
<div className="trek-skeleton" style={{ height: 11, width: '50%' }} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -958,10 +978,8 @@ export default function DashboardPage(): React.ReactElement {
|
||||
padding: '9px 14px', borderRadius: 10, fontSize: 13, fontWeight: 500,
|
||||
background: 'var(--accent)', color: 'var(--accent-text)', flexShrink: 0,
|
||||
marginLeft: 2,
|
||||
transition: 'opacity 0.15s ease',
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.opacity = '0.88'}
|
||||
onMouseLeave={e => e.currentTarget.style.opacity = '1'}
|
||||
className="hover:opacity-[0.88]"
|
||||
>
|
||||
<Plus size={14} strokeWidth={2.5} /> {t('dashboard.newTrip')}
|
||||
</button>
|
||||
@@ -1004,7 +1022,7 @@ export default function DashboardPage(): React.ReactElement {
|
||||
{/* Loading skeletons */}
|
||||
{isLoading && (
|
||||
<>
|
||||
<div style={{ height: 260, background: '#e5e7eb', borderRadius: 20, marginBottom: 32, animation: 'pulse 1.5s ease-in-out infinite' }} />
|
||||
<div className="trek-skeleton" style={{ height: 260, borderRadius: 24, marginBottom: 32 }} />
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: 16 }}>
|
||||
{[1, 2, 3].map(i => <SkeletonCard key={i} />)}
|
||||
</div>
|
||||
@@ -1070,7 +1088,7 @@ export default function DashboardPage(): React.ReactElement {
|
||||
{/* Trips — desktop grid or list */}
|
||||
{!isLoading && (viewMode === 'grid' ? rest : trips).length > 0 && (
|
||||
viewMode === 'grid' ? (
|
||||
<div className="trip-grid hidden md:grid" style={{ gap: 16, marginBottom: 40 }}>
|
||||
<div className="trip-grid hidden md:grid trek-stagger" style={{ gap: 16, marginBottom: 40 }}>
|
||||
{rest.map(trip => (
|
||||
<TripCard
|
||||
key={trip.id}
|
||||
@@ -1085,7 +1103,7 @@ export default function DashboardPage(): React.ReactElement {
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="hidden md:flex" style={{ flexDirection: 'column', gap: 8, marginBottom: 40 }}>
|
||||
<div className="hidden md:flex trek-stagger" style={{ flexDirection: 'column', gap: 8, marginBottom: 40 }}>
|
||||
{trips.map(trip => (
|
||||
<TripListItem
|
||||
key={trip.id}
|
||||
|
||||
@@ -3579,8 +3579,8 @@ describe('JourneyDetailPage', () => {
|
||||
});
|
||||
|
||||
// ── FE-PAGE-JOURNEYDETAIL-148 ──────────────────────────────────────────
|
||||
describe('FE-PAGE-JOURNEYDETAIL-148: EntryEditor file upload for existing entry calls API directly', () => {
|
||||
it('uploading a file on an existing entry calls the upload API immediately', async () => {
|
||||
describe('FE-PAGE-JOURNEYDETAIL-148: EntryEditor queues file uploads until save (#727)', () => {
|
||||
it('uploading a file on an existing entry stays pending until Save is clicked', async () => {
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
||||
let uploadCalled = false;
|
||||
|
||||
@@ -3618,7 +3618,11 @@ describe('JourneyDetailPage', () => {
|
||||
const testFile = new File(['data'], 'upload.jpg', { type: 'image/jpeg' });
|
||||
await user.upload(fileInput, testFile);
|
||||
|
||||
// For existing entries, upload happens immediately
|
||||
// Picked file is queued locally — upload should NOT fire until Save.
|
||||
expect(uploadCalled).toBe(false);
|
||||
|
||||
// Saving triggers the queued upload.
|
||||
await user.click(screen.getByText('Save'));
|
||||
await waitFor(() => {
|
||||
expect(uploadCalled).toBe(true);
|
||||
});
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
UserPlus, Plus, Minus, Calendar, Camera, BookOpen, X, Check, ImagePlus, Trash2, Pencil,
|
||||
Laugh, Smile, Meh, Annoyed, Frown,
|
||||
Sun, CloudSun, Cloud, CloudRain, CloudLightning, Snowflake, ChevronDown, Eye, EyeOff,
|
||||
Archive, ArchiveRestore,
|
||||
} from 'lucide-react'
|
||||
import MobileMapTimeline from '../components/Journey/MobileMapTimeline'
|
||||
import MobileEntryView from '../components/Journey/MobileEntryView'
|
||||
@@ -89,6 +90,12 @@ export default function JourneyDetailPage() {
|
||||
const [activeLocationId, setActiveLocationId] = useState<string | null>(null)
|
||||
|
||||
const isMobile = useIsMobile()
|
||||
// Role-based permissions (server-provided via my_role). Fall back to
|
||||
// "owner" when the field isn't present yet (legacy responses) so behavior
|
||||
// matches the pre-permissions era.
|
||||
const myRole = (current as any)?.my_role ?? 'owner'
|
||||
const canEditEntries = myRole === 'owner' || myRole === 'editor'
|
||||
const canEditJourney = myRole === 'owner'
|
||||
const [view, setView] = useState<'timeline' | 'gallery' | 'map'>('timeline')
|
||||
const [viewingEntry, setViewingEntry] = useState<JourneyEntry | null>(null)
|
||||
const [editingEntry, setEditingEntry] = useState<JourneyEntry | null>(null)
|
||||
@@ -234,11 +241,12 @@ export default function JourneyDetailPage() {
|
||||
entries={timelineEntries}
|
||||
mapEntries={sidebarMapItems}
|
||||
dark={document.documentElement.classList.contains('dark')}
|
||||
readOnly={!canEditEntries}
|
||||
onEntryClick={(entry) => setViewingEntry(entry)}
|
||||
onAddEntry={() => {
|
||||
onAddEntry={canEditEntries ? () => {
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
setEditingEntry({ id: 0, journey_id: current.id, author_id: 0, type: 'entry', entry_date: today, visibility: 'private', sort_order: 0, photos: [], created_at: 0, updated_at: 0 } as JourneyEntry)
|
||||
}}
|
||||
} : undefined}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -246,6 +254,7 @@ export default function JourneyDetailPage() {
|
||||
{viewingEntry && (
|
||||
<MobileEntryView
|
||||
entry={viewingEntry}
|
||||
readOnly={!canEditEntries}
|
||||
onClose={() => setViewingEntry(null)}
|
||||
onEdit={() => { setViewingEntry(null); setEditingEntry(viewingEntry); }}
|
||||
onDelete={() => { setViewingEntry(null); setDeleteTarget(viewingEntry); }}
|
||||
@@ -253,25 +262,55 @@ export default function JourneyDetailPage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Floating tab toggle on mobile combined view */}
|
||||
{/* Floating top bar on mobile combined view: back | tabs+title | settings */}
|
||||
{showMobileCombined && (
|
||||
<div className="fixed top-[calc(var(--nav-h,56px)+12px)] left-4 z-30">
|
||||
<div className="flex bg-white/90 dark:bg-zinc-800/90 backdrop-blur-lg border border-zinc-200 dark:border-zinc-700 rounded-lg overflow-hidden shadow-lg">
|
||||
<button
|
||||
onClick={() => setView('timeline')}
|
||||
className="flex items-center gap-1.5 px-3 py-[7px] text-[12px] font-medium bg-zinc-900 dark:bg-white text-white dark:text-zinc-900"
|
||||
>
|
||||
<MapPin size={13} />
|
||||
{t('journey.detail.journeyTab') || 'Journey'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setView('gallery')}
|
||||
className="flex items-center gap-1.5 px-3 py-[7px] text-[12px] font-medium text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300"
|
||||
>
|
||||
<Grid size={13} />
|
||||
{t('journey.share.gallery')}
|
||||
</button>
|
||||
<div
|
||||
className="fixed left-0 right-0 z-30 flex items-start justify-between gap-2 px-4"
|
||||
style={{ top: 'calc(var(--nav-h, 56px) + 12px)' }}
|
||||
>
|
||||
<button
|
||||
onClick={() => navigate('/journey')}
|
||||
aria-label={t('journey.detail.backToJourney')}
|
||||
className="w-10 h-10 flex-shrink-0 rounded-lg bg-white/90 dark:bg-zinc-800/90 backdrop-blur-lg border border-zinc-200 dark:border-zinc-700 shadow-lg text-zinc-700 dark:text-zinc-200 flex items-center justify-center hover:bg-white dark:hover:bg-zinc-800 active:scale-95 transition-transform"
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
</button>
|
||||
|
||||
<div className="flex-1 min-w-0 flex flex-col items-center gap-1">
|
||||
<div className="flex bg-white/90 dark:bg-zinc-800/90 backdrop-blur-lg border border-zinc-200 dark:border-zinc-700 rounded-lg overflow-hidden shadow-lg">
|
||||
<button
|
||||
onClick={() => setView('timeline')}
|
||||
className="flex items-center gap-1.5 px-3 py-[7px] text-[12px] font-medium bg-zinc-900 dark:bg-white text-white dark:text-zinc-900"
|
||||
>
|
||||
<MapPin size={13} />
|
||||
{t('journey.detail.journeyTab') || 'Journey'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setView('gallery')}
|
||||
className="flex items-center gap-1.5 px-3 py-[7px] text-[12px] font-medium text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300"
|
||||
>
|
||||
<Grid size={13} />
|
||||
{t('journey.share.gallery')}
|
||||
</button>
|
||||
</div>
|
||||
{current?.title && (
|
||||
<div className="max-w-full truncate text-center text-[11px] font-medium text-zinc-700 dark:text-zinc-200 px-2.5 py-0.5 rounded-full bg-white/80 dark:bg-zinc-800/80 backdrop-blur-md border border-zinc-200/60 dark:border-zinc-700/60 shadow-sm">
|
||||
{current.title}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{canEditJourney ? (
|
||||
<button
|
||||
onClick={() => setShowSettings(true)}
|
||||
aria-label={t('journey.settings.title')}
|
||||
className="w-10 h-10 flex-shrink-0 rounded-lg bg-white/90 dark:bg-zinc-800/90 backdrop-blur-lg border border-zinc-200 dark:border-zinc-700 shadow-lg text-zinc-700 dark:text-zinc-200 flex items-center justify-center hover:bg-white dark:hover:bg-zinc-800 active:scale-95 transition-transform"
|
||||
>
|
||||
<MoreHorizontal size={16} />
|
||||
</button>
|
||||
) : (
|
||||
<div className="w-10 h-10 flex-shrink-0" aria-hidden />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -345,7 +384,9 @@ export default function JourneyDetailPage() {
|
||||
{hideSkeletons ? t('journey.skeletons.show') : t('journey.skeletons.hide')}
|
||||
</span>
|
||||
</div>
|
||||
<button onClick={() => setShowSettings(true)} className="w-[34px] h-[34px] rounded-lg bg-white/15 backdrop-blur flex items-center justify-center hover:bg-white/25"><MoreHorizontal size={14} /></button>
|
||||
{canEditJourney && (
|
||||
<button onClick={() => setShowSettings(true)} className="w-[34px] h-[34px] rounded-lg bg-white/15 backdrop-blur flex items-center justify-center hover:bg-white/25"><MoreHorizontal size={14} /></button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -405,7 +446,7 @@ export default function JourneyDetailPage() {
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{(!isMobile ? view === 'timeline' : view !== 'gallery') && (
|
||||
{canEditEntries && (!isMobile ? view === 'timeline' : view !== 'gallery') && (
|
||||
<button
|
||||
onClick={() => {
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
@@ -437,7 +478,7 @@ export default function JourneyDetailPage() {
|
||||
const locations = [...new Set(entries.map(e => e.location_name).filter(Boolean))]
|
||||
|
||||
return (
|
||||
<div key={date} className="flex flex-col gap-3">
|
||||
<div key={date} className="flex flex-col gap-3 trek-stagger">
|
||||
<div className="sticky top-0 md:top-[68px] z-[5] bg-white/95 dark:bg-zinc-900/95 backdrop-blur border-y md:border border-zinc-200 dark:border-zinc-700 rounded-none md:rounded-xl -mx-4 md:mx-0 px-4 py-3.5 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 flex items-center justify-center text-[13px] font-bold">
|
||||
@@ -455,12 +496,13 @@ export default function JourneyDetailPage() {
|
||||
{entries.map(entry => (
|
||||
<div key={entry.id} data-entry-id={String(entry.id)}>
|
||||
{entry.type === 'skeleton' ? (
|
||||
<SkeletonCard entry={entry} onClick={() => setEditingEntry(entry)} />
|
||||
<SkeletonCard entry={entry} onClick={canEditEntries ? () => setEditingEntry(entry) : undefined} />
|
||||
) : entry.type === 'checkin' ? (
|
||||
<CheckinCard entry={entry} onClick={() => setEditingEntry(entry)} />
|
||||
<CheckinCard entry={entry} onClick={canEditEntries ? () => setEditingEntry(entry) : undefined} />
|
||||
) : (
|
||||
<EntryCard
|
||||
entry={entry}
|
||||
readOnly={!canEditEntries}
|
||||
onEdit={() => setEditingEntry(entry)}
|
||||
onDelete={() => setDeleteTarget(entry)}
|
||||
onPhotoClick={(photos, idx) => setLightbox({ photos: photos.map(p => ({ id: p.id, src: photoUrl(p, 'original'), caption: p.caption, provider: p.provider, asset_id: p.asset_id, owner_id: p.owner_id })), index: idx })}
|
||||
@@ -911,12 +953,14 @@ function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefres
|
||||
if (!files?.length) return
|
||||
setGalleryUploading(true)
|
||||
try {
|
||||
// find existing "Gallery" entry or create one
|
||||
// find existing "Gallery" entry or create one. The stored title is the
|
||||
// literal 'Gallery' (server-side checks look for this exact string) —
|
||||
// do not send a translated label here.
|
||||
let galleryEntry = entries.find(e => e.title === 'Gallery' && e.type === 'entry')
|
||||
let entryId = galleryEntry?.id
|
||||
if (!entryId) {
|
||||
const entry = await journeyApi.createEntry(journeyId, {
|
||||
title: t('journey.share.gallery'),
|
||||
title: 'Gallery',
|
||||
entry_date: new Date().toISOString().split('T')[0],
|
||||
type: 'entry',
|
||||
})
|
||||
@@ -1057,7 +1101,7 @@ function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefres
|
||||
if (!targetId) {
|
||||
try {
|
||||
const entry = await journeyApi.createEntry(journeyId, {
|
||||
title: t('journey.share.gallery'),
|
||||
title: 'Gallery',
|
||||
entry_date: new Date().toISOString().split('T')[0],
|
||||
type: 'entry',
|
||||
})
|
||||
@@ -1233,8 +1277,9 @@ function VerdictSection({ pros, cons }: { pros: string[]; cons: string[] }) {
|
||||
|
||||
// ── Entry Card ────────────────────────────────────────────────────────────
|
||||
|
||||
function EntryCard({ entry, onEdit, onDelete, onPhotoClick }: {
|
||||
function EntryCard({ entry, readOnly, onEdit, onDelete, onPhotoClick }: {
|
||||
entry: JourneyEntry
|
||||
readOnly?: boolean
|
||||
onEdit: () => void
|
||||
onDelete: () => void
|
||||
onPhotoClick: (photos: JourneyPhoto[], index: number) => void
|
||||
@@ -1251,7 +1296,7 @@ function EntryCard({ entry, onEdit, onDelete, onPhotoClick }: {
|
||||
const hasProscons = prosArr.length > 0 || consArr.length > 0
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-2xl overflow-hidden transition-all hover:border-zinc-400 dark:hover:border-zinc-500 hover:shadow-sm">
|
||||
<div className="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-2xl overflow-hidden transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] hover:border-zinc-400 dark:hover:border-zinc-500 hover:shadow-sm">
|
||||
|
||||
{/* Hero area: photos with title overlay */}
|
||||
{photos.length > 0 ? (
|
||||
@@ -1277,20 +1322,22 @@ function EntryCard({ entry, onEdit, onDelete, onPhotoClick }: {
|
||||
</div>
|
||||
|
||||
{/* Menu top-right */}
|
||||
<div className="absolute top-2.5 right-3 z-[2]">
|
||||
<button ref={menuBtnRef} onClick={() => setMenuOpen(!menuOpen)} className="w-8 h-8 rounded-[10px] bg-black/40 backdrop-blur-sm flex items-center justify-center text-white hover:bg-black/50">
|
||||
<MoreHorizontal size={14} />
|
||||
</button>
|
||||
{menuOpen && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-[99]" onClick={() => setMenuOpen(false)} />
|
||||
<div className="fixed z-[100] bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-lg shadow-lg py-1 min-w-[120px]" style={{ top: (menuBtnRef.current?.getBoundingClientRect().bottom || 0) + 4, right: window.innerWidth - (menuBtnRef.current?.getBoundingClientRect().right || 0) }}>
|
||||
<button onClick={() => { setMenuOpen(false); onEdit() }} className="w-full text-left px-3 py-1.5 text-[12px] text-zinc-700 dark:text-zinc-300 hover:bg-zinc-50 dark:hover:bg-zinc-700 flex items-center gap-2"><Pencil size={12} /> {t('common.edit')}</button>
|
||||
<button onClick={() => { setMenuOpen(false); onDelete() }} className="w-full text-left px-3 py-1.5 text-[12px] text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 flex items-center gap-2"><Trash2 size={12} /> {t('common.delete')}</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{!readOnly && (
|
||||
<div className="absolute top-2.5 right-3 z-[2]">
|
||||
<button ref={menuBtnRef} onClick={() => setMenuOpen(!menuOpen)} className="w-8 h-8 rounded-[10px] bg-black/40 backdrop-blur-sm flex items-center justify-center text-white hover:bg-black/50">
|
||||
<MoreHorizontal size={14} />
|
||||
</button>
|
||||
{menuOpen && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-[99]" onClick={() => setMenuOpen(false)} />
|
||||
<div className="fixed z-[100] bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-lg shadow-lg py-1 min-w-[120px]" style={{ top: (menuBtnRef.current?.getBoundingClientRect().bottom || 0) + 4, right: window.innerWidth - (menuBtnRef.current?.getBoundingClientRect().right || 0) }}>
|
||||
<button onClick={() => { setMenuOpen(false); onEdit() }} className="w-full text-left px-3 py-1.5 text-[12px] text-zinc-700 dark:text-zinc-300 hover:bg-zinc-50 dark:hover:bg-zinc-700 flex items-center gap-2"><Pencil size={12} /> {t('common.edit')}</button>
|
||||
<button onClick={() => { setMenuOpen(false); onDelete() }} className="w-full text-left px-3 py-1.5 text-[12px] text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 flex items-center gap-2"><Trash2 size={12} /> {t('common.delete')}</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Title on photo */}
|
||||
{entry.title && (
|
||||
@@ -1314,20 +1361,22 @@ function EntryCard({ entry, onEdit, onDelete, onPhotoClick }: {
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="relative">
|
||||
<button ref={menuBtnRef} onClick={() => setMenuOpen(!menuOpen)} className="w-7 h-7 rounded-md flex items-center justify-center text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800">
|
||||
<MoreHorizontal size={14} />
|
||||
</button>
|
||||
{menuOpen && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-[99]" onClick={() => setMenuOpen(false)} />
|
||||
<div className="fixed z-[100] bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-lg shadow-lg py-1 min-w-[120px]" style={{ top: (menuBtnRef.current?.getBoundingClientRect().bottom || 0) + 4, right: window.innerWidth - (menuBtnRef.current?.getBoundingClientRect().right || 0) }}>
|
||||
<button onClick={() => { setMenuOpen(false); onEdit() }} className="w-full text-left px-3 py-1.5 text-[12px] text-zinc-700 dark:text-zinc-300 hover:bg-zinc-50 dark:hover:bg-zinc-700 flex items-center gap-2"><Pencil size={12} /> {t('common.edit')}</button>
|
||||
<button onClick={() => { setMenuOpen(false); onDelete() }} className="w-full text-left px-3 py-1.5 text-[12px] text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 flex items-center gap-2"><Trash2 size={12} /> {t('common.delete')}</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{!readOnly && (
|
||||
<div className="relative">
|
||||
<button ref={menuBtnRef} onClick={() => setMenuOpen(!menuOpen)} className="w-7 h-7 rounded-md flex items-center justify-center text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800">
|
||||
<MoreHorizontal size={14} />
|
||||
</button>
|
||||
{menuOpen && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-[99]" onClick={() => setMenuOpen(false)} />
|
||||
<div className="fixed z-[100] bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-lg shadow-lg py-1 min-w-[120px]" style={{ top: (menuBtnRef.current?.getBoundingClientRect().bottom || 0) + 4, right: window.innerWidth - (menuBtnRef.current?.getBoundingClientRect().right || 0) }}>
|
||||
<button onClick={() => { setMenuOpen(false); onEdit() }} className="w-full text-left px-3 py-1.5 text-[12px] text-zinc-700 dark:text-zinc-300 hover:bg-zinc-50 dark:hover:bg-zinc-700 flex items-center gap-2"><Pencil size={12} /> {t('common.edit')}</button>
|
||||
<button onClick={() => { setMenuOpen(false); onDelete() }} className="w-full text-left px-3 py-1.5 text-[12px] text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 flex items-center gap-2"><Trash2 size={12} /> {t('common.delete')}</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1366,12 +1415,12 @@ function EntryCard({ entry, onEdit, onDelete, onPhotoClick }: {
|
||||
)
|
||||
}
|
||||
|
||||
function SkeletonCard({ entry, onClick }: { entry: JourneyEntry; onClick: () => void }) {
|
||||
function SkeletonCard({ entry, onClick }: { entry: JourneyEntry; onClick?: () => void }) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
className="bg-white dark:bg-zinc-900 border border-dashed border-zinc-200 dark:border-zinc-700 rounded-xl px-4 py-3.5 flex items-center gap-3 transition-all hover:border-solid hover:border-zinc-400 dark:hover:border-zinc-500 cursor-pointer"
|
||||
className={`bg-white dark:bg-zinc-900 border border-dashed border-zinc-200 dark:border-zinc-700 rounded-xl px-4 py-3.5 flex items-center gap-3 transition-[border-color,border-style] duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] ${onClick ? 'hover:border-solid hover:border-zinc-400 dark:hover:border-zinc-500 cursor-pointer' : ''}`}
|
||||
>
|
||||
<div className="w-9 h-9 rounded-lg bg-zinc-100 dark:bg-zinc-800 flex items-center justify-center text-zinc-500 flex-shrink-0">
|
||||
<MapPin size={14} />
|
||||
@@ -1391,11 +1440,11 @@ function SkeletonCard({ entry, onClick }: { entry: JourneyEntry; onClick: () =>
|
||||
)
|
||||
}
|
||||
|
||||
function CheckinCard({ entry, onClick }: { entry: JourneyEntry; onClick: () => void }) {
|
||||
function CheckinCard({ entry, onClick }: { entry: JourneyEntry; onClick?: () => void }) {
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
className="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-xl px-3.5 py-2.5 flex items-center gap-2.5 transition-all hover:border-zinc-400 dark:hover:border-zinc-500 cursor-pointer"
|
||||
className={`bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-xl px-3.5 py-2.5 flex items-center gap-2.5 transition-colors duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] ${onClick ? 'hover:border-zinc-400 dark:hover:border-zinc-500 cursor-pointer' : ''}`}
|
||||
>
|
||||
<div className="w-7 h-7 rounded-lg bg-indigo-50 dark:bg-indigo-900/30 text-indigo-600 dark:text-indigo-400 flex items-center justify-center flex-shrink-0">
|
||||
<MapPin size={13} />
|
||||
@@ -1685,7 +1734,7 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
|
||||
{[
|
||||
{ id: 'trip' as const, label: t('journey.picker.tripPeriod') },
|
||||
{ id: 'custom' as const, label: t('journey.picker.dateRange') },
|
||||
{ id: 'all' as const, label: t('journey.picker.allPhotos') },
|
||||
{ id: 'all' as const, label: t('journey.picker.allPhotos'), short: t('common.all') },
|
||||
{ id: 'album' as const, label: t('journey.picker.albums') },
|
||||
].map(f => (
|
||||
<button
|
||||
@@ -1697,7 +1746,12 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
|
||||
: 'text-zinc-500 hover:bg-zinc-100 dark:hover:bg-zinc-800'
|
||||
}`}
|
||||
>
|
||||
{f.label}
|
||||
{f.short ? (
|
||||
<>
|
||||
<span className="hidden sm:inline">{f.label}</span>
|
||||
<span className="sm:hidden">{f.short}</span>
|
||||
</>
|
||||
) : f.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -1974,7 +2028,7 @@ function DatePicker({ value, onChange, tripDates }: {
|
||||
for (let i = 0; i < firstDow; i++) cells.push(null)
|
||||
for (let d = 1; d <= daysInMonth; d++) cells.push(d)
|
||||
|
||||
const formatted = value ? new Date(value + 'T00:00:00').toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) : t('journey.picker.selectDate')
|
||||
const formatted = value ? new Date(value + 'T00:00:00').toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) : null
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
@@ -1983,7 +2037,14 @@ function DatePicker({ value, onChange, tripDates }: {
|
||||
onClick={() => setOpen(!open)}
|
||||
className="w-full px-3 py-2 border border-zinc-200 dark:border-zinc-700 rounded-lg text-[13px] bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white text-left flex items-center justify-between"
|
||||
>
|
||||
<span>{formatted}</span>
|
||||
{formatted ? (
|
||||
<span>{formatted}</span>
|
||||
) : (
|
||||
<span>
|
||||
<span className="hidden sm:inline">{t('journey.picker.selectDate')}</span>
|
||||
<span className="sm:hidden">{t('common.date')}</span>
|
||||
</span>
|
||||
)}
|
||||
<Calendar size={13} className="text-zinc-400" />
|
||||
</button>
|
||||
|
||||
@@ -2082,6 +2143,31 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
|
||||
const fileRef = useRef<HTMLInputElement>(null)
|
||||
const storyRef = useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
// Track which fields differ from the entry we started editing so we can
|
||||
// warn before discarding on close/cancel.
|
||||
const originalPros = (entry.pros_cons?.pros ?? []).join('\n')
|
||||
const originalCons = (entry.pros_cons?.cons ?? []).join('\n')
|
||||
const isDirty = (
|
||||
title !== (entry.title || '') ||
|
||||
story !== (entry.story || '') ||
|
||||
entryDate !== (entry.entry_date || new Date().toISOString().split('T')[0]) ||
|
||||
entryTime !== (entry.entry_time || '') ||
|
||||
locationName !== (entry.location_name || '') ||
|
||||
(locationLat ?? null) !== (entry.location_lat ?? null) ||
|
||||
(locationLng ?? null) !== (entry.location_lng ?? null) ||
|
||||
mood !== (entry.mood || '') ||
|
||||
weather !== (entry.weather || '') ||
|
||||
pros.filter(p => p.trim()).join('\n') !== originalPros ||
|
||||
cons.filter(c => c.trim()).join('\n') !== originalCons ||
|
||||
pendingFiles.length > 0 ||
|
||||
pendingLinkIds.length > 0
|
||||
)
|
||||
|
||||
const handleClose = () => {
|
||||
if (isDirty && !window.confirm(t('journey.editor.discardChangesConfirm'))) return
|
||||
onClose()
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
@@ -2096,7 +2182,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
|
||||
mood: mood || null,
|
||||
weather: weather || null,
|
||||
pros_cons: { pros: pros.filter(p => p.trim()), cons: cons.filter(c => c.trim()) },
|
||||
type: (entry.type === 'skeleton' && story.trim()) ? 'entry' : undefined,
|
||||
type: ((entry.type === 'skeleton' && (story.trim() || pendingFiles.length > 0 || pendingLinkIds.length > 0)) ? 'entry' : undefined),
|
||||
})
|
||||
// upload queued files after entry is created
|
||||
if (pendingFiles.length > 0 && entryId) {
|
||||
@@ -2119,20 +2205,9 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
|
||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files
|
||||
if (!files?.length) return
|
||||
if (entry.id === 0) {
|
||||
// queue files for upload after save
|
||||
setPendingFiles(prev => [...prev, ...Array.from(files)])
|
||||
} else {
|
||||
setUploading(true)
|
||||
try {
|
||||
const formData = new FormData()
|
||||
for (const f of files) formData.append('photos', f)
|
||||
const newPhotos = await onUploadPhotos(entry.id, formData)
|
||||
if (newPhotos?.length) setPhotos(prev => [...prev, ...newPhotos])
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
}
|
||||
// Queue files locally until Save so cancel/close actually discards. This
|
||||
// keeps photo behavior consistent with text fields — no silent persistence.
|
||||
setPendingFiles(prev => [...prev, ...Array.from(files)])
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -2142,7 +2217,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
|
||||
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-zinc-200 dark:border-zinc-700">
|
||||
<h2 className="text-[16px] font-bold text-zinc-900 dark:text-white">{entry.id === 0 ? t('journey.detail.newEntry') : t('journey.detail.editEntry')}</h2>
|
||||
<button onClick={onClose} className="w-8 h-8 rounded-lg flex items-center justify-center text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800">
|
||||
<button onClick={handleClose} className="w-8 h-8 rounded-lg flex items-center justify-center text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800">
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
@@ -2474,7 +2549,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
|
||||
|
||||
|
||||
<div className="flex items-center justify-end gap-2 px-6 py-4 border-t border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/50" style={{ paddingBottom: 'max(16px, env(safe-area-inset-bottom, 16px))' }}>
|
||||
<button onClick={onClose} className="px-3.5 py-2 rounded-lg border border-zinc-200 dark:border-zinc-600 text-[13px] font-medium text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-700">{t('common.cancel')}</button>
|
||||
<button onClick={handleClose} className="px-3.5 py-2 rounded-lg border border-zinc-200 dark:border-zinc-600 text-[13px] font-medium text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-700">{t('common.cancel')}</button>
|
||||
<button onClick={handleSave} disabled={saving} className="px-3.5 py-2 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[13px] font-medium hover:bg-zinc-800 dark:hover:bg-zinc-100 disabled:opacity-50">
|
||||
{saving ? t('common.saving') : t('common.save')}
|
||||
</button>
|
||||
@@ -2893,7 +2968,7 @@ function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite }: {
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[200] flex items-end md:items-center justify-center md:p-5 overscroll-none" style={{ background: 'rgba(9,9,11,0.75)' }} onClick={onClose} onTouchMove={e => { if (e.target === e.currentTarget) e.preventDefault() }}>
|
||||
<div className="bg-white dark:bg-zinc-900 rounded-t-2xl md:rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] max-w-[480px] w-full max-h-[85vh] md:max-h-[90vh] flex flex-col overflow-hidden" style={{ paddingBottom: 'var(--bottom-nav-h)' }} onClick={e => e.stopPropagation()}>
|
||||
<div className="bg-white dark:bg-zinc-900 rounded-t-2xl md:rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] max-w-[480px] w-full max-h-[85vh] md:max-h-[90vh] flex flex-col overflow-hidden" style={{ paddingBottom: 'env(safe-area-inset-bottom, 0px)' }} onClick={e => e.stopPropagation()}>
|
||||
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-zinc-200 dark:border-zinc-700">
|
||||
<h2 className="text-[16px] font-bold text-zinc-900 dark:text-white">{t('journey.settings.title')}</h2>
|
||||
@@ -2986,6 +3061,25 @@ function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite }: {
|
||||
</div>
|
||||
<div className="flex-1 text-[12px] font-medium text-zinc-900 dark:text-white">{c.username}</div>
|
||||
<span className={`text-[9px] font-medium px-1.5 py-0.5 rounded-full ${c.role === 'owner' ? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900' : 'bg-zinc-100 dark:bg-zinc-800 text-zinc-500'}`}>{c.role}</span>
|
||||
{c.role !== 'owner' && (
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (!window.confirm(t('journey.contributors.removeConfirm', { username: c.username }))) return
|
||||
try {
|
||||
await journeyApi.removeContributor(journey.id, c.user_id)
|
||||
toast.success(t('journey.contributors.removed'))
|
||||
onSaved()
|
||||
} catch {
|
||||
toast.error(t('journey.contributors.removeFailed'))
|
||||
}
|
||||
}}
|
||||
aria-label={t('journey.contributors.remove')}
|
||||
title={t('journey.contributors.remove')}
|
||||
className="w-7 h-7 rounded-lg flex items-center justify-center text-zinc-400 hover:bg-red-50 dark:hover:bg-red-900/20 hover:text-red-500 transition-colors"
|
||||
>
|
||||
<X size={13} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
@@ -3005,24 +3099,28 @@ function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite }: {
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex flex-wrap items-center gap-2 px-6 py-4 pb-6 md:pb-4 border-t border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/50">
|
||||
<div className="flex items-center gap-1.5 px-4 md:px-6 py-4 pb-6 md:pb-4 border-t border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/50">
|
||||
<button
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
className="flex items-center gap-1.5 text-[12px] font-medium text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg px-2.5 py-2"
|
||||
aria-label={t('journey.settings.delete')}
|
||||
title={t('journey.settings.delete')}
|
||||
className="flex items-center justify-center gap-1.5 h-9 min-w-9 px-2 md:px-2.5 text-[12px] font-medium text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg"
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
{t('journey.settings.delete')}
|
||||
<Trash2 size={14} />
|
||||
<span className="hidden md:inline">{t('journey.settings.delete')}</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={handleArchiveToggle}
|
||||
disabled={archiving}
|
||||
className="flex items-center gap-1.5 text-[12px] font-medium text-zinc-600 dark:text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-700 rounded-lg px-2.5 py-2 mr-auto disabled:opacity-40"
|
||||
aria-label={journey.status === 'archived' ? t('journey.settings.reopenJourney') : t('journey.settings.endJourney')}
|
||||
title={t('journey.settings.endDescription')}
|
||||
className="flex items-center justify-center gap-1.5 h-9 min-w-9 px-2 md:px-2.5 text-[12px] font-medium text-zinc-600 dark:text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-700 rounded-lg mr-auto disabled:opacity-40"
|
||||
>
|
||||
{journey.status === 'archived' ? t('journey.settings.reopenJourney') : t('journey.settings.endJourney')}
|
||||
{journey.status === 'archived' ? <ArchiveRestore size={14} /> : <Archive size={14} />}
|
||||
<span className="hidden md:inline">{journey.status === 'archived' ? t('journey.settings.reopenJourney') : t('journey.settings.endJourney')}</span>
|
||||
</button>
|
||||
<button onClick={onClose} className="px-3.5 py-2 rounded-lg border border-zinc-200 dark:border-zinc-600 text-[13px] font-medium text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-700">{t('common.cancel')}</button>
|
||||
<button onClick={handleSave} disabled={saving || !title.trim()} className="px-3.5 py-2 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[13px] font-medium hover:bg-zinc-800 dark:hover:bg-zinc-100 disabled:opacity-40">
|
||||
<button onClick={onClose} className="h-9 px-3.5 rounded-lg border border-zinc-200 dark:border-zinc-600 text-[13px] font-medium text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-700">{t('common.cancel')}</button>
|
||||
<button onClick={handleSave} disabled={saving || !title.trim()} className="h-9 px-3.5 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[13px] font-medium hover:bg-zinc-800 dark:hover:bg-zinc-100 disabled:opacity-40">
|
||||
{saving ? t('common.saving') : t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -238,7 +238,7 @@ export default function JourneyPage() {
|
||||
|
||||
<div
|
||||
onClick={() => navigate(`/journey/${activeJourney.id}`)}
|
||||
className="relative rounded-3xl overflow-hidden cursor-pointer transition-all duration-300 hover:-translate-y-1 hover:shadow-xl h-[340px] md:h-[400px]"
|
||||
className="relative rounded-3xl overflow-hidden cursor-pointer transition-[transform,box-shadow] duration-300 ease-[cubic-bezier(0.23,1,0.32,1)] hover:-translate-y-1 hover:shadow-xl h-[340px] md:h-[400px]"
|
||||
style={{ background: pickGradient(activeJourney.id) }}
|
||||
>
|
||||
{/* Cover image */}
|
||||
@@ -333,9 +333,9 @@ export default function JourneyPage() {
|
||||
{/* Create card */}
|
||||
<button
|
||||
onClick={() => openCreateModal()}
|
||||
className="group min-h-[320px] rounded-2xl border-[1.5px] border-dashed border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-900 flex flex-col items-center justify-center gap-2.5 hover:border-zinc-400 hover:bg-zinc-50 dark:hover:bg-zinc-800 transition-all cursor-pointer hover:-translate-y-0.5"
|
||||
className="group min-h-[320px] rounded-2xl border-[1.5px] border-dashed border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-900 flex flex-col items-center justify-center gap-2.5 hover:border-zinc-400 hover:bg-zinc-50 dark:hover:bg-zinc-800 transition-[border-color,background-color,transform] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] cursor-pointer hover:-translate-y-0.5"
|
||||
>
|
||||
<div className="w-14 h-14 rounded-full bg-zinc-100 dark:bg-zinc-800 flex items-center justify-center text-zinc-400 group-hover:bg-white dark:group-hover:bg-zinc-700 transition-all group-hover:rotate-90 duration-300">
|
||||
<div className="w-14 h-14 rounded-full bg-zinc-100 dark:bg-zinc-800 flex items-center justify-center text-zinc-400 group-hover:bg-white dark:group-hover:bg-zinc-700 transition-[background-color,transform] group-hover:rotate-90 duration-300 ease-[cubic-bezier(0.23,1,0.32,1)]">
|
||||
<Plus size={22} />
|
||||
</div>
|
||||
<span className="text-[14px] font-semibold text-zinc-700 dark:text-zinc-300">{t("journey.frontpage.createNew")}</span>
|
||||
@@ -394,7 +394,7 @@ export default function JourneyPage() {
|
||||
return next
|
||||
})
|
||||
}}
|
||||
className={`flex items-center gap-3 p-3 rounded-xl border cursor-pointer transition-all ${
|
||||
className={`flex items-center gap-3 p-3 rounded-xl border cursor-pointer transition-[border-color,background-color] duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] ${
|
||||
selected
|
||||
? 'border-zinc-900 dark:border-zinc-400 bg-zinc-50 dark:bg-zinc-800'
|
||||
: 'border-zinc-200 dark:border-zinc-700 hover:border-zinc-400 dark:hover:border-zinc-500'
|
||||
@@ -468,7 +468,7 @@ function JourneyCard({ journey, onClick }: { journey: Journey & { entry_count?:
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
className="rounded-2xl border border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-900 overflow-hidden cursor-pointer transition-all duration-250 hover:border-zinc-400 hover:-translate-y-1 hover:shadow-[0_20px_40px_rgba(0,0,0,0.06)] flex flex-col"
|
||||
className="rounded-2xl border border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-900 overflow-hidden cursor-pointer transition-[transform,box-shadow,border-color] duration-250 ease-[cubic-bezier(0.23,1,0.32,1)] hover:border-zinc-400 hover:-translate-y-1 hover:shadow-[0_20px_40px_rgba(0,0,0,0.06)] flex flex-col"
|
||||
>
|
||||
{/* Cover */}
|
||||
<div className="h-[170px] relative overflow-hidden" style={{ background: pickGradient(j.id) }}>
|
||||
|
||||
@@ -574,7 +574,7 @@ export default function LoginPage(): React.ReactElement {
|
||||
{ Icon: FolderOpen, label: t('login.features.files'), desc: t('login.features.filesDesc') },
|
||||
{ Icon: Route, label: t('login.features.routes'), desc: t('login.features.routesDesc') },
|
||||
].map(({ Icon, label, desc }) => (
|
||||
<div key={label} style={{ background: 'rgba(255,255,255,0.04)', borderRadius: 14, padding: '14px 12px', border: '1px solid rgba(255,255,255,0.06)', textAlign: 'left', transition: 'all 0.2s' }}
|
||||
<div key={label} style={{ background: 'rgba(255,255,255,0.04)', borderRadius: 14, padding: '14px 12px', border: '1px solid rgba(255,255,255,0.06)', textAlign: 'left', transition: 'background 200ms cubic-bezier(0.23,1,0.32,1), border-color 200ms cubic-bezier(0.23,1,0.32,1)' }}
|
||||
onMouseEnter={(e: React.MouseEvent<HTMLDivElement>) => { e.currentTarget.style.background = 'rgba(255,255,255,0.08)'; e.currentTarget.style.borderColor = 'rgba(255,255,255,0.12)' }}
|
||||
onMouseLeave={(e: React.MouseEvent<HTMLDivElement>) => { e.currentTarget.style.background = 'rgba(255,255,255,0.04)'; e.currentTarget.style.borderColor = 'rgba(255,255,255,0.06)' }}>
|
||||
<Icon size={17} style={{ color: 'rgba(255,255,255,0.7)', marginBottom: 7 }} />
|
||||
@@ -619,7 +619,7 @@ export default function LoginPage(): React.ReactElement {
|
||||
border: 'none', borderRadius: 12,
|
||||
fontSize: 14, fontWeight: 700, cursor: 'pointer',
|
||||
fontFamily: 'inherit', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
|
||||
textDecoration: 'none', transition: 'all 0.15s',
|
||||
textDecoration: 'none', transition: 'background 180ms cubic-bezier(0.23,1,0.32,1)',
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
onMouseEnter={(e: React.MouseEvent<HTMLAnchorElement>) => { e.currentTarget.style.background = '#1f2937' }}
|
||||
@@ -764,9 +764,21 @@ export default function LoginPage(): React.ReactElement {
|
||||
/>
|
||||
<button type="button" onClick={() => setShowPassword(v => !v)} style={{
|
||||
position: 'absolute', right: 12, top: '50%', transform: 'translateY(-50%)',
|
||||
background: 'none', border: 'none', cursor: 'pointer', padding: 2, display: 'flex', color: '#9ca3af',
|
||||
background: 'none', border: 'none', cursor: 'pointer', padding: 2, color: '#9ca3af',
|
||||
width: 22, height: 22,
|
||||
}}>
|
||||
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
<Eye size={16} style={{
|
||||
position: 'absolute', inset: 3,
|
||||
opacity: showPassword ? 0 : 1,
|
||||
transform: showPassword ? 'scale(0.7) rotate(-20deg)' : 'scale(1) rotate(0)',
|
||||
transition: 'opacity 180ms cubic-bezier(0.23,1,0.32,1), transform 180ms cubic-bezier(0.23,1,0.32,1)',
|
||||
}} />
|
||||
<EyeOff size={16} style={{
|
||||
position: 'absolute', inset: 3,
|
||||
opacity: showPassword ? 1 : 0,
|
||||
transform: showPassword ? 'scale(1) rotate(0)' : 'scale(0.7) rotate(20deg)',
|
||||
transition: 'opacity 180ms cubic-bezier(0.23,1,0.32,1), transform 180ms cubic-bezier(0.23,1,0.32,1)',
|
||||
}} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -816,7 +828,7 @@ export default function LoginPage(): React.ReactElement {
|
||||
border: '1px solid #d1d5db', borderRadius: 12,
|
||||
fontSize: 14, fontWeight: 600, cursor: 'pointer',
|
||||
fontFamily: 'inherit', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
|
||||
textDecoration: 'none', transition: 'all 0.15s',
|
||||
textDecoration: 'none', transition: 'background 180ms cubic-bezier(0.23,1,0.32,1), border-color 180ms cubic-bezier(0.23,1,0.32,1)',
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
onMouseEnter={(e: React.MouseEvent<HTMLAnchorElement>) => { e.currentTarget.style.background = '#f9fafb'; e.currentTarget.style.borderColor = '#9ca3af' }}
|
||||
@@ -837,7 +849,7 @@ export default function LoginPage(): React.ReactElement {
|
||||
color: '#451a03', border: 'none', borderRadius: 14,
|
||||
fontSize: 15, fontWeight: 700, cursor: isLoading ? 'default' : 'pointer',
|
||||
fontFamily: 'inherit', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 10,
|
||||
opacity: isLoading ? 0.7 : 1, transition: 'all 0.2s',
|
||||
opacity: isLoading ? 0.7 : 1, transition: 'transform 200ms cubic-bezier(0.23,1,0.32,1), box-shadow 200ms cubic-bezier(0.23,1,0.32,1), opacity 200ms cubic-bezier(0.23,1,0.32,1)',
|
||||
boxShadow: '0 2px 12px rgba(245, 158, 11, 0.3)',
|
||||
}}
|
||||
onMouseEnter={(e: React.MouseEvent<HTMLButtonElement>) => { if (!isLoading) e.currentTarget.style.transform = 'translateY(-1px)'; e.currentTarget.style.boxShadow = '0 4px 16px rgba(245, 158, 11, 0.4)' }}
|
||||
|
||||
@@ -100,7 +100,7 @@ export default function RegisterPage(): React.ReactElement {
|
||||
required
|
||||
placeholder="johndoe"
|
||||
minLength={3}
|
||||
className="w-full pl-10 pr-4 py-2.5 border border-slate-300 rounded-lg text-slate-900 placeholder-slate-400 focus:ring-2 focus:ring-slate-400 focus:border-transparent transition-all"
|
||||
className="w-full pl-10 pr-4 py-2.5 border border-slate-300 rounded-lg text-slate-900 placeholder-slate-400 focus:ring-2 focus:ring-slate-400 focus:border-transparent transition-[border-color,box-shadow] duration-150 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -115,7 +115,7 @@ export default function RegisterPage(): React.ReactElement {
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setEmail(e.target.value)}
|
||||
required
|
||||
placeholder="your@email.com"
|
||||
className="w-full pl-10 pr-4 py-2.5 border border-slate-300 rounded-lg text-slate-900 placeholder-slate-400 focus:ring-2 focus:ring-slate-400 focus:border-transparent transition-all"
|
||||
className="w-full pl-10 pr-4 py-2.5 border border-slate-300 rounded-lg text-slate-900 placeholder-slate-400 focus:ring-2 focus:ring-slate-400 focus:border-transparent transition-[border-color,box-shadow] duration-150 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -130,7 +130,7 @@ export default function RegisterPage(): React.ReactElement {
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setPassword(e.target.value)}
|
||||
required
|
||||
placeholder={t('register.minChars')}
|
||||
className="w-full pl-10 pr-12 py-2.5 border border-slate-300 rounded-lg text-slate-900 placeholder-slate-400 focus:ring-2 focus:ring-slate-400 focus:border-transparent transition-all"
|
||||
className="w-full pl-10 pr-12 py-2.5 border border-slate-300 rounded-lg text-slate-900 placeholder-slate-400 focus:ring-2 focus:ring-slate-400 focus:border-transparent transition-[border-color,box-shadow] duration-150 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@@ -152,7 +152,7 @@ export default function RegisterPage(): React.ReactElement {
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setConfirmPassword(e.target.value)}
|
||||
required
|
||||
placeholder={t('register.repeatPassword')}
|
||||
className="w-full pl-10 pr-4 py-2.5 border border-slate-300 rounded-lg text-slate-900 placeholder-slate-400 focus:ring-2 focus:ring-slate-400 focus:border-transparent transition-all"
|
||||
className="w-full pl-10 pr-4 py-2.5 border border-slate-300 rounded-lg text-slate-900 placeholder-slate-400 focus:ring-2 focus:ring-slate-400 focus:border-transparent transition-[border-color,box-shadow] duration-150 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -12,12 +12,14 @@ import PlaceInspector from '../components/Planner/PlaceInspector'
|
||||
import DayDetailPanel from '../components/Planner/DayDetailPanel'
|
||||
import PlaceFormModal from '../components/Planner/PlaceFormModal'
|
||||
import TripFormModal from '../components/Trips/TripFormModal'
|
||||
import SlidingTabs from '../components/shared/SlidingTabs'
|
||||
import TripMembersModal from '../components/Trips/TripMembersModal'
|
||||
import { ReservationModal } from '../components/Planner/ReservationModal'
|
||||
import { TransportModal } from '../components/Planner/TransportModal'
|
||||
// MemoriesPanel moved to Journey addon
|
||||
import ReservationsPanel from '../components/Planner/ReservationsPanel'
|
||||
import PackingListPanel from '../components/Packing/PackingListPanel'
|
||||
import ApplyTemplateButton from '../components/Packing/ApplyTemplateButton'
|
||||
import TodoListPanel from '../components/Todo/TodoListPanel'
|
||||
import FileManager from '../components/Files/FileManager'
|
||||
import BudgetPanel from '../components/Budget/BudgetPanel'
|
||||
@@ -37,7 +39,7 @@ import { useRouteCalculation } from '../hooks/useRouteCalculation'
|
||||
import { usePlaceSelection } from '../hooks/usePlaceSelection'
|
||||
import { usePlannerHistory } from '../hooks/usePlannerHistory'
|
||||
import type { Accommodation, TripMember, Day, Place, Reservation, PackingItem, TodoItem } from '../types'
|
||||
import { ListTodo, Upload, Plus } from 'lucide-react'
|
||||
import { ListTodo, Upload, Plus, Trash2, FolderPlus } from 'lucide-react'
|
||||
|
||||
function ListsContainer({ tripId, packingItems, todoItems }: { tripId: number; packingItems: PackingItem[]; todoItems: TodoItem[] }) {
|
||||
const [subTab, setSubTab] = useState<'packing' | 'todo'>(() => {
|
||||
@@ -45,6 +47,8 @@ function ListsContainer({ tripId, packingItems, todoItems }: { tripId: number; p
|
||||
})
|
||||
const setSubTabPersist = (tab: 'packing' | 'todo') => { setSubTab(tab); sessionStorage.setItem(`trip-lists-subtab-${tripId}`, tab) }
|
||||
const [importPackingSignal, setImportPackingSignal] = useState(0)
|
||||
const [clearCheckedSignal, setClearCheckedSignal] = useState(0)
|
||||
const [saveTemplateSignal, setSaveTemplateSignal] = useState(0)
|
||||
const [addTodoSignal, setAddTodoSignal] = useState(0)
|
||||
const { t } = useTranslation()
|
||||
|
||||
@@ -79,7 +83,7 @@ function ListsContainer({ tripId, packingItems, todoItems }: { tripId: number; p
|
||||
color: active ? 'var(--text-primary)' : 'var(--text-muted)',
|
||||
fontWeight: active ? 500 : 400,
|
||||
boxShadow: active ? '0 1px 2px rgba(0,0,0,0.06)' : 'none',
|
||||
transition: 'all 0.15s ease',
|
||||
transition: 'background 180ms cubic-bezier(0.23,1,0.32,1), color 180ms cubic-bezier(0.23,1,0.32,1), box-shadow 180ms cubic-bezier(0.23,1,0.32,1)',
|
||||
}}
|
||||
>
|
||||
<Icon size={13} style={{ color: active ? 'var(--text-primary)' : 'var(--text-faint)' }} />
|
||||
@@ -95,33 +99,58 @@ function ListsContainer({ tripId, packingItems, todoItems }: { tripId: number; p
|
||||
})}
|
||||
</div>
|
||||
|
||||
{subTab === 'packing' && (
|
||||
<button onClick={() => setImportPackingSignal(s => s + 1)} style={{
|
||||
{subTab === 'packing' && (() => {
|
||||
const packingAbgehakt = packingItems.filter(i => i.checked).length
|
||||
const sharedBtnClass = 'inline-flex items-center gap-1.5 px-2.5 sm:px-[14px] py-[7px] sm:py-[9px] hover:opacity-[0.88]'
|
||||
const sharedBtnStyle: React.CSSProperties = {
|
||||
appearance: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit',
|
||||
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||
padding: '9px 14px', borderRadius: 10, fontSize: 13, fontWeight: 500,
|
||||
background: 'var(--accent)', color: 'var(--accent-text)', flexShrink: 0,
|
||||
marginLeft: 'auto',
|
||||
transition: 'opacity 0.15s ease',
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.opacity = '0.88'}
|
||||
onMouseLeave={e => e.currentTarget.style.opacity = '1'}
|
||||
>
|
||||
<Upload size={14} strokeWidth={2.5} />
|
||||
<span className="hidden sm:inline">{t('packing.import')}</span>
|
||||
</button>
|
||||
)}
|
||||
borderRadius: 10, fontSize: 13, fontWeight: 500,
|
||||
}
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: 6, flexShrink: 0, marginLeft: 'auto', flexWrap: 'wrap' }}>
|
||||
{packingAbgehakt > 0 && (
|
||||
<button onClick={() => setClearCheckedSignal(s => s + 1)}
|
||||
className={`hidden sm:inline-flex items-center gap-1.5 px-[14px] py-[9px] hover:opacity-[0.88]`}
|
||||
style={{ ...sharedBtnStyle, background: 'rgba(239,68,68,0.14)', color: '#ef4444' }}
|
||||
>
|
||||
<Trash2 size={14} strokeWidth={2.5} />
|
||||
<span>{t('packing.clearChecked', { count: packingAbgehakt })}</span>
|
||||
</button>
|
||||
)}
|
||||
<ApplyTemplateButton
|
||||
tripId={tripId}
|
||||
className={sharedBtnClass}
|
||||
style={{ ...sharedBtnStyle, background: 'var(--accent)', color: 'var(--accent-text)' }}
|
||||
/>
|
||||
{packingItems.length > 0 && (
|
||||
<button onClick={() => setSaveTemplateSignal(s => s + 1)}
|
||||
className={sharedBtnClass}
|
||||
style={{ ...sharedBtnStyle, background: 'var(--accent)', color: 'var(--accent-text)' }}
|
||||
>
|
||||
<FolderPlus size={14} strokeWidth={2.5} />
|
||||
<span className="hidden sm:inline">{t('packing.saveAsTemplate')}</span>
|
||||
</button>
|
||||
)}
|
||||
<button onClick={() => setImportPackingSignal(s => s + 1)}
|
||||
className={sharedBtnClass}
|
||||
style={{ ...sharedBtnStyle, background: 'var(--accent)', color: 'var(--accent-text)' }}
|
||||
>
|
||||
<Upload size={14} strokeWidth={2.5} />
|
||||
<span className="hidden sm:inline">{t('packing.import')}</span>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
{subTab === 'todo' && (
|
||||
<button onClick={() => setAddTodoSignal(s => s + 1)} style={{
|
||||
appearance: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit',
|
||||
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||
padding: '9px 14px', borderRadius: 10, fontSize: 13, fontWeight: 500,
|
||||
background: 'var(--accent)', color: 'var(--accent-text)', flexShrink: 0,
|
||||
marginLeft: 'auto',
|
||||
transition: 'opacity 0.15s ease',
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.opacity = '0.88'}
|
||||
onMouseLeave={e => e.currentTarget.style.opacity = '1'}
|
||||
<button onClick={() => setAddTodoSignal(s => s + 1)}
|
||||
className="hover:opacity-[0.88]"
|
||||
style={{
|
||||
appearance: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit',
|
||||
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||
padding: '9px 14px', borderRadius: 10, fontSize: 13, fontWeight: 500,
|
||||
background: 'var(--accent)', color: 'var(--accent-text)', flexShrink: 0,
|
||||
marginLeft: 'auto',
|
||||
}}
|
||||
>
|
||||
<Plus size={14} strokeWidth={2.5} />
|
||||
<span className="hidden sm:inline">{t('todo.addItem')}</span>
|
||||
@@ -130,7 +159,7 @@ function ListsContainer({ tripId, packingItems, todoItems }: { tripId: number; p
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ padding: '16px 28px 0' }} className="max-md:!px-4">
|
||||
{subTab === 'packing' && <PackingListPanel tripId={tripId} items={packingItems} openImportSignal={importPackingSignal} inlineHeader={false} />}
|
||||
{subTab === 'packing' && <PackingListPanel tripId={tripId} items={packingItems} openImportSignal={importPackingSignal} clearCheckedSignal={clearCheckedSignal} saveTemplateSignal={saveTemplateSignal} inlineHeader={false} />}
|
||||
{subTab === 'todo' && <TodoListPanel tripId={tripId} items={todoItems} addItemSignal={addTodoSignal} />}
|
||||
</div>
|
||||
</div>
|
||||
@@ -720,34 +749,17 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
WebkitBackdropFilter: 'blur(16px)',
|
||||
borderBottom: '1px solid var(--border-faint)',
|
||||
height: 44,
|
||||
overflowX: 'auto', scrollbarWidth: 'none', msOverflowStyle: 'none',
|
||||
gap: 2,
|
||||
}}>
|
||||
{TRIP_TABS.map(tab => {
|
||||
const isActive = activeTab === tab.id
|
||||
const TabIcon = tab.icon
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => handleTabChange(tab.id)}
|
||||
title={tab.label}
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
padding: '5px 14px', borderRadius: 20, border: 'none', cursor: 'pointer',
|
||||
fontSize: 13, fontWeight: isActive ? 600 : 400,
|
||||
background: isActive ? 'var(--accent)' : 'transparent',
|
||||
color: isActive ? 'var(--accent-text)' : 'var(--text-muted)',
|
||||
fontFamily: 'inherit', transition: 'all 0.15s',
|
||||
display: 'flex', alignItems: 'center', gap: 5,
|
||||
}}
|
||||
onMouseEnter={e => { if (!isActive) e.currentTarget.style.background = 'var(--bg-hover)'; e.currentTarget.style.color = isActive ? 'var(--accent-text)' : 'var(--text-primary)' }}
|
||||
onMouseLeave={e => { if (!isActive) e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = isActive ? 'var(--accent-text)' : 'var(--text-muted)' }}
|
||||
>
|
||||
{TabIcon && <><TabIcon size={20} className="sm:hidden" /><TabIcon size={15} className="hidden sm:block" /></>}
|
||||
<span className="hidden sm:inline">{tab.shortLabel || tab.label}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
<SlidingTabs
|
||||
tabs={TRIP_TABS.map(tab => ({
|
||||
id: tab.id,
|
||||
label: <span className="hidden sm:inline">{tab.shortLabel || tab.label}</span>,
|
||||
title: tab.label,
|
||||
icon: tab.icon,
|
||||
}))}
|
||||
activeTab={activeTab}
|
||||
onChange={handleTabChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Offset by navbar + tab bar (44px) */}
|
||||
|
||||
@@ -92,7 +92,7 @@ export default function VacayPage(): React.ReactElement {
|
||||
<div className="grid grid-cols-4 gap-1">
|
||||
{years.map(y => (
|
||||
<div key={y} onClick={() => setSelectedYear(y)}
|
||||
className="group relative py-1.5 rounded-lg text-xs font-medium transition-all text-center cursor-pointer"
|
||||
className="group relative py-1.5 rounded-lg text-xs font-medium transition-[background-color,color] duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] text-center cursor-pointer"
|
||||
style={{
|
||||
background: y === selectedYear ? 'var(--text-primary)' : 'var(--bg-secondary)',
|
||||
color: y === selectedYear ? 'var(--bg-card)' : 'var(--text-muted)',
|
||||
@@ -262,8 +262,8 @@ export default function VacayPage(): React.ReactElement {
|
||||
<div className="fixed inset-0 flex items-center justify-center px-4"
|
||||
style={{ zIndex: 99995, backgroundColor: 'rgba(0,0,0,0.7)', backdropFilter: 'blur(8px)' }}>
|
||||
{incomingInvites.map(inv => (
|
||||
<div key={inv.id} className="w-full max-w-md rounded-2xl shadow-2xl overflow-hidden"
|
||||
style={{ background: 'var(--bg-card)', animation: 'modalIn 0.25s ease-out' }}>
|
||||
<div key={inv.id} className="trek-modal-enter w-full max-w-md rounded-2xl shadow-2xl overflow-hidden"
|
||||
style={{ background: 'var(--bg-card)' }}>
|
||||
<div className="px-6 pt-6 pb-4 text-center">
|
||||
<div className="w-14 h-14 rounded-full mx-auto mb-4 flex items-center justify-center text-lg font-bold"
|
||||
style={{ background: 'var(--bg-secondary)', color: 'var(--text-primary)' }}>
|
||||
|
||||
@@ -25,6 +25,15 @@ afterAll(() => server.close());
|
||||
|
||||
// ── jsdom stubs ────────────────────────────────────────────────────────────────
|
||||
|
||||
// Force en-US locale for toLocaleDateString so tests are deterministic on
|
||||
// non-US dev machines (Windows-de-DE returns "Sonntag" instead of "Sunday").
|
||||
// Only affects calls without an explicit locale — callers that pass a locale
|
||||
// keep their behavior.
|
||||
const _origToLocaleDateString = Date.prototype.toLocaleDateString
|
||||
Date.prototype.toLocaleDateString = function (locales?: Intl.LocalesArgument, options?: Intl.DateTimeFormatOptions) {
|
||||
return _origToLocaleDateString.call(this, locales ?? 'en-US', options)
|
||||
}
|
||||
|
||||
// window.matchMedia — used by dark mode / responsive components
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
|
||||
@@ -1738,6 +1738,35 @@ function runMigrations(db: Database.Database): void {
|
||||
AND substr(reservations.reservation_end_time, 1, 10) != substr(reservations.reservation_time, 1, 10)
|
||||
`);
|
||||
},
|
||||
// Migration 111: opt-in Immich auto-upload — users column only (#730)
|
||||
// Default is off — uploading to Immich must be an explicit choice, not a
|
||||
// side effect of having a writable API key.
|
||||
() => {
|
||||
try { db.exec('ALTER TABLE users ADD COLUMN immich_auto_upload INTEGER NOT NULL DEFAULT 0'); }
|
||||
catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; }
|
||||
},
|
||||
// Migration 112: expose immich auto-upload toggle in the Settings UI (#730)
|
||||
// Runs after Immich provider seeding so the FK to photo_providers holds.
|
||||
() => {
|
||||
try {
|
||||
const hasTable = db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name IN ('photo_providers', 'photo_provider_fields')").all() as Array<{ name: string }>;
|
||||
const hasProviders = hasTable.some(t => t.name === 'photo_providers');
|
||||
const hasFields = hasTable.some(t => t.name === 'photo_provider_fields');
|
||||
if (hasProviders && hasFields) {
|
||||
const immichRow = db.prepare("SELECT 1 FROM photo_providers WHERE id = 'immich' LIMIT 1").get();
|
||||
if (immichRow) {
|
||||
db.prepare(`
|
||||
INSERT OR IGNORE INTO photo_provider_fields
|
||||
(provider_id, field_key, label, input_type, placeholder, required, secret, settings_key, payload_key, sort_order)
|
||||
VALUES
|
||||
('immich', 'immich_auto_upload', 'immichAutoUpload', 'checkbox', NULL, 0, 0, 'auto_upload', 'auto_upload', 5)
|
||||
`).run();
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (!err.message?.includes('no such table') && !err.message?.includes('FOREIGN KEY')) throw err;
|
||||
}
|
||||
},
|
||||
];
|
||||
|
||||
if (currentVersion < migrations.length) {
|
||||
|
||||
@@ -98,6 +98,13 @@ router.get('/:id/download', (req: Request, res: Response) => {
|
||||
if (!safe) return res.status(403).json({ error: 'Forbidden' });
|
||||
if (!fs.existsSync(resolved)) return res.status(404).json({ error: 'File not found' });
|
||||
|
||||
// Serve Apple Wallet passes inline with the canonical MIME type so Safari
|
||||
// (iOS/macOS) hands them off to Wallet instead of downloading as a blob.
|
||||
if (path.extname(resolved).toLowerCase() === '.pkpass') {
|
||||
res.setHeader('Content-Type', 'application/vnd.apple.pkpass');
|
||||
res.setHeader('Content-Disposition', `inline; filename="${path.basename(file.original_name || resolved)}"`);
|
||||
}
|
||||
|
||||
res.sendFile(resolved);
|
||||
});
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import crypto from 'node:crypto';
|
||||
import { authenticate } from '../middleware/auth';
|
||||
import { AuthRequest } from '../types';
|
||||
import * as svc from '../services/journeyService';
|
||||
import { db } from '../db/database';
|
||||
import { createOrUpdateJourneyShareLink, getJourneyShareLink, deleteJourneyShareLink, getPublicJourney } from '../services/journeyShareService';
|
||||
import { uploadToImmich } from '../services/memories/immichService';
|
||||
|
||||
@@ -95,16 +96,21 @@ router.post('/entries/:entryId/photos', authenticate, upload.array('photos', 10)
|
||||
req.body?.caption
|
||||
);
|
||||
if (photo) {
|
||||
// sync to Immich if connected — update the same photo record
|
||||
try {
|
||||
const immichId = await uploadToImmich(authReq.user.id, relativePath, file.originalname);
|
||||
if (immichId) {
|
||||
svc.setPhotoProvider(photo.id, 'immich', immichId, authReq.user.id);
|
||||
photo.provider = 'immich' as any;
|
||||
photo.asset_id = immichId;
|
||||
photo.owner_id = authReq.user.id;
|
||||
}
|
||||
} catch {}
|
||||
// Mirror to Immich only when the user has explicitly opted in via the
|
||||
// Immich integration settings. Avoids the "surprise upload" in #730
|
||||
// where a write-capable API key implicitly enabled mirroring.
|
||||
const prefs = db.prepare('SELECT immich_auto_upload FROM users WHERE id = ?').get(authReq.user.id) as { immich_auto_upload?: number } | undefined;
|
||||
if (prefs?.immich_auto_upload) {
|
||||
try {
|
||||
const immichId = await uploadToImmich(authReq.user.id, relativePath, file.originalname);
|
||||
if (immichId) {
|
||||
svc.setPhotoProvider(photo.id, 'immich', immichId, authReq.user.id);
|
||||
photo.provider = 'immich' as any;
|
||||
photo.asset_id = immichId;
|
||||
photo.owner_id = authReq.user.id;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
results.push(photo);
|
||||
}
|
||||
}
|
||||
@@ -301,11 +307,15 @@ router.post('/:id/share-link', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { share_timeline, share_gallery, share_map } = req.body || {};
|
||||
const result = createOrUpdateJourneyShareLink(Number(req.params.id), authReq.user.id, { share_timeline, share_gallery, share_map });
|
||||
if (!result) return res.status(403).json({ error: 'Not allowed' });
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
router.delete('/:id/share-link', authenticate, (req: Request, res: Response) => {
|
||||
deleteJourneyShareLink(Number(req.params.id));
|
||||
const authReq = req as AuthRequest;
|
||||
if (!deleteJourneyShareLink(Number(req.params.id), authReq.user.id)) {
|
||||
return res.status(403).json({ error: 'Not allowed' });
|
||||
}
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { getClientIp } from '../../services/auditLog';
|
||||
import {
|
||||
getConnectionSettings,
|
||||
saveImmichSettings,
|
||||
setImmichAutoUpload,
|
||||
testConnection,
|
||||
getConnectionStatus,
|
||||
browseTimeline,
|
||||
@@ -31,9 +32,12 @@ router.get('/settings', authenticate, (req: Request, res: Response) => {
|
||||
|
||||
router.put('/settings', authenticate, async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { immich_url, immich_api_key } = req.body;
|
||||
const { immich_url, immich_api_key, auto_upload } = req.body;
|
||||
const result = await saveImmichSettings(authReq.user.id, immich_url, immich_api_key, getClientIp(req));
|
||||
if (!result.success) return res.status(400).json({ error: result.error });
|
||||
if (typeof auto_upload === 'boolean') {
|
||||
setImmichAutoUpload(authReq.user.id, auto_upload);
|
||||
}
|
||||
if (result.warning) return res.json({ success: true, warning: result.warning });
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
@@ -11,7 +11,7 @@ import { TripFile } from '../types';
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50 MB
|
||||
export const DEFAULT_ALLOWED_EXTENSIONS = 'jpg,jpeg,png,gif,webp,heic,pdf,doc,docx,xls,xlsx,txt,csv';
|
||||
export const DEFAULT_ALLOWED_EXTENSIONS = 'jpg,jpeg,png,gif,webp,heic,pdf,doc,docx,xls,xlsx,txt,csv,pkpass';
|
||||
export const BLOCKED_EXTENSIONS = ['.svg', '.html', '.htm', '.xml'];
|
||||
export const filesDir = path.join(__dirname, '../../uploads/files');
|
||||
|
||||
|
||||
@@ -167,6 +167,19 @@ export function getJourneyFull(journeyId: number, userId: number) {
|
||||
'SELECT hide_skeletons FROM journey_contributors WHERE journey_id = ? AND user_id = ?'
|
||||
).get(journeyId, userId) as { hide_skeletons: number } | undefined;
|
||||
|
||||
// Determine the viewer's role on this journey so the UI can gate edit/settings
|
||||
// actions. 'owner' = creator, 'editor' | 'viewer' = from journey_contributors.
|
||||
const journeyRow = journey as unknown as { user_id?: number };
|
||||
let myRole: 'owner' | 'editor' | 'viewer' | null = null;
|
||||
if (journeyRow.user_id === userId) {
|
||||
myRole = 'owner';
|
||||
} else {
|
||||
const contribRow = db.prepare(
|
||||
'SELECT role FROM journey_contributors WHERE journey_id = ? AND user_id = ?'
|
||||
).get(journeyId, userId) as { role: 'editor' | 'viewer' } | undefined;
|
||||
myRole = contribRow?.role ?? null;
|
||||
}
|
||||
|
||||
return {
|
||||
...journey,
|
||||
entries: enrichedEntries,
|
||||
@@ -174,6 +187,7 @@ export function getJourneyFull(journeyId: number, userId: number) {
|
||||
contributors,
|
||||
stats: { entries: entryCount, photos: photoCount, places: places.length },
|
||||
hide_skeletons: !!(userPrefs?.hide_skeletons),
|
||||
my_role: myRole,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -184,7 +198,9 @@ export function updateJourney(journeyId: number, userId: number, data: Partial<{
|
||||
cover_image: string;
|
||||
status: string;
|
||||
}>): Journey | null {
|
||||
if (!canEdit(journeyId, userId)) return null;
|
||||
// Journey-level settings (title, cover, status) are owner-only — editors
|
||||
// may only edit entries and photos, not reshape the journey itself.
|
||||
if (!isOwner(journeyId, userId)) return null;
|
||||
|
||||
const ALLOWED_STATUSES = ['draft', 'active', 'completed', 'archived'];
|
||||
const allowed = ['title', 'subtitle', 'cover_gradient', 'cover_image', 'status'];
|
||||
@@ -615,6 +631,14 @@ export function deleteEntry(entryId: number, userId: number, sid?: string): bool
|
||||
|
||||
// ── Photos ───────────────────────────────────────────────────────────────
|
||||
|
||||
// Promote a skeleton suggestion to a concrete entry. Called whenever the user
|
||||
// adds content (photo upload, provider photo, gallery link) — a suggestion
|
||||
// with photos is no longer just a suggestion.
|
||||
function promoteSkeletonIfNeeded(entry: JourneyEntry): void {
|
||||
if (entry.type !== 'skeleton') return;
|
||||
db.prepare('UPDATE journey_entries SET type = ?, updated_at = ? WHERE id = ?').run('entry', ts(), entry.id);
|
||||
}
|
||||
|
||||
export function addPhoto(entryId: number, userId: number, filePath: string, thumbnailPath?: string, caption?: string): JourneyPhoto | null {
|
||||
const entry = db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entryId) as JourneyEntry | undefined;
|
||||
if (!entry) return null;
|
||||
@@ -629,6 +653,8 @@ export function addPhoto(entryId: number, userId: number, filePath: string, thum
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`).run(entryId, trekPhotoId, caption || null, (maxOrder?.m ?? -1) + 1, now);
|
||||
|
||||
promoteSkeletonIfNeeded(entry);
|
||||
|
||||
return db.prepare(`SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE jp.id = ?`).get(Number(res.lastInsertRowid)) as JourneyPhoto;
|
||||
}
|
||||
|
||||
@@ -651,6 +677,8 @@ export function addProviderPhoto(entryId: number, userId: number, provider: stri
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`).run(entryId, trekPhotoId, caption || null, (maxOrder?.m ?? -1) + 1, now);
|
||||
|
||||
promoteSkeletonIfNeeded(entry);
|
||||
|
||||
return db.prepare(`SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE jp.id = ?`).get(Number(res.lastInsertRowid)) as JourneyPhoto;
|
||||
}
|
||||
|
||||
@@ -664,21 +692,41 @@ export function linkPhotoToEntry(entryId: number, photoId: number, userId: numbe
|
||||
|
||||
if (source.entry_id === entryId) return source;
|
||||
|
||||
const oldEntryId = source.entry_id;
|
||||
const oldEntry = db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(source.entry_id) as JourneyEntry | undefined;
|
||||
const sourceIsGallery = oldEntry?.title === 'Gallery';
|
||||
|
||||
// move photo to the target entry
|
||||
db.prepare('UPDATE journey_photos SET entry_id = ? WHERE id = ?').run(entryId, photoId);
|
||||
// skip if target already has this photo (by trek_photo_id)
|
||||
const dupe = db.prepare('SELECT id FROM journey_photos WHERE entry_id = ? AND photo_id = ?').get(entryId, source.photo_id) as { id: number } | undefined;
|
||||
if (dupe) return db.prepare(`SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE jp.id = ?`).get(dupe.id) as JourneyPhoto;
|
||||
|
||||
// clean up: if old entry was a "Gallery" entry and is now empty, delete it
|
||||
const oldEntry = db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(oldEntryId) as JourneyEntry | undefined;
|
||||
if (oldEntry && oldEntry.title === 'Gallery') {
|
||||
const remaining = db.prepare('SELECT COUNT(*) as c FROM journey_photos WHERE entry_id = ?').get(oldEntryId) as { c: number };
|
||||
const maxOrder = db.prepare('SELECT MAX(sort_order) as m FROM journey_photos WHERE entry_id = ?').get(entryId) as { m: number | null };
|
||||
let resultId: number;
|
||||
|
||||
if (sourceIsGallery) {
|
||||
// Copy so the photo stays in the gallery even after being used in an entry.
|
||||
const res = db.prepare(`
|
||||
INSERT INTO journey_photos (entry_id, photo_id, caption, sort_order, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`).run(entryId, source.photo_id, source.caption || null, (maxOrder?.m ?? -1) + 1, ts());
|
||||
resultId = Number(res.lastInsertRowid);
|
||||
} else {
|
||||
// Non-gallery source: keep existing move behavior.
|
||||
db.prepare('UPDATE journey_photos SET entry_id = ? WHERE id = ?').run(entryId, photoId);
|
||||
resultId = photoId;
|
||||
}
|
||||
|
||||
promoteSkeletonIfNeeded(entry);
|
||||
|
||||
// If we moved out of a Gallery entry (shouldn't happen with the guard above,
|
||||
// but kept for any legacy data), clean up the Gallery wrapper if emptied.
|
||||
if (!sourceIsGallery && oldEntry && oldEntry.title === 'Gallery') {
|
||||
const remaining = db.prepare('SELECT COUNT(*) as c FROM journey_photos WHERE entry_id = ?').get(source.entry_id) as { c: number };
|
||||
if (remaining.c === 0) {
|
||||
db.prepare('DELETE FROM journey_entries WHERE id = ?').run(oldEntryId);
|
||||
db.prepare('DELETE FROM journey_entries WHERE id = ?').run(source.entry_id);
|
||||
}
|
||||
}
|
||||
|
||||
return db.prepare(`SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE jp.id = ?`).get(photoId) as JourneyPhoto;
|
||||
return db.prepare(`SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE jp.id = ?`).get(resultId) as JourneyPhoto;
|
||||
}
|
||||
|
||||
export function setPhotoProvider(photoId: number, provider: string, assetId: string, ownerId: number) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { db } from '../db/database';
|
||||
import crypto from 'crypto';
|
||||
import { isOwner } from './journeyService';
|
||||
|
||||
interface JourneySharePermissions {
|
||||
share_timeline?: boolean;
|
||||
@@ -19,7 +20,11 @@ export function createOrUpdateJourneyShareLink(
|
||||
journeyId: number,
|
||||
createdBy: number,
|
||||
permissions: JourneySharePermissions
|
||||
): { token: string; created: boolean } {
|
||||
): { token: string; created: boolean } | null {
|
||||
// Public sharing is an owner-only action — editors/viewers must not be
|
||||
// able to publish the journey or change which screens are shared.
|
||||
if (!isOwner(journeyId, createdBy)) return null;
|
||||
|
||||
const {
|
||||
share_timeline = true,
|
||||
share_gallery = true,
|
||||
@@ -51,8 +56,10 @@ export function getJourneyShareLink(journeyId: number): JourneyShareTokenInfo |
|
||||
};
|
||||
}
|
||||
|
||||
export function deleteJourneyShareLink(journeyId: number): void {
|
||||
export function deleteJourneyShareLink(journeyId: number, userId: number): boolean {
|
||||
if (!isOwner(journeyId, userId)) return false;
|
||||
db.prepare('DELETE FROM journey_share_tokens WHERE journey_id = ?').run(journeyId);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function validateShareTokenForPhoto(token: string, photoId: number): { journeyId: number; ownerId: number } | null {
|
||||
|
||||
@@ -25,12 +25,18 @@ export function isValidAssetId(id: string): boolean {
|
||||
|
||||
export function getConnectionSettings(userId: number) {
|
||||
const creds = getImmichCredentials(userId);
|
||||
const prefs = db.prepare('SELECT immich_auto_upload FROM users WHERE id = ?').get(userId) as { immich_auto_upload?: number } | undefined;
|
||||
return {
|
||||
immich_url: creds?.immich_url || '',
|
||||
connected: !!(creds?.immich_url && creds?.immich_api_key),
|
||||
auto_upload: !!(prefs?.immich_auto_upload),
|
||||
};
|
||||
}
|
||||
|
||||
export function setImmichAutoUpload(userId: number, enabled: boolean): void {
|
||||
db.prepare('UPDATE users SET immich_auto_upload = ? WHERE id = ?').run(enabled ? 1 : 0, userId);
|
||||
}
|
||||
|
||||
export async function saveImmichSettings(
|
||||
userId: number,
|
||||
immichUrl: string | undefined,
|
||||
|
||||
@@ -194,6 +194,37 @@ async function _getWeatherImpl(
|
||||
}
|
||||
}
|
||||
|
||||
// Past date: use archive API for the actual date
|
||||
if (diffDays < -1) {
|
||||
const dateStr = targetDate.toISOString().slice(0, 10);
|
||||
const url = `https://archive-api.open-meteo.com/v1/archive?latitude=${lat}&longitude=${lng}&start_date=${dateStr}&end_date=${dateStr}&daily=temperature_2m_max,temperature_2m_min,weathercode,precipitation_sum&timezone=auto`;
|
||||
const response = await fetch(url);
|
||||
const data = await response.json() as OpenMeteoForecast;
|
||||
|
||||
if (!response.ok || data.error) {
|
||||
throw new ApiError(response.status || 500, data.reason || 'Open-Meteo Archive API error');
|
||||
}
|
||||
|
||||
const daily = data.daily;
|
||||
if (daily && daily.time && daily.time.length > 0 && daily.temperature_2m_max[0] != null) {
|
||||
const code = daily.weathercode?.[0];
|
||||
const descriptions = lang === 'de' ? WMO_DESCRIPTION_DE : WMO_DESCRIPTION_EN;
|
||||
const tMax = daily.temperature_2m_max[0];
|
||||
const tMin = daily.temperature_2m_min[0];
|
||||
const result: WeatherResult = {
|
||||
temp: Math.round((tMax + tMin) / 2),
|
||||
temp_max: Math.round(tMax),
|
||||
temp_min: Math.round(tMin),
|
||||
main: WMO_MAP[code!] || estimateCondition((tMax + tMin) / 2, daily.precipitation_sum?.[0] || 0),
|
||||
description: descriptions[code!] || '',
|
||||
type: 'forecast',
|
||||
};
|
||||
setCache(ck, result, TTL_CLIMATE_MS);
|
||||
return result;
|
||||
}
|
||||
return { temp: 0, main: '', description: '', type: '', error: 'no_forecast' };
|
||||
}
|
||||
|
||||
// Climate / archive fallback (far-future dates)
|
||||
if (diffDays > -1) {
|
||||
const month = targetDate.getMonth() + 1;
|
||||
|
||||
@@ -318,7 +318,9 @@ describe('updateJourney', () => {
|
||||
expect(updated!.subtitle).toBe('New Sub');
|
||||
});
|
||||
|
||||
it('JOURNEY-SVC-019: editor contributor can update', () => {
|
||||
it('JOURNEY-SVC-019: editor contributor cannot update journey settings (#732)', () => {
|
||||
// Post-#732: journey-level settings (title/cover/status) are owner-only.
|
||||
// Editors keep access to entries and photos, but not the journey shell.
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: editor } = createUser(testDb);
|
||||
const journey = createJourney(testDb, owner.id, { title: 'Original' });
|
||||
@@ -326,8 +328,7 @@ describe('updateJourney', () => {
|
||||
|
||||
const updated = updateJourney(journey.id, editor.id, { title: 'Edited' });
|
||||
|
||||
expect(updated).not.toBeNull();
|
||||
expect(updated!.title).toBe('Edited');
|
||||
expect(updated).toBeNull();
|
||||
});
|
||||
|
||||
it('JOURNEY-SVC-020: viewer cannot update', () => {
|
||||
|
||||
@@ -176,13 +176,14 @@ describe('getJourneyShareLink', () => {
|
||||
});
|
||||
|
||||
describe('deleteJourneyShareLink', () => {
|
||||
it('JOURNEY-SHARE-007: removes an existing share link', () => {
|
||||
it('JOURNEY-SHARE-007: owner can remove an existing share link', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const journey = createJourney(testDb, user.id);
|
||||
createOrUpdateJourneyShareLink(journey.id, user.id, {});
|
||||
|
||||
deleteJourneyShareLink(journey.id);
|
||||
const ok = deleteJourneyShareLink(journey.id, user.id);
|
||||
|
||||
expect(ok).toBe(true);
|
||||
expect(getJourneyShareLink(journey.id)).toBeNull();
|
||||
});
|
||||
|
||||
@@ -190,7 +191,7 @@ describe('deleteJourneyShareLink', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const journey = createJourney(testDb, user.id);
|
||||
|
||||
expect(() => deleteJourneyShareLink(journey.id)).not.toThrow();
|
||||
expect(() => deleteJourneyShareLink(journey.id, user.id)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -282,13 +282,36 @@ describe('getWeather', () => {
|
||||
});
|
||||
|
||||
describe('with date — past date (diffDays < -1)', () => {
|
||||
it('returns no_forecast error immediately without fetching', async () => {
|
||||
it('returns forecast-type WeatherResult from the archive API', async () => {
|
||||
const date = dateOffset(-5); // 5 days in the past
|
||||
const archiveBody = {
|
||||
daily: {
|
||||
time: [date],
|
||||
temperature_2m_max: [18],
|
||||
temperature_2m_min: [10],
|
||||
weathercode: [2],
|
||||
precipitation_sum: [0],
|
||||
},
|
||||
};
|
||||
vi.mocked(fetch).mockResolvedValueOnce(mockResponse(archiveBody));
|
||||
|
||||
const result = await getWeather('14.00', '24.00', date, 'en');
|
||||
|
||||
expect(result.type).toBe('forecast');
|
||||
expect(result.temp).toBe(14);
|
||||
expect(result.temp_max).toBe(18);
|
||||
expect(result.temp_min).toBe(10);
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
expect(vi.mocked(fetch).mock.calls[0][0]).toContain('archive-api.open-meteo.com');
|
||||
});
|
||||
|
||||
it('returns no_forecast error when archive has no data for the date', async () => {
|
||||
const date = dateOffset(-5);
|
||||
vi.mocked(fetch).mockResolvedValueOnce(mockResponse({ daily: { time: [], temperature_2m_max: [], temperature_2m_min: [], weathercode: [] } }));
|
||||
|
||||
const result = await getWeather('14.01', '24.01', date, 'en');
|
||||
|
||||
expect(result.error).toBe('no_forecast');
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user