mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
Merge pull request #738 from mauriceboe/feat/visual-features
UI polish pass: animations, transitions, shared components
This commit is contained in:
Generated
-75
@@ -2367,9 +2367,6 @@
|
|||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2387,9 +2384,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2407,9 +2401,6 @@
|
|||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2427,9 +2418,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2447,9 +2435,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2467,9 +2452,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2487,9 +2469,6 @@
|
|||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2513,9 +2492,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2539,9 +2515,6 @@
|
|||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2565,9 +2538,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2591,9 +2561,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2617,9 +2584,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3399,9 +3363,6 @@
|
|||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3416,9 +3377,6 @@
|
|||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3433,9 +3391,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3450,9 +3405,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3467,9 +3419,6 @@
|
|||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3484,9 +3433,6 @@
|
|||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3501,9 +3447,6 @@
|
|||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3518,9 +3461,6 @@
|
|||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3535,9 +3475,6 @@
|
|||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3552,9 +3489,6 @@
|
|||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3569,9 +3503,6 @@
|
|||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3586,9 +3517,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3603,9 +3531,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
|
|||||||
href="https://ko-fi.com/mauriceboe"
|
href="https://ko-fi.com/mauriceboe"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
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' }}
|
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' }}
|
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' }}
|
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"
|
href="https://buymeacoffee.com/mauriceboe"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
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' }}
|
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' }}
|
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' }}
|
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"
|
href="https://discord.gg/NhZBDSd4qW"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
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' }}
|
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' }}
|
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' }}
|
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"
|
href="https://github.com/mauriceboe/TREK/issues/new?template=bug_report.yml"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
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' }}
|
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' }}
|
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' }}
|
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"
|
href="https://github.com/mauriceboe/TREK/discussions/new?category=feature-requests"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
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' }}
|
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' }}
|
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' }}
|
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"
|
href="https://github.com/mauriceboe/TREK/wiki"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
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' }}
|
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' }}
|
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' }}
|
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
||||||
|
|||||||
@@ -529,11 +529,14 @@ function PieChart({ segments, size = 200, totalLabel }: PieChartProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ position: 'relative', width: size, height: size, margin: '0 auto' }}>
|
<div style={{ position: 'relative', width: size, height: size, margin: '0 auto' }}>
|
||||||
<div style={{
|
<div
|
||||||
|
className="trek-pie-reveal"
|
||||||
|
style={{
|
||||||
width: size, height: size, borderRadius: '50%',
|
width: size, height: size, borderRadius: '50%',
|
||||||
background: `conic-gradient(${stops})`,
|
background: `conic-gradient(${stops})`,
|
||||||
boxShadow: '0 4px 24px rgba(0,0,0,0.08)',
|
boxShadow: '0 4px 24px rgba(0,0,0,0.08)',
|
||||||
}} />
|
}}
|
||||||
|
/>
|
||||||
<div style={{
|
<div style={{
|
||||||
position: 'absolute', top: '50%', left: '50%',
|
position: 'absolute', top: '50%', left: '50%',
|
||||||
transform: 'translate(-50%, -50%)',
|
transform: 'translate(-50%, -50%)',
|
||||||
|
|||||||
@@ -34,9 +34,21 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
|
|||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const [userMenuOpen, setUserMenuOpen] = useState<boolean>(false)
|
const [userMenuOpen, setUserMenuOpen] = useState<boolean>(false)
|
||||||
|
const [scrolled, setScrolled] = useState<boolean>(false)
|
||||||
const darkMode = settings.dark_mode
|
const darkMode = settings.dark_mode
|
||||||
const dark = darkMode === true || darkMode === 'dark' || (darkMode === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
|
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
|
// 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)
|
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 = () => {
|
const toggleDarkMode = () => {
|
||||||
|
document.documentElement.classList.add('trek-theme-transitioning')
|
||||||
updateSetting('dark_mode', dark ? 'light' : 'dark').catch(() => {})
|
updateSetting('dark_mode', dark ? 'light' : 'dark').catch(() => {})
|
||||||
|
window.setTimeout(() => {
|
||||||
|
document.documentElement.classList.remove('trek-theme-transitioning')
|
||||||
|
}, 360)
|
||||||
}
|
}
|
||||||
|
|
||||||
const getAddonName = (addon: Addon): string => {
|
const getAddonName = (addon: Addon): string => {
|
||||||
@@ -61,23 +77,29 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<nav style={{
|
<nav style={{
|
||||||
background: dark ? 'rgba(9,9,11,0.95)' : 'rgba(255,255,255,0.95)',
|
background: dark
|
||||||
backdropFilter: 'blur(20px)', WebkitBackdropFilter: 'blur(20px)',
|
? (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)'}`,
|
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',
|
touchAction: 'manipulation',
|
||||||
paddingTop: 'env(safe-area-inset-top, 0px)',
|
paddingTop: 'env(safe-area-inset-top, 0px)',
|
||||||
height: 'var(--nav-h)',
|
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]">
|
}} className="hidden md:flex items-center px-4 gap-4 fixed top-0 left-0 right-0 z-[200]">
|
||||||
{/* Left side */}
|
{/* Left side */}
|
||||||
<div className="flex items-center gap-3 min-w-0">
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
{showBack && (
|
{showBack && (
|
||||||
<button onClick={onBack}
|
<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)' }}
|
style={{ color: 'var(--text-muted)' }}
|
||||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||||
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
|
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>
|
<span className="hidden sm:inline">{t('common.back')}</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -161,11 +183,14 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
|
|||||||
|
|
||||||
{/* Dark mode toggle (light ↔ dark, overrides auto) — hidden on mobile */}
|
{/* Dark mode toggle (light ↔ dark, overrides auto) — hidden on mobile */}
|
||||||
<button onClick={toggleDarkMode} title={dark ? t('nav.lightMode') : t('nav.darkMode')}
|
<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)' }}
|
style={{ color: 'var(--text-muted)' }}
|
||||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||||
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
|
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>
|
</button>
|
||||||
|
|
||||||
{/* Notification bell — only in trip view on mobile, everywhere on desktop */}
|
{/* 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(
|
{userMenuOpen && ReactDOM.createPortal(
|
||||||
<>
|
<>
|
||||||
<div style={{ position: 'fixed', inset: 0, zIndex: 9998 }} onClick={() => setUserMenuOpen(false)} />
|
<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)' }}>
|
<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-sm font-medium" style={{ color: 'var(--text-primary)' }}>{user.username}</p>
|
||||||
<p className="text-xs truncate" style={{ color: 'var(--text-muted)' }}>{user.email}</p>
|
<p className="text-xs truncate" style={{ color: 'var(--text-muted)' }}>{user.email}</p>
|
||||||
|
|||||||
@@ -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={{
|
<button onClick={handleToggle} style={{
|
||||||
flexShrink: 0, background: 'none', border: 'none', cursor: 'pointer', padding: 0, display: 'flex',
|
flexShrink: 0, background: 'none', border: 'none', cursor: 'pointer', padding: 0, position: 'relative',
|
||||||
color: item.checked ? '#10b981' : 'var(--text-faint)', transition: 'color 0.15s',
|
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>
|
</button>
|
||||||
|
|
||||||
{editing && canEdit ? (
|
{editing && canEdit ? (
|
||||||
@@ -274,6 +287,7 @@ function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingE
|
|||||||
flex: 1, fontSize: 13.5,
|
flex: 1, fontSize: 13.5,
|
||||||
cursor: !canEdit || item.checked ? 'default' : 'text',
|
cursor: !canEdit || item.checked ? 'default' : 'text',
|
||||||
color: item.checked ? 'var(--text-faint)' : 'var(--text-primary)',
|
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',
|
textDecoration: item.checked ? 'line-through' : 'none',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -730,10 +744,12 @@ interface PackingListPanelProps {
|
|||||||
tripId: number
|
tripId: number
|
||||||
items: PackingItem[]
|
items: PackingItem[]
|
||||||
openImportSignal?: number
|
openImportSignal?: number
|
||||||
|
clearCheckedSignal?: number
|
||||||
|
saveTemplateSignal?: number
|
||||||
inlineHeader?: boolean
|
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 [filter, setFilter] = useState('alle') // 'alle' | 'offen' | 'erledigt'
|
||||||
const [addingCategory, setAddingCategory] = useState(false)
|
const [addingCategory, setAddingCategory] = useState(false)
|
||||||
const [newCatName, setNewCatName] = useState('')
|
const [newCatName, setNewCatName] = useState('')
|
||||||
@@ -899,6 +915,8 @@ export default function PackingListPanel({ tripId, items, openImportSignal = 0,
|
|||||||
const [showImportModal, setShowImportModal] = useState(false)
|
const [showImportModal, setShowImportModal] = useState(false)
|
||||||
const [importText, setImportText] = useState('')
|
const [importText, setImportText] = useState('')
|
||||||
const lastHandledImportSignal = useRef(openImportSignal)
|
const lastHandledImportSignal = useRef(openImportSignal)
|
||||||
|
const lastHandledClearSignal = useRef(clearCheckedSignal)
|
||||||
|
const lastHandledSaveSignal = useRef(saveTemplateSignal)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (openImportSignal !== lastHandledImportSignal.current && openImportSignal > 0) {
|
if (openImportSignal !== lastHandledImportSignal.current && openImportSignal > 0) {
|
||||||
@@ -906,6 +924,21 @@ export default function PackingListPanel({ tripId, items, openImportSignal = 0,
|
|||||||
}
|
}
|
||||||
lastHandledImportSignal.current = openImportSignal
|
lastHandledImportSignal.current = openImportSignal
|
||||||
}, [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 csvInputRef = useRef<HTMLInputElement>(null)
|
||||||
const templateDropdownRef = useRef<HTMLDivElement>(null)
|
const templateDropdownRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
@@ -1020,14 +1053,22 @@ export default function PackingListPanel({ tripId, items, openImportSignal = 0,
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : <span />}
|
||||||
items.length > 0 ? (
|
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', justifyContent: 'flex-end' }}>
|
||||||
<p style={{ margin: 0, fontSize: 12.5, color: 'var(--text-faint)' }}>
|
{canEdit && items.length > 0 && showSaveTemplate && (
|
||||||
{t('packing.progress', { packed: abgehakt, total: items.length, percent: fortschritt })}
|
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||||
</p>
|
<input
|
||||||
) : <span />
|
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>
|
||||||
)}
|
)}
|
||||||
<div style={{ display: 'flex', gap: 6 }}>
|
|
||||||
{inlineHeader && canEdit && (
|
{inlineHeader && canEdit && (
|
||||||
<button onClick={() => setShowImportModal(true)} style={{
|
<button onClick={() => setShowImportModal(true)} style={{
|
||||||
display: 'flex', alignItems: 'center', gap: 5, padding: '5px 11px', borderRadius: 99,
|
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>
|
<Upload size={12} /> <span className="hidden sm:inline">{t('packing.import')}</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{canEdit && abgehakt > 0 && (
|
{inlineHeader && canEdit && abgehakt > 0 && (
|
||||||
<button onClick={handleClearChecked} style={{
|
<button onClick={handleClearChecked} style={{
|
||||||
fontSize: 11.5, padding: '5px 10px', borderRadius: 99, border: '1px solid rgba(239,68,68,0.3)',
|
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',
|
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>
|
<span className="sm:hidden">{t('packing.clearCheckedShort', { count: abgehakt })}</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{canEdit && availableTemplates.length > 0 && (
|
{inlineHeader && canEdit && availableTemplates.length > 0 && (
|
||||||
<div ref={templateDropdownRef} style={{ position: 'relative' }}>
|
<div ref={templateDropdownRef} style={{ position: 'relative' }}>
|
||||||
<button onClick={() => setShowTemplateDropdown(v => !v)} disabled={applyingTemplate} style={{
|
<button onClick={() => setShowTemplateDropdown(v => !v)} disabled={applyingTemplate} style={{
|
||||||
display: 'flex', alignItems: 'center', gap: 5, padding: '5px 11px', borderRadius: 99,
|
display: 'flex', alignItems: 'center', gap: 5, padding: '5px 11px', borderRadius: 99,
|
||||||
@@ -1085,22 +1126,7 @@ export default function PackingListPanel({ tripId, items, openImportSignal = 0,
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{canEdit && items.length > 0 && (
|
{inlineHeader && canEdit && items.length > 0 && !showSaveTemplate && (
|
||||||
<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={{
|
<button onClick={() => setShowSaveTemplate(true)} style={{
|
||||||
display: 'flex', alignItems: 'center', gap: 5, padding: '5px 11px', borderRadius: 99,
|
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',
|
border: '1px solid var(--border-primary)', fontSize: 12, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit',
|
||||||
@@ -1109,8 +1135,6 @@ export default function PackingListPanel({ tripId, items, openImportSignal = 0,
|
|||||||
<FolderPlus size={12} /> <span className="hidden sm:inline">{t('packing.saveAsTemplate')}</span>
|
<FolderPlus size={12} /> <span className="hidden sm:inline">{t('packing.saveAsTemplate')}</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{bagTrackingEnabled && (
|
{bagTrackingEnabled && (
|
||||||
<button onClick={() => setShowBagModal(true)} className="xl:!hidden"
|
<button onClick={() => setShowBagModal(true)} className="xl:!hidden"
|
||||||
style={{
|
style={{
|
||||||
@@ -1127,17 +1151,69 @@ export default function PackingListPanel({ tripId, items, openImportSignal = 0,
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{items.length > 0 && (
|
{items.length > 0 && (
|
||||||
<div style={{ marginBottom: 14 }}>
|
<div className="hidden sm:block" style={{ marginTop: 14, marginBottom: 14 }}>
|
||||||
<div style={{ height: 5, background: 'var(--bg-tertiary)', borderRadius: 99, overflow: 'hidden' }}>
|
<div className="flex items-center" style={{ gap: 14 }}>
|
||||||
|
{fortschritt === 100 ? (
|
||||||
<div style={{
|
<div style={{
|
||||||
height: '100%', borderRadius: 99, transition: 'width 0.4s ease',
|
display: 'flex', alignItems: 'center', gap: 8,
|
||||||
background: fortschritt === 100 ? '#10b981' : 'linear-gradient(90deg, var(--text-primary) 0%, var(--text-muted) 100%)',
|
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={{
|
||||||
|
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}%`,
|
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>
|
||||||
{fortschritt === 100 && (
|
</div>
|
||||||
<p style={{ fontSize: 11.5, color: '#10b981', marginTop: 4, fontWeight: 600, margin: '4px 0 0' }}>{t('packing.allPacked')}</p>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1106,7 +1106,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tagesliste */}
|
{/* 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) => {
|
{days.map((day, index) => {
|
||||||
const isSelected = selectedDayId === day.id
|
const isSelected = selectedDayId === day.id
|
||||||
const isExpanded = expandedDays.has(day.id)
|
const isExpanded = expandedDays.has(day.id)
|
||||||
@@ -1575,7 +1575,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
|||||||
border: 'none',
|
border: 'none',
|
||||||
background: active ? '#3b82f6' : 'transparent',
|
background: active ? '#3b82f6' : 'transparent',
|
||||||
color: active ? '#fff' : 'var(--text-faint)',
|
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)' }}
|
onMouseEnter={e => { if (!active) e.currentTarget.style.color = 'var(--text-primary)' }}
|
||||||
onMouseLeave={e => { if (!active) e.currentTarget.style.color = 'var(--text-faint)' }}
|
onMouseLeave={e => { if (!active) e.currentTarget.style.color = 'var(--text-faint)' }}
|
||||||
@@ -1598,7 +1598,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
|||||||
display: 'grid', placeItems: 'center', cursor: 'pointer',
|
display: 'grid', placeItems: 'center', cursor: 'pointer',
|
||||||
border: 'none', background: 'transparent',
|
border: 'none', background: 'transparent',
|
||||||
color: 'var(--text-faint)',
|
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)' }}
|
onMouseEnter={e => { e.currentTarget.style.color = 'var(--text-primary)' }}
|
||||||
onMouseLeave={e => { e.currentTarget.style.color = 'var(--text-faint)' }}
|
onMouseLeave={e => { e.currentTarget.style.color = 'var(--text-faint)' }}
|
||||||
@@ -1808,7 +1808,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
|||||||
border: 'none',
|
border: 'none',
|
||||||
background: active ? color : 'transparent',
|
background: active ? color : 'transparent',
|
||||||
color: active ? '#fff' : 'var(--text-faint)',
|
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)' }}
|
onMouseEnter={e => { if (!active) e.currentTarget.style.color = 'var(--text-primary)' }}
|
||||||
onMouseLeave={e => { if (!active) e.currentTarget.style.color = 'var(--text-faint)' }}
|
onMouseLeave={e => { if (!active) e.currentTarget.style.color = 'var(--text-faint)' }}
|
||||||
|
|||||||
@@ -374,6 +374,7 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
|
|||||||
<MapPin size={11} strokeWidth={2} /> {t(hasMultipleListImportProviders ? 'places.importList' : 'places.importGoogleList')}
|
<MapPin size={11} strokeWidth={2} /> {t(hasMultipleListImportProviders ? 'places.importList' : 'places.importGoogleList')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div style={{ height: 1, background: 'var(--border-primary)', margin: '2px 0 10px' }} />
|
||||||
</>}
|
</>}
|
||||||
|
|
||||||
{/* Filter-Tabs */}
|
{/* Filter-Tabs */}
|
||||||
@@ -635,7 +636,7 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Liste */}
|
{/* Liste */}
|
||||||
<div style={{ flex: 1, overflowY: 'auto', minHeight: 0 }}>
|
<div className="trek-stagger" style={{ flex: 1, overflowY: 'auto', minHeight: 0 }}>
|
||||||
{filtered.length === 0 ? (
|
{filtered.length === 0 ? (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', padding: '40px 16px', gap: 8 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', padding: '40px 16px', gap: 8 }}>
|
||||||
<span style={{ fontSize: 13, color: 'var(--text-faint)' }}>
|
<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"
|
href="https://ko-fi.com/mauriceboe"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
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' }}
|
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' }}
|
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' }}
|
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"
|
href="https://buymeacoffee.com/mauriceboe"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
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' }}
|
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' }}
|
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' }}
|
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"
|
href="https://discord.gg/NhZBDSd4qW"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
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' }}
|
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' }}
|
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' }}
|
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"
|
href="https://github.com/mauriceboe/TREK/issues/new?template=bug_report.yml"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
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' }}
|
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' }}
|
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' }}
|
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"
|
href="https://github.com/mauriceboe/TREK/discussions/new?category=feature-requests"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
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' }}
|
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' }}
|
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' }}
|
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"
|
href="https://github.com/mauriceboe/TREK/wiki"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
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' }}
|
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' }}
|
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' }}
|
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
||||||
|
|||||||
@@ -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)' }}>
|
<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
|
<button
|
||||||
onClick={() => setCompanyMode(false)}
|
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={{
|
style={{
|
||||||
background: !companyMode ? 'var(--text-primary)' : 'transparent',
|
background: !companyMode ? 'var(--text-primary)' : 'transparent',
|
||||||
color: !companyMode ? 'var(--bg-card)' : 'var(--text-muted)',
|
color: !companyMode ? 'var(--bg-card)' : 'var(--text-muted)',
|
||||||
@@ -107,7 +107,7 @@ export default function VacayCalendar() {
|
|||||||
{companyHolidaysEnabled && (
|
{companyHolidaysEnabled && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setCompanyMode(true)}
|
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={{
|
style={{
|
||||||
background: companyMode ? '#d97706' : 'transparent',
|
background: companyMode ? '#d97706' : 'transparent',
|
||||||
color: companyMode ? '#fff' : 'var(--text-muted)',
|
color: companyMode ? '#fff' : 'var(--text-muted)',
|
||||||
|
|||||||
@@ -121,9 +121,9 @@ export default function VacayPersons() {
|
|||||||
|
|
||||||
{/* Invite Modal — Portal to body to avoid z-index issues */}
|
{/* Invite Modal — Portal to body to avoid z-index issues */}
|
||||||
{showInvite && ReactDOM.createPortal(
|
{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)}>
|
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()}>
|
onClick={e => e.stopPropagation()}>
|
||||||
<div className="flex items-center justify-between p-5" style={{ borderBottom: '1px solid var(--border-secondary)' }}>
|
<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>
|
<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 */}
|
{/* Color Picker Modal — Portal to body */}
|
||||||
{showColorPicker && ReactDOM.createPortal(
|
{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) }}>
|
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()}>
|
onClick={e => e.stopPropagation()}>
|
||||||
<div className="flex items-center justify-between p-5" style={{ borderBottom: '1px solid var(--border-secondary)' }}>
|
<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>
|
<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">
|
<div className="flex flex-wrap gap-2 justify-center">
|
||||||
{PRESET_COLORS.map(c => (
|
{PRESET_COLORS.map(c => (
|
||||||
<button key={c} onClick={() => handleColorChange(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 }} />
|
style={{ backgroundColor: c }} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</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>
|
<span className="text-[10px] tabular-nums" style={{ color: 'var(--text-faint)' }}>{s.used}/{s.total_available}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-1.5 rounded-full overflow-hidden" style={{ background: 'var(--bg-secondary)' }}>
|
<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>
|
||||||
<div className="grid grid-cols-3 gap-1.5">
|
<div className="grid grid-cols-3 gap-1.5">
|
||||||
{/* Days — editable */}
|
{/* Days — editable */}
|
||||||
|
|||||||
@@ -40,16 +40,13 @@ export default function ConfirmDialog({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<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)' }}
|
style={{ backgroundColor: 'rgba(15, 23, 42, 0.5)' }}
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="rounded-2xl shadow-2xl w-full max-w-sm p-6"
|
className="trek-modal-enter rounded-2xl shadow-2xl w-full max-w-sm p-6"
|
||||||
style={{
|
style={{ background: 'var(--bg-card)' }}
|
||||||
animation: 'modalIn 0.2s ease-out forwards',
|
|
||||||
background: 'var(--bg-card)',
|
|
||||||
}}
|
|
||||||
onClick={e => e.stopPropagation()}
|
onClick={e => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<div className="flex items-start gap-4">
|
<div className="flex items-start gap-4">
|
||||||
@@ -90,12 +87,6 @@ export default function ConfirmDialog({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>{`
|
|
||||||
@keyframes modalIn {
|
|
||||||
from { opacity: 0; transform: scale(0.95) translateY(-10px); }
|
|
||||||
to { opacity: 1; transform: scale(1) translateY(0); }
|
|
||||||
}
|
|
||||||
`}</style>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ export function ContextMenu({ menu, onClose }: ContextMenuProps) {
|
|||||||
if (!menu) return null
|
if (!menu) return null
|
||||||
|
|
||||||
return ReactDOM.createPortal(
|
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,
|
position: 'fixed', left: menu.x, top: menu.y, zIndex: 999999,
|
||||||
background: 'var(--bg-card)', borderRadius: 10, padding: '4px',
|
background: 'var(--bg-card)', borderRadius: 10, padding: '4px',
|
||||||
border: '1px solid var(--border-primary)',
|
border: '1px solid var(--border-primary)',
|
||||||
@@ -73,7 +73,7 @@ export function ContextMenu({ menu, onClose }: ContextMenuProps) {
|
|||||||
backdropFilter: 'blur(20px)', WebkitBackdropFilter: 'blur(20px)',
|
backdropFilter: 'blur(20px)', WebkitBackdropFilter: 'blur(20px)',
|
||||||
minWidth: 160,
|
minWidth: 160,
|
||||||
fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif",
|
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) => {
|
{menu.items.filter(Boolean).map((item, i) => {
|
||||||
if (item.divider) return <div key={i} style={{ height: 1, background: 'var(--border-faint)', margin: '3px 6px' }} />
|
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>
|
</button>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
<style>{`@keyframes ctxIn { from { opacity: 0; transform: scale(0.95) } to { opacity: 1; transform: scale(1) } }`}</style>
|
|
||||||
</div>,
|
</div>,
|
||||||
document.body
|
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)' }}>
|
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', color: selected ? 'var(--text-primary)' : 'var(--text-faint)' }}>
|
||||||
{selected ? selected.label : placeholder}
|
{selected ? selected.label : placeholder}
|
||||||
</span>
|
</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>
|
</button>
|
||||||
|
|
||||||
{/* Dropdown */}
|
{/* Dropdown */}
|
||||||
@@ -128,7 +128,9 @@ export default function CustomSelect({
|
|||||||
borderRadius: 10,
|
borderRadius: 10,
|
||||||
boxShadow: '0 8px 32px rgba(0,0,0,0.12)',
|
boxShadow: '0 8px 32px rgba(0,0,0,0.12)',
|
||||||
overflow: 'hidden',
|
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 */}
|
{/* Search */}
|
||||||
{searchable && (
|
{searchable && (
|
||||||
@@ -194,12 +196,6 @@ export default function CustomSelect({
|
|||||||
document.body
|
document.body
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<style>{`
|
|
||||||
@keyframes selectIn {
|
|
||||||
from { opacity: 0; transform: translateY(-4px); }
|
|
||||||
to { opacity: 1; transform: translateY(0); }
|
|
||||||
}
|
|
||||||
`}</style>
|
|
||||||
</div>
|
</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 (
|
return (
|
||||||
<div
|
<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' }}
|
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 }}
|
onMouseDown={e => { mouseDownTarget.current = e.target }}
|
||||||
onClick={e => {
|
onClick={e => {
|
||||||
@@ -60,14 +60,11 @@ export default function Modal({
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={`
|
className={`
|
||||||
|
trek-modal-enter
|
||||||
rounded-2xl shadow-2xl w-full ${sizeClasses[size] || sizeClasses.md}
|
rounded-2xl shadow-2xl w-full ${sizeClasses[size] || sizeClasses.md}
|
||||||
flex flex-col max-h-[calc(100vh-180px)] md:max-h-[calc(100vh-90px)]
|
flex flex-col max-h-[calc(100vh-180px)] md:max-h-[calc(100vh-90px)]
|
||||||
animate-in fade-in zoom-in-95 duration-200
|
|
||||||
`}
|
`}
|
||||||
style={{
|
style={{ background: 'var(--bg-card)' }}
|
||||||
animation: 'modalIn 0.2s ease-out forwards',
|
|
||||||
background: 'var(--bg-card)',
|
|
||||||
}}
|
|
||||||
onClick={e => e.stopPropagation()}
|
onClick={e => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -96,12 +93,6 @@ export default function Modal({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>{`
|
|
||||||
@keyframes modalIn {
|
|
||||||
from { opacity: 0; transform: scale(0.95) translateY(-10px); }
|
|
||||||
to { opacity: 1; transform: scale(1) translateY(0); }
|
|
||||||
}
|
|
||||||
`}</style>
|
|
||||||
</div>
|
</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
|
||||||
@@ -68,6 +68,7 @@ export function Tooltip({ label, placement = 'bottom', delay = 250, disabled, ch
|
|||||||
<div
|
<div
|
||||||
ref={tooltipRef}
|
ref={tooltipRef}
|
||||||
role="tooltip"
|
role="tooltip"
|
||||||
|
className="trek-popover-enter"
|
||||||
style={{
|
style={{
|
||||||
position: 'fixed',
|
position: 'fixed',
|
||||||
top: coords?.top ?? -9999,
|
top: coords?.top ?? -9999,
|
||||||
@@ -85,6 +86,7 @@ export function Tooltip({ label, placement = 'bottom', delay = 250, disabled, ch
|
|||||||
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
|
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
|
||||||
border: '1px solid var(--border-faint, #e5e7eb)',
|
border: '1px solid var(--border-faint, #e5e7eb)',
|
||||||
fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif",
|
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}
|
{label}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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; }
|
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 {
|
.atlas-tooltip {
|
||||||
background: rgba(10, 10, 20, 0.6) !important;
|
background: rgba(10, 10, 20, 0.6) !important;
|
||||||
backdrop-filter: blur(20px) saturate(180%) !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); }
|
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 ─────────────────────────────── */
|
/* ── Design tokens ─────────────────────────────── */
|
||||||
:root {
|
: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);
|
--safe-top: env(safe-area-inset-top, 0px);
|
||||||
--nav-h: 0px;
|
--nav-h: 0px;
|
||||||
--bottom-nav-h: 0px;
|
--bottom-nav-h: 0px;
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { getApiErrorMessage } from '../types'
|
|||||||
import Navbar from '../components/Layout/Navbar'
|
import Navbar from '../components/Layout/Navbar'
|
||||||
import Modal from '../components/shared/Modal'
|
import Modal from '../components/shared/Modal'
|
||||||
import { useToast } from '../components/shared/Toast'
|
import { useToast } from '../components/shared/Toast'
|
||||||
|
import { useCountUp } from '../hooks/useCountUp'
|
||||||
import CategoryManager from '../components/Admin/CategoryManager'
|
import CategoryManager from '../components/Admin/CategoryManager'
|
||||||
import BackupPanel from '../components/Admin/BackupPanel'
|
import BackupPanel from '../components/Admin/BackupPanel'
|
||||||
import GitHubPanel from '../components/Admin/GitHubPanel'
|
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 {
|
export default function AdminPage(): React.ReactElement {
|
||||||
const { demoMode, serverTimezone } = useAuthStore()
|
const { demoMode, serverTimezone } = useAuthStore()
|
||||||
const { t, locale } = useTranslation()
|
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.places'), value: stats.totalPlaces, icon: Map },
|
||||||
{ label: t('admin.stats.files'), value: stats.totalFiles || 0, icon: FileText },
|
{ label: t('admin.stats.files'), value: stats.totalFiles || 0, icon: FileText },
|
||||||
].map(({ label, value, icon: Icon }) => (
|
].map(({ label, value, icon: Icon }) => (
|
||||||
<div key={label} className="rounded-xl border p-4" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
|
<AdminStatCard key={label} label={label} value={value} icon={Icon} />
|
||||||
<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>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -629,7 +637,7 @@ export default function AdminPage(): React.ReactElement {
|
|||||||
<th className="px-5 py-3 text-right">{t('admin.table.actions')}</th>
|
<th className="px-5 py-3 text-right">{t('admin.table.actions')}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-slate-100">
|
<tbody className="divide-y divide-slate-100 trek-stagger">
|
||||||
{users.map(u => (
|
{users.map(u => (
|
||||||
<tr key={u.id} className={`hover:bg-slate-50 transition-colors ${u.id === currentUser?.id ? 'bg-slate-50/60' : ''}`}>
|
<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">
|
<td className="px-5 py-3">
|
||||||
|
|||||||
@@ -938,7 +938,7 @@ export default function AtlasPage(): React.ReactElement {
|
|||||||
ref={panelRef}
|
ref={panelRef}
|
||||||
onMouseMove={handlePanelMouseMove}
|
onMouseMove={handlePanelMouseMove}
|
||||||
onMouseLeave={handlePanelMouseLeave}
|
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={{
|
style={{
|
||||||
bottom: 16,
|
bottom: 16,
|
||||||
left: '50%',
|
left: '50%',
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import TimezoneWidget from '../components/Dashboard/TimezoneWidget'
|
|||||||
import TripFormModal from '../components/Trips/TripFormModal'
|
import TripFormModal from '../components/Trips/TripFormModal'
|
||||||
import ConfirmDialog from '../components/shared/ConfirmDialog'
|
import ConfirmDialog from '../components/shared/ConfirmDialog'
|
||||||
import { useToast } from '../components/shared/Toast'
|
import { useToast } from '../components/shared/Toast'
|
||||||
|
import { useCountUp } from '../hooks/useCountUp'
|
||||||
import {
|
import {
|
||||||
Plus, Calendar, Trash2, Edit2, Map, ChevronDown, ChevronUp,
|
Plus, Calendar, Trash2, Edit2, Map, ChevronDown, ChevronUp,
|
||||||
Archive, ArchiveRestore, Clock, MapPin, Settings, X, ArrowRightLeft, Users,
|
Archive, ArchiveRestore, Clock, MapPin, Settings, X, ArrowRightLeft, Users,
|
||||||
@@ -152,6 +153,28 @@ interface TripCardProps {
|
|||||||
dark?: boolean
|
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 {
|
function SpotlightCard({ trip, onEdit, onCopy, onDelete, onArchive, onClick, t, locale }: TripCardProps): React.ReactElement {
|
||||||
const status = getTripStatus(trip)
|
const status = getTripStatus(trip)
|
||||||
const isLive = status === 'ongoing'
|
const isLive = status === 'ongoing'
|
||||||
@@ -173,16 +196,16 @@ function SpotlightCard({ trip, onEdit, onCopy, onDelete, onArchive, onClick, t,
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
onClick={() => onClick(trip)}
|
onClick={() => onClick(trip)}
|
||||||
className="group relative rounded-3xl overflow-hidden cursor-pointer mb-8"
|
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)' }}
|
style={{ minHeight: 340, boxShadow: '0 8px 40px rgba(0,0,0,0.13)', isolation: 'isolate' }}
|
||||||
>
|
>
|
||||||
{/* Background */}
|
{/* 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),
|
background: trip.cover_image ? undefined : tripGradient(trip.id),
|
||||||
}}>
|
}}>
|
||||||
{trip.cover_image && (
|
{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%)' }} />
|
<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>
|
<span className="opacity-70">{t('dashboard.mobile.daysLeft', { count: daysLeft })}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-1.5 bg-white/15 rounded-full overflow-hidden">
|
<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)]" />
|
<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>
|
||||||
</div>
|
</div>
|
||||||
@@ -241,20 +271,7 @@ function SpotlightCard({ trip, onEdit, onCopy, onDelete, onArchive, onClick, t,
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Stats */}
|
{/* 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">
|
<SpotlightStats trip={trip} totalDays={totalDays} t={t} />
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -278,13 +295,13 @@ function MobileTripCard({ trip, onEdit, onCopy, onDelete, onArchive, onClick, t,
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
onClick={() => onClick?.(trip)}
|
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"
|
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)' }}
|
style={{ background: 'var(--bg-card)', isolation: 'isolate' }}
|
||||||
>
|
>
|
||||||
{/* Cover */}
|
{/* Cover */}
|
||||||
<div className="relative h-[120px] overflow-hidden" style={{ background: trip.cover_image ? undefined : tripGradient(trip.id) }}>
|
<div className="relative h-[120px] overflow-hidden" style={{ background: trip.cover_image ? undefined : tripGradient(trip.id) }}>
|
||||||
{trip.cover_image && (
|
{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%)' }} />
|
<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 (
|
return (
|
||||||
<div
|
<div
|
||||||
onClick={() => onClick(trip)}
|
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"
|
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)' }}
|
style={{ background: 'var(--bg-card)', isolation: 'isolate' }}
|
||||||
>
|
>
|
||||||
{/* Cover */}
|
{/* Cover */}
|
||||||
<div className="relative h-[140px] overflow-hidden" style={{ background: trip.cover_image ? undefined : tripGradient(trip.id) }}>
|
<div className="relative h-[140px] overflow-hidden" style={{ background: trip.cover_image ? undefined : tripGradient(trip.id) }}>
|
||||||
{trip.cover_image && (
|
{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%)' }} />
|
<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 ─────────────────────────────────────────────────────────────────
|
// ── Skeleton ─────────────────────────────────────────────────────────────────
|
||||||
function SkeletonCard(): React.ReactElement {
|
function SkeletonCard(): React.ReactElement {
|
||||||
return (
|
return (
|
||||||
<div style={{ background: 'white', borderRadius: 16, overflow: 'hidden', border: '1px solid #f3f4f6' }}>
|
<div
|
||||||
<div style={{ height: 120, background: '#f3f4f6', animation: 'pulse 1.5s ease-in-out infinite' }} />
|
className="rounded-2xl border border-zinc-200 dark:border-zinc-700 overflow-hidden"
|
||||||
<div style={{ padding: '12px 14px 14px' }}>
|
style={{ background: 'var(--bg-card)' }}
|
||||||
<div style={{ height: 14, background: '#f3f4f6', borderRadius: 6, marginBottom: 8, width: '70%' }} />
|
>
|
||||||
<div style={{ height: 11, background: '#f3f4f6', borderRadius: 6, width: '50%' }} />
|
<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>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -958,10 +978,8 @@ export default function DashboardPage(): React.ReactElement {
|
|||||||
padding: '9px 14px', borderRadius: 10, fontSize: 13, fontWeight: 500,
|
padding: '9px 14px', borderRadius: 10, fontSize: 13, fontWeight: 500,
|
||||||
background: 'var(--accent)', color: 'var(--accent-text)', flexShrink: 0,
|
background: 'var(--accent)', color: 'var(--accent-text)', flexShrink: 0,
|
||||||
marginLeft: 2,
|
marginLeft: 2,
|
||||||
transition: 'opacity 0.15s ease',
|
|
||||||
}}
|
}}
|
||||||
onMouseEnter={e => e.currentTarget.style.opacity = '0.88'}
|
className="hover:opacity-[0.88]"
|
||||||
onMouseLeave={e => e.currentTarget.style.opacity = '1'}
|
|
||||||
>
|
>
|
||||||
<Plus size={14} strokeWidth={2.5} /> {t('dashboard.newTrip')}
|
<Plus size={14} strokeWidth={2.5} /> {t('dashboard.newTrip')}
|
||||||
</button>
|
</button>
|
||||||
@@ -1004,7 +1022,7 @@ export default function DashboardPage(): React.ReactElement {
|
|||||||
{/* Loading skeletons */}
|
{/* Loading skeletons */}
|
||||||
{isLoading && (
|
{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 }}>
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: 16 }}>
|
||||||
{[1, 2, 3].map(i => <SkeletonCard key={i} />)}
|
{[1, 2, 3].map(i => <SkeletonCard key={i} />)}
|
||||||
</div>
|
</div>
|
||||||
@@ -1070,7 +1088,7 @@ export default function DashboardPage(): React.ReactElement {
|
|||||||
{/* Trips — desktop grid or list */}
|
{/* Trips — desktop grid or list */}
|
||||||
{!isLoading && (viewMode === 'grid' ? rest : trips).length > 0 && (
|
{!isLoading && (viewMode === 'grid' ? rest : trips).length > 0 && (
|
||||||
viewMode === 'grid' ? (
|
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 => (
|
{rest.map(trip => (
|
||||||
<TripCard
|
<TripCard
|
||||||
key={trip.id}
|
key={trip.id}
|
||||||
@@ -1085,7 +1103,7 @@ export default function DashboardPage(): React.ReactElement {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</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 => (
|
{trips.map(trip => (
|
||||||
<TripListItem
|
<TripListItem
|
||||||
key={trip.id}
|
key={trip.id}
|
||||||
|
|||||||
@@ -437,7 +437,7 @@ export default function JourneyDetailPage() {
|
|||||||
const locations = [...new Set(entries.map(e => e.location_name).filter(Boolean))]
|
const locations = [...new Set(entries.map(e => e.location_name).filter(Boolean))]
|
||||||
|
|
||||||
return (
|
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="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="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">
|
<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">
|
||||||
@@ -1251,7 +1251,7 @@ function EntryCard({ entry, onEdit, onDelete, onPhotoClick }: {
|
|||||||
const hasProscons = prosArr.length > 0 || consArr.length > 0
|
const hasProscons = prosArr.length > 0 || consArr.length > 0
|
||||||
|
|
||||||
return (
|
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 */}
|
{/* Hero area: photos with title overlay */}
|
||||||
{photos.length > 0 ? (
|
{photos.length > 0 ? (
|
||||||
@@ -1371,7 +1371,7 @@ function SkeletonCard({ entry, onClick }: { entry: JourneyEntry; onClick: () =>
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
onClick={onClick}
|
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)] 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">
|
<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} />
|
<MapPin size={14} />
|
||||||
@@ -1395,7 +1395,7 @@ function CheckinCard({ entry, onClick }: { entry: JourneyEntry; onClick: () => v
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
onClick={onClick}
|
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)] 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">
|
<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} />
|
<MapPin size={13} />
|
||||||
|
|||||||
@@ -238,7 +238,7 @@ export default function JourneyPage() {
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
onClick={() => navigate(`/journey/${activeJourney.id}`)}
|
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) }}
|
style={{ background: pickGradient(activeJourney.id) }}
|
||||||
>
|
>
|
||||||
{/* Cover image */}
|
{/* Cover image */}
|
||||||
@@ -333,9 +333,9 @@ export default function JourneyPage() {
|
|||||||
{/* Create card */}
|
{/* Create card */}
|
||||||
<button
|
<button
|
||||||
onClick={() => openCreateModal()}
|
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} />
|
<Plus size={22} />
|
||||||
</div>
|
</div>
|
||||||
<span className="text-[14px] font-semibold text-zinc-700 dark:text-zinc-300">{t("journey.frontpage.createNew")}</span>
|
<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
|
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
|
selected
|
||||||
? 'border-zinc-900 dark:border-zinc-400 bg-zinc-50 dark:bg-zinc-800'
|
? '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'
|
: '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 (
|
return (
|
||||||
<div
|
<div
|
||||||
onClick={onClick}
|
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 */}
|
{/* Cover */}
|
||||||
<div className="h-[170px] relative overflow-hidden" style={{ background: pickGradient(j.id) }}>
|
<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: FolderOpen, label: t('login.features.files'), desc: t('login.features.filesDesc') },
|
||||||
{ Icon: Route, label: t('login.features.routes'), desc: t('login.features.routesDesc') },
|
{ Icon: Route, label: t('login.features.routes'), desc: t('login.features.routesDesc') },
|
||||||
].map(({ Icon, label, desc }) => (
|
].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)' }}
|
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)' }}>
|
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 }} />
|
<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,
|
border: 'none', borderRadius: 12,
|
||||||
fontSize: 14, fontWeight: 700, cursor: 'pointer',
|
fontSize: 14, fontWeight: 700, cursor: 'pointer',
|
||||||
fontFamily: 'inherit', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
|
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',
|
boxSizing: 'border-box',
|
||||||
}}
|
}}
|
||||||
onMouseEnter={(e: React.MouseEvent<HTMLAnchorElement>) => { e.currentTarget.style.background = '#1f2937' }}
|
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={{
|
<button type="button" onClick={() => setShowPassword(v => !v)} style={{
|
||||||
position: 'absolute', right: 12, top: '50%', transform: 'translateY(-50%)',
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -816,7 +828,7 @@ export default function LoginPage(): React.ReactElement {
|
|||||||
border: '1px solid #d1d5db', borderRadius: 12,
|
border: '1px solid #d1d5db', borderRadius: 12,
|
||||||
fontSize: 14, fontWeight: 600, cursor: 'pointer',
|
fontSize: 14, fontWeight: 600, cursor: 'pointer',
|
||||||
fontFamily: 'inherit', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
|
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',
|
boxSizing: 'border-box',
|
||||||
}}
|
}}
|
||||||
onMouseEnter={(e: React.MouseEvent<HTMLAnchorElement>) => { e.currentTarget.style.background = '#f9fafb'; e.currentTarget.style.borderColor = '#9ca3af' }}
|
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,
|
color: '#451a03', border: 'none', borderRadius: 14,
|
||||||
fontSize: 15, fontWeight: 700, cursor: isLoading ? 'default' : 'pointer',
|
fontSize: 15, fontWeight: 700, cursor: isLoading ? 'default' : 'pointer',
|
||||||
fontFamily: 'inherit', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 10,
|
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)',
|
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)' }}
|
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
|
required
|
||||||
placeholder="johndoe"
|
placeholder="johndoe"
|
||||||
minLength={3}
|
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>
|
||||||
</div>
|
</div>
|
||||||
@@ -115,7 +115,7 @@ export default function RegisterPage(): React.ReactElement {
|
|||||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setEmail(e.target.value)}
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setEmail(e.target.value)}
|
||||||
required
|
required
|
||||||
placeholder="your@email.com"
|
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>
|
||||||
</div>
|
</div>
|
||||||
@@ -130,7 +130,7 @@ export default function RegisterPage(): React.ReactElement {
|
|||||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setPassword(e.target.value)}
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setPassword(e.target.value)}
|
||||||
required
|
required
|
||||||
placeholder={t('register.minChars')}
|
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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -152,7 +152,7 @@ export default function RegisterPage(): React.ReactElement {
|
|||||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setConfirmPassword(e.target.value)}
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setConfirmPassword(e.target.value)}
|
||||||
required
|
required
|
||||||
placeholder={t('register.repeatPassword')}
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,12 +12,14 @@ import PlaceInspector from '../components/Planner/PlaceInspector'
|
|||||||
import DayDetailPanel from '../components/Planner/DayDetailPanel'
|
import DayDetailPanel from '../components/Planner/DayDetailPanel'
|
||||||
import PlaceFormModal from '../components/Planner/PlaceFormModal'
|
import PlaceFormModal from '../components/Planner/PlaceFormModal'
|
||||||
import TripFormModal from '../components/Trips/TripFormModal'
|
import TripFormModal from '../components/Trips/TripFormModal'
|
||||||
|
import SlidingTabs from '../components/shared/SlidingTabs'
|
||||||
import TripMembersModal from '../components/Trips/TripMembersModal'
|
import TripMembersModal from '../components/Trips/TripMembersModal'
|
||||||
import { ReservationModal } from '../components/Planner/ReservationModal'
|
import { ReservationModal } from '../components/Planner/ReservationModal'
|
||||||
import { TransportModal } from '../components/Planner/TransportModal'
|
import { TransportModal } from '../components/Planner/TransportModal'
|
||||||
// MemoriesPanel moved to Journey addon
|
// MemoriesPanel moved to Journey addon
|
||||||
import ReservationsPanel from '../components/Planner/ReservationsPanel'
|
import ReservationsPanel from '../components/Planner/ReservationsPanel'
|
||||||
import PackingListPanel from '../components/Packing/PackingListPanel'
|
import PackingListPanel from '../components/Packing/PackingListPanel'
|
||||||
|
import ApplyTemplateButton from '../components/Packing/ApplyTemplateButton'
|
||||||
import TodoListPanel from '../components/Todo/TodoListPanel'
|
import TodoListPanel from '../components/Todo/TodoListPanel'
|
||||||
import FileManager from '../components/Files/FileManager'
|
import FileManager from '../components/Files/FileManager'
|
||||||
import BudgetPanel from '../components/Budget/BudgetPanel'
|
import BudgetPanel from '../components/Budget/BudgetPanel'
|
||||||
@@ -37,7 +39,7 @@ import { useRouteCalculation } from '../hooks/useRouteCalculation'
|
|||||||
import { usePlaceSelection } from '../hooks/usePlaceSelection'
|
import { usePlaceSelection } from '../hooks/usePlaceSelection'
|
||||||
import { usePlannerHistory } from '../hooks/usePlannerHistory'
|
import { usePlannerHistory } from '../hooks/usePlannerHistory'
|
||||||
import type { Accommodation, TripMember, Day, Place, Reservation, PackingItem, TodoItem } from '../types'
|
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[] }) {
|
function ListsContainer({ tripId, packingItems, todoItems }: { tripId: number; packingItems: PackingItem[]; todoItems: TodoItem[] }) {
|
||||||
const [subTab, setSubTab] = useState<'packing' | 'todo'>(() => {
|
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 setSubTabPersist = (tab: 'packing' | 'todo') => { setSubTab(tab); sessionStorage.setItem(`trip-lists-subtab-${tripId}`, tab) }
|
||||||
const [importPackingSignal, setImportPackingSignal] = useState(0)
|
const [importPackingSignal, setImportPackingSignal] = useState(0)
|
||||||
|
const [clearCheckedSignal, setClearCheckedSignal] = useState(0)
|
||||||
|
const [saveTemplateSignal, setSaveTemplateSignal] = useState(0)
|
||||||
const [addTodoSignal, setAddTodoSignal] = useState(0)
|
const [addTodoSignal, setAddTodoSignal] = useState(0)
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
|
||||||
@@ -79,7 +83,7 @@ function ListsContainer({ tripId, packingItems, todoItems }: { tripId: number; p
|
|||||||
color: active ? 'var(--text-primary)' : 'var(--text-muted)',
|
color: active ? 'var(--text-primary)' : 'var(--text-muted)',
|
||||||
fontWeight: active ? 500 : 400,
|
fontWeight: active ? 500 : 400,
|
||||||
boxShadow: active ? '0 1px 2px rgba(0,0,0,0.06)' : 'none',
|
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)' }} />
|
<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>
|
</div>
|
||||||
|
|
||||||
{subTab === 'packing' && (
|
{subTab === 'packing' && (() => {
|
||||||
<button onClick={() => setImportPackingSignal(s => s + 1)} style={{
|
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',
|
appearance: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit',
|
||||||
display: 'inline-flex', alignItems: 'center', gap: 6,
|
borderRadius: 10, fontSize: 13, fontWeight: 500,
|
||||||
padding: '9px 14px', borderRadius: 10, fontSize: 13, fontWeight: 500,
|
}
|
||||||
background: 'var(--accent)', color: 'var(--accent-text)', flexShrink: 0,
|
return (
|
||||||
marginLeft: 'auto',
|
<div style={{ display: 'flex', gap: 6, flexShrink: 0, marginLeft: 'auto', flexWrap: 'wrap' }}>
|
||||||
transition: 'opacity 0.15s ease',
|
{packingAbgehakt > 0 && (
|
||||||
}}
|
<button onClick={() => setClearCheckedSignal(s => s + 1)}
|
||||||
onMouseEnter={e => e.currentTarget.style.opacity = '0.88'}
|
className={`hidden sm:inline-flex items-center gap-1.5 px-[14px] py-[9px] hover:opacity-[0.88]`}
|
||||||
onMouseLeave={e => e.currentTarget.style.opacity = '1'}
|
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} />
|
<Upload size={14} strokeWidth={2.5} />
|
||||||
<span className="hidden sm:inline">{t('packing.import')}</span>
|
<span className="hidden sm:inline">{t('packing.import')}</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
</div>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
{subTab === 'todo' && (
|
{subTab === 'todo' && (
|
||||||
<button onClick={() => setAddTodoSignal(s => s + 1)} style={{
|
<button onClick={() => setAddTodoSignal(s => s + 1)}
|
||||||
|
className="hover:opacity-[0.88]"
|
||||||
|
style={{
|
||||||
appearance: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit',
|
appearance: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit',
|
||||||
display: 'inline-flex', alignItems: 'center', gap: 6,
|
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||||
padding: '9px 14px', borderRadius: 10, fontSize: 13, fontWeight: 500,
|
padding: '9px 14px', borderRadius: 10, fontSize: 13, fontWeight: 500,
|
||||||
background: 'var(--accent)', color: 'var(--accent-text)', flexShrink: 0,
|
background: 'var(--accent)', color: 'var(--accent-text)', flexShrink: 0,
|
||||||
marginLeft: 'auto',
|
marginLeft: 'auto',
|
||||||
transition: 'opacity 0.15s ease',
|
|
||||||
}}
|
}}
|
||||||
onMouseEnter={e => e.currentTarget.style.opacity = '0.88'}
|
|
||||||
onMouseLeave={e => e.currentTarget.style.opacity = '1'}
|
|
||||||
>
|
>
|
||||||
<Plus size={14} strokeWidth={2.5} />
|
<Plus size={14} strokeWidth={2.5} />
|
||||||
<span className="hidden sm:inline">{t('todo.addItem')}</span>
|
<span className="hidden sm:inline">{t('todo.addItem')}</span>
|
||||||
@@ -130,7 +159,7 @@ function ListsContainer({ tripId, packingItems, todoItems }: { tripId: number; p
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ padding: '16px 28px 0' }} className="max-md:!px-4">
|
<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} />}
|
{subTab === 'todo' && <TodoListPanel tripId={tripId} items={todoItems} addItemSignal={addTodoSignal} />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -720,34 +749,17 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
|||||||
WebkitBackdropFilter: 'blur(16px)',
|
WebkitBackdropFilter: 'blur(16px)',
|
||||||
borderBottom: '1px solid var(--border-faint)',
|
borderBottom: '1px solid var(--border-faint)',
|
||||||
height: 44,
|
height: 44,
|
||||||
overflowX: 'auto', scrollbarWidth: 'none', msOverflowStyle: 'none',
|
|
||||||
gap: 2,
|
|
||||||
}}>
|
}}>
|
||||||
{TRIP_TABS.map(tab => {
|
<SlidingTabs
|
||||||
const isActive = activeTab === tab.id
|
tabs={TRIP_TABS.map(tab => ({
|
||||||
const TabIcon = tab.icon
|
id: tab.id,
|
||||||
return (
|
label: <span className="hidden sm:inline">{tab.shortLabel || tab.label}</span>,
|
||||||
<button
|
title: tab.label,
|
||||||
key={tab.id}
|
icon: tab.icon,
|
||||||
onClick={() => handleTabChange(tab.id)}
|
}))}
|
||||||
title={tab.label}
|
activeTab={activeTab}
|
||||||
style={{
|
onChange={handleTabChange}
|
||||||
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>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Offset by navbar + tab bar (44px) */}
|
{/* Offset by navbar + tab bar (44px) */}
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ export default function VacayPage(): React.ReactElement {
|
|||||||
<div className="grid grid-cols-4 gap-1">
|
<div className="grid grid-cols-4 gap-1">
|
||||||
{years.map(y => (
|
{years.map(y => (
|
||||||
<div key={y} onClick={() => setSelectedYear(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={{
|
style={{
|
||||||
background: y === selectedYear ? 'var(--text-primary)' : 'var(--bg-secondary)',
|
background: y === selectedYear ? 'var(--text-primary)' : 'var(--bg-secondary)',
|
||||||
color: y === selectedYear ? 'var(--bg-card)' : 'var(--text-muted)',
|
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"
|
<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)' }}>
|
style={{ zIndex: 99995, backgroundColor: 'rgba(0,0,0,0.7)', backdropFilter: 'blur(8px)' }}>
|
||||||
{incomingInvites.map(inv => (
|
{incomingInvites.map(inv => (
|
||||||
<div key={inv.id} className="w-full max-w-md rounded-2xl shadow-2xl overflow-hidden"
|
<div key={inv.id} className="trek-modal-enter w-full max-w-md rounded-2xl shadow-2xl overflow-hidden"
|
||||||
style={{ background: 'var(--bg-card)', animation: 'modalIn 0.25s ease-out' }}>
|
style={{ background: 'var(--bg-card)' }}>
|
||||||
<div className="px-6 pt-6 pb-4 text-center">
|
<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"
|
<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)' }}>
|
style={{ background: 'var(--bg-secondary)', color: 'var(--text-primary)' }}>
|
||||||
|
|||||||
@@ -25,6 +25,15 @@ afterAll(() => server.close());
|
|||||||
|
|
||||||
// ── jsdom stubs ────────────────────────────────────────────────────────────────
|
// ── 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
|
// window.matchMedia — used by dark mode / responsive components
|
||||||
Object.defineProperty(window, 'matchMedia', {
|
Object.defineProperty(window, 'matchMedia', {
|
||||||
writable: true,
|
writable: true,
|
||||||
|
|||||||
Reference in New Issue
Block a user