Migrate static theme inline styles to Tailwind utilities and extract page sub-components

Replace the static, color-only inline `style={{ ... 'var(--bg-primary)' ... }}` props with the new semantic Tailwind utilities (bg-surface, text-content, border-edge, ...) wherever the result is byte-identical; dynamic/conditional theme styles and hardcoded status colors are left inline. Extract the Atlas country-search autocomplete, the Admin update banner, and two Journey dialogs into their own presentational components to shrink the oversized page files, keeping behaviour and markup identical.
This commit is contained in:
Maurice
2026-05-31 17:15:54 +02:00
parent 43173e2b33
commit 9614a5cf84
52 changed files with 1218 additions and 1149 deletions
+30 -31
View File
@@ -158,16 +158,16 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Header */} {/* Header */}
<div className="rounded-xl border overflow-hidden" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}> <div className="rounded-xl border overflow-hidden bg-surface-card border-edge">
<div className="px-6 py-4 border-b" style={{ borderColor: 'var(--border-secondary)' }}> <div className="px-6 py-4 border-b border-edge-secondary">
<h2 className="font-semibold" style={{ color: 'var(--text-primary)' }}>{t('admin.addons.title')}</h2> <h2 className="font-semibold text-content">{t('admin.addons.title')}</h2>
<p className="text-xs mt-1" style={{ color: 'var(--text-muted)', display: 'flex', alignItems: 'center', gap: 4, flexWrap: 'wrap' }}> <p className="text-xs mt-1 text-content-muted" style={{ display: 'flex', alignItems: 'center', gap: 4, flexWrap: 'wrap' }}>
{t('admin.addons.subtitleBefore')}<img src={dark ? '/text-light.svg' : '/text-dark.svg'} alt="TREK" style={{ height: 11, display: 'inline', verticalAlign: 'middle', opacity: 0.7 }} />{t('admin.addons.subtitleAfter')} {t('admin.addons.subtitleBefore')}<img src={dark ? '/text-light.svg' : '/text-dark.svg'} alt="TREK" style={{ height: 11, display: 'inline', verticalAlign: 'middle', opacity: 0.7 }} />{t('admin.addons.subtitleAfter')}
</p> </p>
</div> </div>
{addons.length === 0 ? ( {addons.length === 0 ? (
<div className="p-8 text-center text-sm" style={{ color: 'var(--text-faint)' }}> <div className="p-8 text-center text-sm text-content-faint">
{t('admin.addons.noAddons')} {t('admin.addons.noAddons')}
</div> </div>
) : ( ) : (
@@ -175,9 +175,9 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
{/* Trip Addons */} {/* Trip Addons */}
{tripAddons.length > 0 && ( {tripAddons.length > 0 && (
<div> <div>
<div className="px-6 py-2.5 border-b flex items-center gap-2" style={{ background: 'var(--bg-secondary)', borderColor: 'var(--border-secondary)' }}> <div className="px-6 py-2.5 border-b flex items-center gap-2 bg-surface-secondary border-edge-secondary">
<Briefcase size={13} style={{ color: 'var(--text-muted)' }} /> <Briefcase size={13} className="text-content-muted" />
<span className="text-xs font-medium uppercase tracking-wider" style={{ color: 'var(--text-muted)' }}> <span className="text-xs font-medium uppercase tracking-wider text-content-muted">
{t('admin.addons.type.trip')} {t('admin.addons.tripHint')} {t('admin.addons.type.trip')} {t('admin.addons.tripHint')}
</span> </span>
</div> </div>
@@ -185,11 +185,11 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
<div key={addon.id}> <div key={addon.id}>
<AddonRow addon={addon} onToggle={handleToggle} t={t} /> <AddonRow addon={addon} onToggle={handleToggle} t={t} />
{addon.id === 'packing' && addon.enabled && onToggleBagTracking && ( {addon.id === 'packing' && addon.enabled && onToggleBagTracking && (
<div className="flex items-center gap-4 px-6 py-3 border-b" style={{ borderColor: 'var(--border-secondary)', background: 'var(--bg-secondary)', paddingLeft: 70 }}> <div className="flex items-center gap-4 px-6 py-3 border-b border-edge-secondary bg-surface-secondary" style={{ paddingLeft: 70 }}>
<Luggage size={14} style={{ color: 'var(--text-faint)', flexShrink: 0 }} /> <Luggage size={14} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
<div style={{ flex: 1, minWidth: 0 }}> <div style={{ flex: 1, minWidth: 0 }}>
<div className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>{t('admin.bagTracking.title')}</div> <div className="text-sm font-medium text-content-secondary">{t('admin.bagTracking.title')}</div>
<div className="text-xs mt-0.5" style={{ color: 'var(--text-faint)' }}>{t('admin.bagTracking.subtitle')}</div> <div className="text-xs mt-0.5 text-content-faint">{t('admin.bagTracking.subtitle')}</div>
</div> </div>
<div className="flex items-center gap-2 shrink-0"> <div className="flex items-center gap-2 shrink-0">
<span className="hidden sm:inline text-xs font-medium" style={{ color: bagTrackingEnabled ? 'var(--text-primary)' : 'var(--text-faint)' }}> <span className="hidden sm:inline text-xs font-medium" style={{ color: bagTrackingEnabled ? 'var(--text-primary)' : 'var(--text-faint)' }}>
@@ -205,7 +205,7 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
</div> </div>
)} )}
{addon.id === 'collab' && addon.enabled && collabFeatures && onToggleCollabFeature && ( {addon.id === 'collab' && addon.enabled && collabFeatures && onToggleCollabFeature && (
<div className="px-6 py-3 border-b" style={{ borderColor: 'var(--border-secondary)', background: 'var(--bg-secondary)', paddingLeft: 70 }}> <div className="px-6 py-3 border-b border-edge-secondary bg-surface-secondary" style={{ paddingLeft: 70 }}>
<div className="space-y-2"> <div className="space-y-2">
{COLLAB_SUB_FEATURES.map(feat => { {COLLAB_SUB_FEATURES.map(feat => {
const enabled = collabFeatures[feat.key] const enabled = collabFeatures[feat.key]
@@ -214,8 +214,8 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
<div key={feat.key} className="flex items-center gap-4" style={{ minHeight: 32 }}> <div key={feat.key} className="flex items-center gap-4" style={{ minHeight: 32 }}>
<Icon size={14} style={{ color: 'var(--text-faint)', flexShrink: 0 }} /> <Icon size={14} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
<div style={{ flex: 1, minWidth: 0 }}> <div style={{ flex: 1, minWidth: 0 }}>
<div className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>{t(feat.titleKey)}</div> <div className="text-sm font-medium text-content-secondary">{t(feat.titleKey)}</div>
<div className="text-xs mt-0.5" style={{ color: 'var(--text-faint)' }}>{t(feat.subtitleKey)}</div> <div className="text-xs mt-0.5 text-content-faint">{t(feat.subtitleKey)}</div>
</div> </div>
<div className="flex items-center gap-2 shrink-0"> <div className="flex items-center gap-2 shrink-0">
<span className="hidden sm:inline text-xs font-medium" style={{ color: enabled ? 'var(--text-primary)' : 'var(--text-faint)' }}> <span className="hidden sm:inline text-xs font-medium" style={{ color: enabled ? 'var(--text-primary)' : 'var(--text-faint)' }}>
@@ -242,9 +242,9 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
{/* Global Addons */} {/* Global Addons */}
{globalAddons.length > 0 && ( {globalAddons.length > 0 && (
<div> <div>
<div className="px-6 py-2.5 border-b border-t flex items-center gap-2" style={{ background: 'var(--bg-secondary)', borderColor: 'var(--border-secondary)' }}> <div className="px-6 py-2.5 border-b border-t flex items-center gap-2 bg-surface-secondary border-edge-secondary">
<Globe size={13} style={{ color: 'var(--text-muted)' }} /> <Globe size={13} className="text-content-muted" />
<span className="text-xs font-medium uppercase tracking-wider" style={{ color: 'var(--text-muted)' }}> <span className="text-xs font-medium uppercase tracking-wider text-content-muted">
{t('admin.addons.type.global')} {t('admin.addons.globalHint')} {t('admin.addons.type.global')} {t('admin.addons.globalHint')}
</span> </span>
</div> </div>
@@ -253,7 +253,7 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
<AddonRow addon={addon} onToggle={handleToggle} t={t} /> <AddonRow addon={addon} onToggle={handleToggle} t={t} />
{/* Memories providers as sub-items under Journey addon */} {/* Memories providers as sub-items under Journey addon */}
{addon.id === 'journey' && providerOptions.length > 0 && ( {addon.id === 'journey' && providerOptions.length > 0 && (
<div className="px-6 py-3 border-b" style={{ borderColor: 'var(--border-secondary)', background: 'var(--bg-secondary)', paddingLeft: 70 }}> <div className="px-6 py-3 border-b border-edge-secondary bg-surface-secondary" style={{ paddingLeft: 70 }}>
<div className="space-y-2"> <div className="space-y-2">
{providerOptions.map(provider => { {providerOptions.map(provider => {
const ProviderIcon = PROVIDER_ICONS[provider.key] const ProviderIcon = PROVIDER_ICONS[provider.key]
@@ -261,8 +261,8 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
<div key={provider.key} className="flex items-center gap-4" style={{ minHeight: 32 }}> <div key={provider.key} className="flex items-center gap-4" style={{ minHeight: 32 }}>
{ProviderIcon && <span style={{ color: 'var(--text-faint)' }}><ProviderIcon size={14} /></span>} {ProviderIcon && <span style={{ color: 'var(--text-faint)' }}><ProviderIcon size={14} /></span>}
<div style={{ flex: 1, minWidth: 0 }}> <div style={{ flex: 1, minWidth: 0 }}>
<div className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>{provider.label}</div> <div className="text-sm font-medium text-content-secondary">{provider.label}</div>
<div className="text-xs mt-0.5" style={{ color: 'var(--text-faint)' }}>{provider.description}</div> <div className="text-xs mt-0.5 text-content-faint">{provider.description}</div>
</div> </div>
<div className="flex items-center gap-2 shrink-0"> <div className="flex items-center gap-2 shrink-0">
<span className="hidden sm:inline text-xs font-medium" style={{ color: provider.enabled ? 'var(--text-primary)' : 'var(--text-faint)' }}> <span className="hidden sm:inline text-xs font-medium" style={{ color: provider.enabled ? 'var(--text-primary)' : 'var(--text-faint)' }}>
@@ -291,9 +291,9 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
{/* Integration Addons */} {/* Integration Addons */}
{integrationAddons.length > 0 && ( {integrationAddons.length > 0 && (
<div> <div>
<div className="px-6 py-2.5 border-b border-t flex items-center gap-2" style={{ background: 'var(--bg-secondary)', borderColor: 'var(--border-secondary)' }}> <div className="px-6 py-2.5 border-b border-t flex items-center gap-2 bg-surface-secondary border-edge-secondary">
<Link2 size={13} style={{ color: 'var(--text-muted)' }} /> <Link2 size={13} className="text-content-muted" />
<span className="text-xs font-medium uppercase tracking-wider" style={{ color: 'var(--text-muted)' }}> <span className="text-xs font-medium uppercase tracking-wider text-content-muted">
{t('admin.addons.type.integration')} {t('admin.addons.integrationHint')} {t('admin.addons.type.integration')} {t('admin.addons.integrationHint')}
</span> </span>
</div> </div>
@@ -336,26 +336,26 @@ function AddonRow({ addon, onToggle, t, nameOverride, descriptionOverride, statu
const displayDescription = descriptionOverride || label.description const displayDescription = descriptionOverride || label.description
const enabledState = statusOverride ?? addon.enabled const enabledState = statusOverride ?? addon.enabled
return ( return (
<div className="flex items-center gap-4 px-6 py-4 border-b transition-colors hover:opacity-95" style={{ borderColor: 'var(--border-secondary)', opacity: isComingSoon ? 0.5 : 1, pointerEvents: isComingSoon ? 'none' : 'auto' }}> <div className="flex items-center gap-4 px-6 py-4 border-b transition-colors hover:opacity-95 border-edge-secondary" style={{ opacity: isComingSoon ? 0.5 : 1, pointerEvents: isComingSoon ? 'none' : 'auto' }}>
{/* Icon */} {/* Icon */}
<div className="w-10 h-10 rounded-xl flex items-center justify-center shrink-0" style={{ background: 'var(--bg-secondary)', color: 'var(--text-primary)' }}> <div className="w-10 h-10 rounded-xl flex items-center justify-center shrink-0 bg-surface-secondary text-content">
<AddonIcon name={addon.icon} size={20} /> <AddonIcon name={addon.icon} size={20} />
</div> </div>
{/* Info */} {/* Info */}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>{displayName}</span> <span className="text-sm font-semibold text-content">{displayName}</span>
{isComingSoon && ( {isComingSoon && (
<span className="text-[9px] font-semibold px-2 py-0.5 rounded-full" style={{ background: 'var(--bg-tertiary)', color: 'var(--text-faint)' }}> <span className="text-[9px] font-semibold px-2 py-0.5 rounded-full text-content-faint" style={{ background: 'var(--bg-tertiary)' }}>
Coming Soon Coming Soon
</span> </span>
)} )}
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded-full" style={{ background: 'var(--bg-secondary)', color: 'var(--text-muted)' }}> <span className="text-[10px] font-medium px-1.5 py-0.5 rounded-full bg-surface-secondary text-content-muted">
{addon.type === 'global' ? t('admin.addons.type.global') : addon.type === 'integration' ? t('admin.addons.type.integration') : t('admin.addons.type.trip')} {addon.type === 'global' ? t('admin.addons.type.global') : addon.type === 'integration' ? t('admin.addons.type.integration') : t('admin.addons.type.trip')}
</span> </span>
</div> </div>
<p className="text-xs mt-0.5" style={{ color: 'var(--text-muted)' }}>{displayDescription}</p> <p className="text-xs mt-0.5 text-content-muted">{displayDescription}</p>
</div> </div>
{/* Toggle */} {/* Toggle */}
@@ -371,9 +371,8 @@ function AddonRow({ addon, onToggle, t, nameOverride, descriptionOverride, statu
style={{ background: (enabledState && !isComingSoon) ? 'var(--text-primary)' : 'var(--border-primary)', cursor: isComingSoon ? 'not-allowed' : 'pointer' }} style={{ background: (enabledState && !isComingSoon) ? 'var(--text-primary)' : 'var(--border-primary)', cursor: isComingSoon ? 'not-allowed' : 'pointer' }}
> >
<span <span
className="inline-block h-4 w-4 transform rounded-full transition-transform" className="inline-block h-4 w-4 transform rounded-full transition-transform bg-surface-card"
style={{ style={{
background: 'var(--bg-card)',
transform: (enabledState && !isComingSoon) ? 'translateX(22px)' : 'translateX(4px)', transform: (enabledState && !isComingSoon) ? 'translateX(22px)' : 'translateX(4px)',
}} }}
/> />
+51 -54
View File
@@ -67,7 +67,7 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
elements.push( elements.push(
<ul key={`ul-${elements.length}`} className="space-y-1 my-2"> <ul key={`ul-${elements.length}`} className="space-y-1 my-2">
{listItems.map((item, i) => ( {listItems.map((item, i) => (
<li key={i} className="flex gap-2 text-xs" style={{ color: 'var(--text-muted)' }}> <li key={i} className="flex gap-2 text-xs text-content-muted">
<span className="mt-1.5 w-1 h-1 rounded-full flex-shrink-0" style={{ background: 'var(--text-faint)' }} /> <span className="mt-1.5 w-1 h-1 rounded-full flex-shrink-0" style={{ background: 'var(--text-faint)' }} />
<span dangerouslySetInnerHTML={{ __html: inlineFormat(item) }} /> <span dangerouslySetInnerHTML={{ __html: inlineFormat(item) }} />
</li> </li>
@@ -96,14 +96,14 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
if (trimmed.startsWith('### ')) { if (trimmed.startsWith('### ')) {
flushList() flushList()
elements.push( elements.push(
<h4 key={elements.length} className="text-xs font-semibold mt-3 mb-1" style={{ color: 'var(--text-primary)' }}> <h4 key={elements.length} className="text-xs font-semibold mt-3 mb-1 text-content">
{trimmed.slice(4)} {trimmed.slice(4)}
</h4> </h4>
) )
} else if (trimmed.startsWith('## ')) { } else if (trimmed.startsWith('## ')) {
flushList() flushList()
elements.push( elements.push(
<h3 key={elements.length} className="text-sm font-semibold mt-3 mb-1" style={{ color: 'var(--text-primary)' }}> <h3 key={elements.length} className="text-sm font-semibold mt-3 mb-1 text-content">
{trimmed.slice(3)} {trimmed.slice(3)}
</h3> </h3>
) )
@@ -112,7 +112,7 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
} else { } else {
flushList() flushList()
elements.push( elements.push(
<p key={elements.length} className="text-xs my-1" style={{ color: 'var(--text-muted)' }} <p key={elements.length} className="text-xs my-1 text-content-muted"
dangerouslySetInnerHTML={{ __html: inlineFormat(trimmed) }} dangerouslySetInnerHTML={{ __html: inlineFormat(trimmed) }}
/> />
) )
@@ -130,8 +130,8 @@ 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-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]" 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)] bg-surface-card"
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }} style={{ 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' }}
> >
@@ -139,17 +139,17 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
<Coffee size={20} style={{ color: '#ff5e5b' }} /> <Coffee size={20} style={{ color: '#ff5e5b' }} />
</div> </div>
<div> <div>
<div className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>Ko-fi</div> <div className="text-sm font-semibold text-content">Ko-fi</div>
<div className="text-xs" style={{ color: 'var(--text-faint)' }}>{t('admin.github.support')}</div> <div className="text-xs text-content-faint">{t('admin.github.support')}</div>
</div> </div>
<ExternalLink size={14} className="ml-auto flex-shrink-0" style={{ color: 'var(--text-faint)' }} /> <ExternalLink size={14} className="ml-auto flex-shrink-0 text-content-faint" />
</a> </a>
<a <a
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-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]" 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)] bg-surface-card"
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }} style={{ 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' }}
> >
@@ -157,17 +157,17 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
<Heart size={20} style={{ color: '#ffdd00' }} /> <Heart size={20} style={{ color: '#ffdd00' }} />
</div> </div>
<div> <div>
<div className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>Buy Me a Coffee</div> <div className="text-sm font-semibold text-content">Buy Me a Coffee</div>
<div className="text-xs" style={{ color: 'var(--text-faint)' }}>{t('admin.github.support')}</div> <div className="text-xs text-content-faint">{t('admin.github.support')}</div>
</div> </div>
<ExternalLink size={14} className="ml-auto flex-shrink-0" style={{ color: 'var(--text-faint)' }} /> <ExternalLink size={14} className="ml-auto flex-shrink-0 text-content-faint" />
</a> </a>
<a <a
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-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]" 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)] bg-surface-card"
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }} style={{ 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' }}
> >
@@ -175,10 +175,10 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
<svg width="20" height="20" viewBox="0 0 24 24" fill="#5865F2"><path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"/></svg> <svg width="20" height="20" viewBox="0 0 24 24" fill="#5865F2"><path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"/></svg>
</div> </div>
<div> <div>
<div className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>Discord</div> <div className="text-sm font-semibold text-content">Discord</div>
<div className="text-xs" style={{ color: 'var(--text-faint)' }}>Join the community</div> <div className="text-xs text-content-faint">Join the community</div>
</div> </div>
<ExternalLink size={14} className="ml-auto flex-shrink-0" style={{ color: 'var(--text-faint)' }} /> <ExternalLink size={14} className="ml-auto flex-shrink-0 text-content-faint" />
</a> </a>
</div> </div>
@@ -187,8 +187,8 @@ 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-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]" 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)] bg-surface-card"
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }} style={{ 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' }}
> >
@@ -196,17 +196,17 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
<Bug size={20} style={{ color: '#ef4444' }} /> <Bug size={20} style={{ color: '#ef4444' }} />
</div> </div>
<div> <div>
<div className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>{t('settings.about.reportBug')}</div> <div className="text-sm font-semibold text-content">{t('settings.about.reportBug')}</div>
<div className="text-xs" style={{ color: 'var(--text-faint)' }}>{t('settings.about.reportBugHint')}</div> <div className="text-xs text-content-faint">{t('settings.about.reportBugHint')}</div>
</div> </div>
<ExternalLink size={14} className="ml-auto flex-shrink-0" style={{ color: 'var(--text-faint)' }} /> <ExternalLink size={14} className="ml-auto flex-shrink-0 text-content-faint" />
</a> </a>
<a <a
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-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]" 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)] bg-surface-card"
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }} style={{ 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' }}
> >
@@ -214,17 +214,17 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
<Lightbulb size={20} style={{ color: '#f59e0b' }} /> <Lightbulb size={20} style={{ color: '#f59e0b' }} />
</div> </div>
<div> <div>
<div className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>{t('settings.about.featureRequest')}</div> <div className="text-sm font-semibold text-content">{t('settings.about.featureRequest')}</div>
<div className="text-xs" style={{ color: 'var(--text-faint)' }}>{t('settings.about.featureRequestHint')}</div> <div className="text-xs text-content-faint">{t('settings.about.featureRequestHint')}</div>
</div> </div>
<ExternalLink size={14} className="ml-auto flex-shrink-0" style={{ color: 'var(--text-faint)' }} /> <ExternalLink size={14} className="ml-auto flex-shrink-0 text-content-faint" />
</a> </a>
<a <a
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-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]" 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)] bg-surface-card"
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }} style={{ 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' }}
> >
@@ -232,40 +232,39 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
<BookOpen size={20} style={{ color: '#6366f1' }} /> <BookOpen size={20} style={{ color: '#6366f1' }} />
</div> </div>
<div> <div>
<div className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>Wiki</div> <div className="text-sm font-semibold text-content">Wiki</div>
<div className="text-xs" style={{ color: 'var(--text-faint)' }}>{t('settings.about.wikiHint')}</div> <div className="text-xs text-content-faint">{t('settings.about.wikiHint')}</div>
</div> </div>
<ExternalLink size={14} className="ml-auto flex-shrink-0" style={{ color: 'var(--text-faint)' }} /> <ExternalLink size={14} className="ml-auto flex-shrink-0 text-content-faint" />
</a> </a>
</div> </div>
{/* Loading / Error / Releases */} {/* Loading / Error / Releases */}
{loading ? ( {loading ? (
<div className="rounded-xl border overflow-hidden" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}> <div className="rounded-xl border overflow-hidden bg-surface-card border-edge">
<div className="p-8 flex items-center justify-center"> <div className="p-8 flex items-center justify-center">
<Loader2 className="w-6 h-6 animate-spin" style={{ color: 'var(--text-muted)' }} /> <Loader2 className="w-6 h-6 animate-spin text-content-muted" />
</div> </div>
</div> </div>
) : error ? ( ) : error ? (
<div className="rounded-xl border overflow-hidden" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}> <div className="rounded-xl border overflow-hidden bg-surface-card border-edge">
<div className="p-6 text-center"> <div className="p-6 text-center">
<p className="text-sm" style={{ color: 'var(--text-muted)' }}>{t('admin.github.error')}</p> <p className="text-sm text-content-muted">{t('admin.github.error')}</p>
<p className="text-xs mt-1" style={{ color: 'var(--text-faint)' }}>{error}</p> <p className="text-xs mt-1 text-content-faint">{error}</p>
</div> </div>
</div> </div>
) : ( ) : (
<div className="rounded-xl border overflow-hidden" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}> <div className="rounded-xl border overflow-hidden bg-surface-card border-edge">
<div className="px-5 py-4 border-b flex items-center justify-between" style={{ borderColor: 'var(--border-secondary)' }}> <div className="px-5 py-4 border-b flex items-center justify-between border-edge-secondary">
<div> <div>
<h2 className="font-semibold" style={{ color: 'var(--text-primary)' }}>{t('admin.github.title')}</h2> <h2 className="font-semibold text-content">{t('admin.github.title')}</h2>
<p className="text-xs mt-0.5" style={{ color: 'var(--text-faint)' }}>{t('admin.github.subtitle').replace('{repo}', REPO)}</p> <p className="text-xs mt-0.5 text-content-faint">{t('admin.github.subtitle').replace('{repo}', REPO)}</p>
</div> </div>
<a <a
href={`https://github.com/${REPO}/releases`} href={`https://github.com/${REPO}/releases`}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors" className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors bg-surface-secondary text-content-muted"
style={{ background: 'var(--bg-secondary)', color: 'var(--text-muted)' }}
> >
<ExternalLink size={12} /> <ExternalLink size={12} />
GitHub GitHub
@@ -299,7 +298,7 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
{/* Release content */} {/* Release content */}
<div> <div>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}> <span className="text-sm font-semibold text-content">
{release.tag_name} {release.tag_name}
</span> </span>
{isLatest && ( {isLatest && (
@@ -317,18 +316,18 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
</div> </div>
{release.name && release.name !== release.tag_name && ( {release.name && release.name !== release.tag_name && (
<p className="text-xs font-medium mt-0.5" style={{ color: 'var(--text-muted)' }}> <p className="text-xs font-medium mt-0.5 text-content-muted">
{release.name} {release.name}
</p> </p>
)} )}
<div className="flex items-center gap-3 mt-1"> <div className="flex items-center gap-3 mt-1">
<span className="flex items-center gap-1 text-[11px]" style={{ color: 'var(--text-faint)' }}> <span className="flex items-center gap-1 text-[11px] text-content-faint">
<Calendar size={10} /> <Calendar size={10} />
{formatDate(release.published_at || release.created_at)} {formatDate(release.published_at || release.created_at)}
</span> </span>
{release.author && ( {release.author && (
<span className="text-[11px]" style={{ color: 'var(--text-faint)' }}> <span className="text-[11px] text-content-faint">
{t('admin.github.by')} {release.author.login} {t('admin.github.by')} {release.author.login}
</span> </span>
)} )}
@@ -339,15 +338,14 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
<div className="mt-2"> <div className="mt-2">
<button <button
onClick={() => toggleExpand(release.id)} onClick={() => toggleExpand(release.id)}
className="flex items-center gap-1 text-[11px] font-medium transition-colors" className="flex items-center gap-1 text-[11px] font-medium transition-colors text-content-muted"
style={{ color: 'var(--text-muted)' }}
> >
{isExpanded ? <ChevronUp size={12} /> : <ChevronDown size={12} />} {isExpanded ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
{isExpanded ? t('admin.github.hideDetails') : t('admin.github.showDetails')} {isExpanded ? t('admin.github.hideDetails') : t('admin.github.showDetails')}
</button> </button>
{isExpanded && ( {isExpanded && (
<div className="mt-2 p-3 rounded-lg" style={{ background: 'var(--bg-secondary)' }}> <div className="mt-2 p-3 rounded-lg bg-surface-secondary">
{renderBody(release.body)} {renderBody(release.body)}
</div> </div>
)} )}
@@ -366,8 +364,7 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
<button <button
onClick={handleLoadMore} onClick={handleLoadMore}
disabled={loadingMore} disabled={loadingMore}
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-xs font-medium transition-colors" className="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-xs font-medium transition-colors bg-surface-secondary text-content-muted"
style={{ background: 'var(--bg-secondary)', color: 'var(--text-muted)' }}
> >
{loadingMore ? <Loader2 size={12} className="animate-spin" /> : <ChevronDown size={12} />} {loadingMore ? <Loader2 size={12} className="animate-spin" /> : <ChevronDown size={12} />}
{loadingMore ? t('admin.github.loading') : t('admin.github.loadMore')} {loadingMore ? t('admin.github.loading') : t('admin.github.loadMore')}
+15 -10
View File
@@ -3,6 +3,7 @@ import { useState, useEffect, useRef, useMemo, useCallback } from 'react'
import DOM from 'react-dom' import DOM from 'react-dom'
import { useTripStore } from '../../store/tripStore' import { useTripStore } from '../../store/tripStore'
import { useCanDo } from '../../store/permissionsStore' import { useCanDo } from '../../store/permissionsStore'
import { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n' import { useTranslation } from '../../i18n'
import { Plus, Trash2, Calculator, Wallet, Pencil, Users, Check, Info, ChevronDown, ChevronRight, Download, GripVertical, TrendingUp, TrendingDown, PieChart as PieChartIcon } from 'lucide-react' import { Plus, Trash2, Calculator, Wallet, Pencil, Users, Check, Info, ChevronDown, ChevronRight, Download, GripVertical, TrendingUp, TrendingDown, PieChart as PieChartIcon } from 'lucide-react'
@@ -206,7 +207,7 @@ function AddItemRow({ onAdd, t }: AddItemRowProps) {
const inp = { border: '1px solid var(--border-primary)', borderRadius: 4, padding: '4px 6px', fontSize: 13, outline: 'none', fontFamily: 'inherit', width: '100%', background: 'var(--bg-input)', color: 'var(--text-primary)' } const inp = { border: '1px solid var(--border-primary)', borderRadius: 4, padding: '4px 6px', fontSize: 13, outline: 'none', fontFamily: 'inherit', width: '100%', background: 'var(--bg-input)', color: 'var(--text-primary)' }
return ( return (
<tr style={{ background: 'var(--bg-secondary)' }}> <tr className="bg-surface-secondary">
<td style={{ padding: '4px 6px' }}> <td style={{ padding: '4px 6px' }}>
<input ref={nameRef} value={name} onChange={e => setName(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()} <input ref={nameRef} value={name} onChange={e => setName(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()}
placeholder={t('budget.newEntry')} style={inp} /> placeholder={t('budget.newEntry')} style={inp} />
@@ -224,9 +225,9 @@ function AddItemRow({ onAdd, t }: AddItemRowProps) {
<input value={days} onChange={e => setDays(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()} <input value={days} onChange={e => setDays(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()}
placeholder="-" inputMode="numeric" style={{ ...inp, textAlign: 'center', maxWidth: 60, margin: '0 auto' }} /> placeholder="-" inputMode="numeric" style={{ ...inp, textAlign: 'center', maxWidth: 60, margin: '0 auto' }} />
</td> </td>
<td className="hidden md:table-cell" style={{ padding: '4px 6px', color: 'var(--text-faint)', fontSize: 12, textAlign: 'center' }}>-</td> <td className="hidden md:table-cell text-content-faint" style={{ padding: '4px 6px', fontSize: 12, textAlign: 'center' }}>-</td>
<td className="hidden md:table-cell" style={{ padding: '4px 6px', color: 'var(--text-faint)', fontSize: 12, textAlign: 'center' }}>-</td> <td className="hidden md:table-cell text-content-faint" style={{ padding: '4px 6px', fontSize: 12, textAlign: 'center' }}>-</td>
<td className="hidden lg:table-cell" style={{ padding: '4px 6px', color: 'var(--text-faint)', fontSize: 12, textAlign: 'center' }}>-</td> <td className="hidden lg:table-cell text-content-faint" style={{ padding: '4px 6px', fontSize: 12, textAlign: 'center' }}>-</td>
<td className="hidden sm:table-cell" style={{ padding: '4px 6px', textAlign: 'center' }}> <td className="hidden sm:table-cell" style={{ padding: '4px 6px', textAlign: 'center' }}>
<div style={{ maxWidth: 90, margin: '0 auto' }}> <div style={{ maxWidth: 90, margin: '0 auto' }}>
<CustomDatePicker value={expenseDate} onChange={setExpenseDate} placeholder="-" compact /> <CustomDatePicker value={expenseDate} onChange={setExpenseDate} placeholder="-" compact />
@@ -561,6 +562,7 @@ interface BudgetPanelProps {
export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelProps) { export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelProps) {
const { trip, budgetItems, addBudgetItem, updateBudgetItem, deleteBudgetItem, loadBudgetItems, updateTrip, setBudgetItemMembers, toggleBudgetMemberPaid, reorderBudgetItems, reorderBudgetCategories } = useTripStore() const { trip, budgetItems, addBudgetItem, updateBudgetItem, deleteBudgetItem, loadBudgetItems, updateTrip, setBudgetItemMembers, toggleBudgetMemberPaid, reorderBudgetItems, reorderBudgetCategories } = useTripStore()
const can = useCanDo() const can = useCanDo()
const toast = useToast()
const { t, locale } = useTranslation() const { t, locale } = useTranslation()
const isDark = useIsDark() const isDark = useIsDark()
const theme = useMemo(() => widgetTheme(isDark), [isDark]) const theme = useMemo(() => widgetTheme(isDark), [isDark])
@@ -625,21 +627,24 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
})).filter(s => s.value > 0) })).filter(s => s.value > 0)
, [grouped, categoryNames]) , [grouped, categoryNames])
const handleAddItem = async (category, data) => { try { await addBudgetItem(tripId, { ...data, category }) } catch {} } const handleAddItem = async (category, data) => { try { await addBudgetItem(tripId, { ...data, category }) } catch { toast.error(t('common.error')) } }
const handleUpdateField = async (id, field, value) => { try { await updateBudgetItem(tripId, id, { [field]: value }) } catch {} } const handleUpdateField = async (id, field, value) => { try { await updateBudgetItem(tripId, id, { [field]: value }) } catch { toast.error(t('common.error')) } }
const handleDeleteItem = async (id) => { try { await deleteBudgetItem(tripId, id) } catch {} } const handleDeleteItem = async (id) => { try { await deleteBudgetItem(tripId, id) } catch { toast.error(t('common.error')) } }
const handleDeleteCategory = async (cat) => { const handleDeleteCategory = async (cat) => {
const items = grouped.get(cat) || [] const items = grouped.get(cat) || []
for (const item of Array.from(items)) await deleteBudgetItem(tripId, item.id) try { for (const item of Array.from(items)) await deleteBudgetItem(tripId, item.id) }
catch { toast.error(t('common.error')) }
} }
const handleRenameCategory = async (oldName, newName) => { const handleRenameCategory = async (oldName, newName) => {
if (!newName.trim() || newName.trim() === oldName) return if (!newName.trim() || newName.trim() === oldName) return
const items = grouped.get(oldName) || [] const items = grouped.get(oldName) || []
for (const item of Array.from(items)) await updateBudgetItem(tripId, item.id, { category: newName.trim() }) try { for (const item of Array.from(items)) await updateBudgetItem(tripId, item.id, { category: newName.trim() }) }
catch { toast.error(t('common.error')) }
} }
const handleAddCategory = () => { const handleAddCategory = () => {
if (!newCategoryName.trim()) return if (!newCategoryName.trim()) return
addBudgetItem(tripId, { name: t('budget.defaultEntry'), category: newCategoryName.trim(), total_price: 0 }) Promise.resolve(addBudgetItem(tripId, { name: t('budget.defaultEntry'), category: newCategoryName.trim(), total_price: 0 }))
.catch(() => toast.error(t('common.error')))
setNewCategoryName('') setNewCategoryName('')
} }
+11 -9
View File
@@ -7,6 +7,7 @@ import { useCanDo } from '../../store/permissionsStore'
import { useTripStore } from '../../store/tripStore' import { useTripStore } from '../../store/tripStore'
import { addListener, removeListener } from '../../api/websocket' import { addListener, removeListener } from '../../api/websocket'
import { useTranslation } from '../../i18n' import { useTranslation } from '../../i18n'
import { useToast } from '../shared/Toast'
import type { User } from '../../types' import type { User } from '../../types'
interface ChatReaction { interface ChatReaction {
@@ -367,7 +368,7 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
<div ref={containerRef} style={{ display: 'flex', flexDirection: 'column', flex: 1, overflow: 'hidden', position: 'relative', minHeight: 0, height: '100%' }}> <div ref={containerRef} style={{ display: 'flex', flexDirection: 'column', flex: 1, overflow: 'hidden', position: 'relative', minHeight: 0, height: '100%' }}>
<ChatMessages {...S} /> <ChatMessages {...S} />
{/* Composer */} {/* Composer */}
<div style={{ flexShrink: 0, paddingTop: 8, paddingLeft: 12, paddingRight: 12, borderTop: '1px solid var(--border-faint)', background: 'var(--bg-card)' }} className="pb-3"> <div style={{ flexShrink: 0, paddingTop: 8, paddingLeft: 12, paddingRight: 12, borderTop: '1px solid var(--border-faint)' }} className="pb-3 bg-surface-card">
{/* Reply preview */} {/* Reply preview */}
{replyTo && ( {replyTo && (
<div style={{ <div style={{
@@ -447,6 +448,7 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
function useCollabChat(tripId: any, currentUser: any) { function useCollabChat(tripId: any, currentUser: any) {
const { t } = useTranslation() const { t } = useTranslation()
const toast = useToast()
const is12h = useSettingsStore(s => s.settings.time_format) === '12h' const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
const can = useCanDo() const can = useCanDo()
const trip = useTripStore((s) => s.trip) const trip = useTripStore((s) => s.trip)
@@ -569,8 +571,8 @@ function useCollabChat(tripId: any, currentUser: any) {
if (textareaRef.current) textareaRef.current.style.height = 'auto' if (textareaRef.current) textareaRef.current.style.height = 'auto'
isAtBottom.current = true isAtBottom.current = true
setTimeout(() => scrollToBottom('smooth'), 50) setTimeout(() => scrollToBottom('smooth'), 50)
} catch {} finally { setSending(false) } } catch { toast.error(t('common.error')) } finally { setSending(false) }
}, [text, sending, replyTo, tripId, scrollToBottom]) }, [text, sending, replyTo, tripId, scrollToBottom, toast, t])
const handleKeyDown = useCallback((e) => { const handleKeyDown = useCallback((e) => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend() } if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend() }
@@ -581,23 +583,23 @@ function useCollabChat(tripId: any, currentUser: any) {
requestAnimationFrame(() => { requestAnimationFrame(() => {
setDeletingIds(prev => new Set(prev).add(msgId)) setDeletingIds(prev => new Set(prev).add(msgId))
}) })
const t = setTimeout(async () => { const timer = setTimeout(async () => {
try { try {
await collabApi.deleteMessage(tripId, msgId) await collabApi.deleteMessage(tripId, msgId)
setMessages(prev => prev.map(m => m.id === msgId ? { ...m, _deleted: true } : m)) setMessages(prev => prev.map(m => m.id === msgId ? { ...m, _deleted: true } : m))
} catch {} } catch { toast.error(t('common.error')) }
setDeletingIds(prev => { const s = new Set(prev); s.delete(msgId); return s }) setDeletingIds(prev => { const s = new Set(prev); s.delete(msgId); return s })
}, 400) }, 400)
deleteTimersRef.current.push(t) deleteTimersRef.current.push(timer)
}, [tripId]) }, [tripId, toast, t])
const handleReact = useCallback(async (msgId, emoji) => { const handleReact = useCallback(async (msgId, emoji) => {
setReactMenu(null) setReactMenu(null)
try { try {
const data = await collabApi.reactMessage(tripId, msgId, emoji) const data = await collabApi.reactMessage(tripId, msgId, emoji)
setMessages(prev => prev.map(m => m.id === msgId ? { ...m, reactions: data.reactions } : m)) setMessages(prev => prev.map(m => m.id === msgId ? { ...m, reactions: data.reactions } : m))
} catch {} } catch { toast.error(t('common.error')) }
}, [tripId]) }, [tripId, toast, t])
const handleEmojiSelect = useCallback((emoji) => { const handleEmojiSelect = useCallback((emoji) => {
setText(prev => prev + emoji) setText(prev => prev + emoji)
+30 -11
View File
@@ -12,6 +12,7 @@ import { useCanDo } from '../../store/permissionsStore'
import { useTripStore } from '../../store/tripStore' import { useTripStore } from '../../store/tripStore'
import { addListener, removeListener } from '../../api/websocket' import { addListener, removeListener } from '../../api/websocket'
import { useTranslation } from '../../i18n' import { useTranslation } from '../../i18n'
import { useToast } from '../shared/Toast'
import type { User } from '../../types' import type { User } from '../../types'
interface NoteFile { interface NoteFile {
@@ -916,6 +917,7 @@ interface CollabNotesProps {
*/ */
function useCollabNotes({ tripId, currentUser }: CollabNotesProps) { function useCollabNotes({ tripId, currentUser }: CollabNotesProps) {
const { t } = useTranslation() const { t } = useTranslation()
const toast = useToast()
const can = useCanDo() const can = useCanDo()
const trip = useTripStore((s) => s.trip) const trip = useTripStore((s) => s.trip)
const canEdit = can('collab_edit', trip) const canEdit = can('collab_edit', trip)
@@ -996,7 +998,13 @@ function useCollabNotes({ tripId, currentUser }: CollabNotesProps) {
const handleCreateNote = useCallback(async (data) => { const handleCreateNote = useCallback(async (data) => {
const pendingFiles = data._pendingFiles || [] const pendingFiles = data._pendingFiles || []
delete data._pendingFiles delete data._pendingFiles
const created = await collabApi.createNote(tripId, data) let created
try {
created = await collabApi.createNote(tripId, data)
} catch (err) {
toast.error(t('common.error'))
throw err
}
if (created) { if (created) {
const note = created.note || created const note = created.note || created
// Upload pending files // Upload pending files
@@ -1004,7 +1012,7 @@ function useCollabNotes({ tripId, currentUser }: CollabNotesProps) {
for (const file of pendingFiles) { for (const file of pendingFiles) {
const fd = new FormData() const fd = new FormData()
fd.append('file', file) fd.append('file', file)
try { await collabApi.uploadNoteFile(tripId, note.id, fd) } catch (err) { console.error('Failed to upload note attachment:', err) } try { await collabApi.uploadNoteFile(tripId, note.id, fd) } catch (err) { console.error('Failed to upload note attachment:', err); toast.error(t('common.error')) }
} }
// Reload note with attachments // Reload note with attachments
const fresh = await collabApi.getNotes(tripId) const fresh = await collabApi.getNotes(tripId)
@@ -1017,17 +1025,23 @@ function useCollabNotes({ tripId, currentUser }: CollabNotesProps) {
return [note, ...prev] return [note, ...prev]
}) })
} }
}, [tripId]) }, [tripId, toast, t])
const handleUpdateNote = useCallback(async (noteId, data) => { const handleUpdateNote = useCallback(async (noteId, data) => {
const result = await collabApi.updateNote(tripId, noteId, data) let result
try {
result = await collabApi.updateNote(tripId, noteId, data)
} catch (err) {
toast.error(t('common.error'))
throw err
}
const updated = result?.note || result const updated = result?.note || result
if (updated) { if (updated) {
setNotes(prev => setNotes(prev =>
prev.map(n => (n.id === noteId ? { ...n, ...updated } : n)) prev.map(n => (n.id === noteId ? { ...n, ...updated } : n))
) )
} }
}, [tripId]) }, [tripId, toast, t])
const saveCategoryColors = useCallback(async (newMap) => { const saveCategoryColors = useCallback(async (newMap) => {
// Update notes with changed colors // Update notes with changed colors
@@ -1058,24 +1072,29 @@ function useCollabNotes({ tripId, currentUser }: CollabNotesProps) {
for (const file of pendingFiles) { for (const file of pendingFiles) {
const fd = new FormData() const fd = new FormData()
fd.append('file', file) fd.append('file', file)
try { await collabApi.uploadNoteFile(tripId, editingNote.id, fd) } catch {} try { await collabApi.uploadNoteFile(tripId, editingNote.id, fd) } catch { toast.error(t('common.error')) }
} }
const fresh = await collabApi.getNotes(tripId) const fresh = await collabApi.getNotes(tripId)
if (fresh?.notes) setNotes(fresh.notes) if (fresh?.notes) setNotes(fresh.notes)
window.dispatchEvent(new Event('collab-files-changed')) window.dispatchEvent(new Event('collab-files-changed'))
} }
}, [editingNote, tripId, handleUpdateNote]) }, [editingNote, tripId, handleUpdateNote, toast, t])
const handleDeleteNoteFile = useCallback(async (noteId, fileId) => { const handleDeleteNoteFile = useCallback(async (noteId, fileId) => {
try { await collabApi.deleteNoteFile(tripId, noteId, fileId) } catch {} try { await collabApi.deleteNoteFile(tripId, noteId, fileId) } catch { toast.error(t('common.error')) }
window.dispatchEvent(new Event('collab-files-changed')) window.dispatchEvent(new Event('collab-files-changed'))
}, [tripId]) }, [tripId, toast, t])
const handleDeleteNote = useCallback(async (noteId) => { const handleDeleteNote = useCallback(async (noteId) => {
await collabApi.deleteNote(tripId, noteId) try {
await collabApi.deleteNote(tripId, noteId)
} catch (err) {
toast.error(t('common.error'))
throw err
}
setNotes(prev => prev.filter(n => n.id !== noteId)) setNotes(prev => prev.filter(n => n.id !== noteId))
window.dispatchEvent(new Event('collab-files-changed')) window.dispatchEvent(new Event('collab-files-changed'))
}, [tripId]) }, [tripId, toast, t])
// ── Derived data ── // ── Derived data ──
const categories = [...new Set(notes.map(n => n.category).filter(Boolean))] const categories = [...new Set(notes.map(n => n.category).filter(Boolean))]
+24 -11
View File
@@ -3,6 +3,7 @@ import { Plus, Trash2, X, Check, BarChart3, Lock, Clock } from 'lucide-react'
import { collabApi } from '../../api/client' import { collabApi } from '../../api/client'
import { addListener, removeListener } from '../../api/websocket' import { addListener, removeListener } from '../../api/websocket'
import { useTranslation } from '../../i18n' import { useTranslation } from '../../i18n'
import { useToast } from '../shared/Toast'
import { useCanDo } from '../../store/permissionsStore' import { useCanDo } from '../../store/permissionsStore'
import { useTripStore } from '../../store/tripStore' import { useTripStore } from '../../store/tripStore'
import ReactDOM from 'react-dom' import ReactDOM from 'react-dom'
@@ -342,6 +343,7 @@ interface CollabPollsProps {
export default function CollabPolls({ tripId, currentUser }: CollabPollsProps) { export default function CollabPolls({ tripId, currentUser }: CollabPollsProps) {
const { t } = useTranslation() const { t } = useTranslation()
const toast = useToast()
const can = useCanDo() const can = useCanDo()
const trip = useTripStore((s) => s.trip) const trip = useTripStore((s) => s.trip)
const canEdit = can('collab_edit', trip) const canEdit = can('collab_edit', trip)
@@ -378,33 +380,44 @@ export default function CollabPolls({ tripId, currentUser }: CollabPollsProps) {
}, []) }, [])
const handleCreate = useCallback(async (data) => { const handleCreate = useCallback(async (data) => {
const result = await collabApi.createPoll(tripId, data) try {
const created = result.poll || result const result = await collabApi.createPoll(tripId, data)
setPolls(prev => prev.some(p => p.id === created.id) ? prev : [created, ...prev]) const created = result.poll || result
setShowForm(false) setPolls(prev => prev.some(p => p.id === created.id) ? prev : [created, ...prev])
}, [tripId]) setShowForm(false)
} catch (err) {
toast.error(t('common.error'))
throw err
}
}, [tripId, toast, t])
const handleVote = useCallback(async (pollId, optionIndex) => { const handleVote = useCallback(async (pollId, optionIndex) => {
try { try {
const result = await collabApi.votePoll(tripId, pollId, optionIndex) const result = await collabApi.votePoll(tripId, pollId, optionIndex)
const updated = result.poll || result const updated = result.poll || result
setPolls(prev => prev.map(p => p.id === updated.id ? updated : p)) setPolls(prev => prev.map(p => p.id === updated.id ? updated : p))
} catch {} } catch {
}, [tripId]) toast.error(t('common.error'))
}
}, [tripId, toast, t])
const handleClose = useCallback(async (pollId) => { const handleClose = useCallback(async (pollId) => {
try { try {
await collabApi.closePoll(tripId, pollId) await collabApi.closePoll(tripId, pollId)
setPolls(prev => prev.map(p => p.id === pollId ? { ...p, is_closed: true } : p)) setPolls(prev => prev.map(p => p.id === pollId ? { ...p, is_closed: true } : p))
} catch {} } catch {
}, [tripId]) toast.error(t('common.error'))
}
}, [tripId, toast, t])
const handleDelete = useCallback(async (pollId) => { const handleDelete = useCallback(async (pollId) => {
try { try {
await collabApi.deletePoll(tripId, pollId) await collabApi.deletePoll(tripId, pollId)
setPolls(prev => prev.filter(p => p.id !== pollId)) setPolls(prev => prev.filter(p => p.id !== pollId))
} catch {} } catch {
}, [tripId]) toast.error(t('common.error'))
}
}, [tripId, toast, t])
const activePolls = polls.filter(p => !p.is_closed && !isExpired(p.deadline)) const activePolls = polls.filter(p => !p.is_closed && !isExpired(p.deadline))
const closedPolls = polls.filter(p => p.is_closed || isExpired(p.deadline)) const closedPolls = polls.filter(p => p.is_closed || isExpired(p.deadline))
@@ -0,0 +1,136 @@
import { useEffect, useState } from 'react'
import { X, Check, UserPlus } from 'lucide-react'
import { journeyApi, authApi } from '../../api/client'
import { useTranslation } from '../../i18n'
import { useToast } from '../shared/Toast'
export default function ContributorInviteDialog({ journeyId, existingUserIds, onClose, onInvited }: {
journeyId: number
existingUserIds: number[]
onClose: () => void
onInvited: () => void
}) {
const { t } = useTranslation()
const [users, setUsers] = useState<{ id: number; username: string; email: string; avatar?: string | null }[]>([])
const [search, setSearch] = useState('')
const [selectedUserId, setSelectedUserId] = useState<number | null>(null)
const [role, setRole] = useState<'editor' | 'viewer'>('viewer')
const [sending, setSending] = useState(false)
const toast = useToast()
useEffect(() => {
authApi.listUsers().then(d => setUsers(d.users || [])).catch(() => {})
}, [])
const filtered = users.filter(u => {
if (existingUserIds.includes(u.id)) return false
if (!search) return true
const q = search.toLowerCase()
return u.username.toLowerCase().includes(q) || u.email.toLowerCase().includes(q)
})
const handleInvite = async () => {
if (!selectedUserId) return
setSending(true)
try {
await journeyApi.addContributor(journeyId, selectedUserId, role)
toast.success(t('journey.contributors.added'))
onInvited()
} catch {
toast.error(t('journey.contributors.addFailed'))
} finally {
setSending(false)
}
}
return (
<div className="fixed inset-0 z-[200] flex items-center justify-center p-5" style={{ background: 'rgba(9,9,11,0.75)' }}>
<div className="bg-white dark:bg-zinc-900 rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] max-w-[420px] w-full flex flex-col overflow-hidden">
<div className="flex items-center justify-between px-6 py-4 border-b border-zinc-200 dark:border-zinc-700">
<h2 className="text-[16px] font-bold text-zinc-900 dark:text-white">{t('journey.contributors.invite')}</h2>
<button onClick={onClose} className="w-8 h-8 rounded-lg flex items-center justify-center text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800">
<X size={16} />
</button>
</div>
<div className="px-6 py-5 flex flex-col gap-4">
{/* Search */}
<div>
<label className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500 block mb-1.5">{t('journey.contributors.searchUser')}</label>
<input
value={search}
onChange={e => setSearch(e.target.value)}
placeholder={t('journey.contributors.searchPlaceholder')}
className="w-full px-3 py-2 border border-zinc-200 dark:border-zinc-700 rounded-lg text-[13px] bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white outline-none focus:border-zinc-400 dark:focus:border-zinc-500"
/>
</div>
{/* User list */}
<div className="max-h-[200px] overflow-y-auto flex flex-col gap-1">
{filtered.length === 0 && (
<p className="text-[12px] text-zinc-400 text-center py-4">{t('journey.contributors.noUsers')}</p>
)}
{filtered.map(u => (
<div
key={u.id}
onClick={() => setSelectedUserId(u.id)}
className={`flex items-center gap-2.5 p-2.5 rounded-lg cursor-pointer transition-all ${
selectedUserId === u.id
? 'bg-zinc-100 dark:bg-zinc-800 border border-zinc-900 dark:border-white'
: 'hover:bg-zinc-50 dark:hover:bg-zinc-800 border border-transparent'
}`}
>
<div className="w-8 h-8 rounded-full bg-zinc-200 dark:bg-zinc-700 text-zinc-600 dark:text-zinc-300 flex items-center justify-center text-[12px] font-semibold">
{u.username[0].toUpperCase()}
</div>
<div className="flex-1 min-w-0">
<div className="text-[13px] font-medium text-zinc-900 dark:text-white">{u.username}</div>
<div className="text-[11px] text-zinc-500 truncate">{u.email}</div>
</div>
{selectedUserId === u.id && (
<div className="w-5 h-5 rounded-full bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 flex items-center justify-center">
<Check size={12} />
</div>
)}
</div>
))}
</div>
{/* Role selector */}
<div>
<label className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500 block mb-2">{t('journey.invite.role')}</label>
<div className="flex gap-2">
{(['viewer', 'editor'] as const).map(r => (
<button
key={r}
onClick={() => setRole(r)}
className={`flex-1 py-2 rounded-lg text-[12px] font-medium border transition-all ${
role === r
? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 border-zinc-900 dark:border-white'
: 'border-zinc-200 dark:border-zinc-700 text-zinc-500 hover:border-zinc-400'
}`}
>
{t(`journey.invite.${r}`)}
</button>
))}
</div>
</div>
</div>
<div className="flex items-center justify-end gap-2 px-6 py-4 border-t border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/50">
<button onClick={onClose} className="px-3.5 py-2 rounded-lg border border-zinc-200 dark:border-zinc-600 text-[13px] font-medium text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-700">
{t('common.cancel')}
</button>
<button
onClick={handleInvite}
disabled={!selectedUserId || sending}
className="px-3.5 py-2 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[13px] font-medium hover:bg-zinc-800 dark:hover:bg-zinc-100 disabled:opacity-40 disabled:cursor-not-allowed"
>
{sending ? t('journey.invite.inviting') : t('journey.invite.invite')}
</button>
</div>
</div>
</div>
)
}
@@ -0,0 +1,112 @@
import { useEffect, useState } from 'react'
import { Link, List, Grid, MapPin, Check } from 'lucide-react'
import { journeyApi } from '../../api/client'
import { useTranslation } from '../../i18n'
import { useToast } from '../shared/Toast'
export default function JourneyShareSection({ journeyId }: { journeyId: number }) {
const { t } = useTranslation()
const [link, setLink] = useState<{ token: string; share_timeline: boolean; share_gallery: boolean; share_map: boolean } | null>(null)
const [loading, setLoading] = useState(true)
const [copied, setCopied] = useState(false)
const toast = useToast()
useEffect(() => {
journeyApi.getShareLink(journeyId).then(d => setLink(d.link || null)).catch(() => {}).finally(() => setLoading(false))
}, [journeyId])
const createLink = async () => {
try {
const res = await journeyApi.createShareLink(journeyId, { share_timeline: true, share_gallery: true, share_map: true })
setLink({ token: res.token, share_timeline: true, share_gallery: true, share_map: true })
toast.success(t('journey.share.linkCreated'))
} catch { toast.error(t('journey.share.createFailed')) }
}
const togglePerm = async (key: 'share_timeline' | 'share_gallery' | 'share_map') => {
if (!link) return
const updated = { ...link, [key]: !link[key] }
setLink(updated)
try {
await journeyApi.createShareLink(journeyId, { share_timeline: updated.share_timeline, share_gallery: updated.share_gallery, share_map: updated.share_map })
} catch { setLink(link); toast.error(t('journey.share.updateFailed')) }
}
const deleteLink = async () => {
try {
await journeyApi.deleteShareLink(journeyId)
setLink(null)
toast.success(t('journey.share.linkDeleted'))
} catch { toast.error(t('journey.share.deleteFailed')) }
}
const shareUrl = link ? `${window.location.origin}/public/journey/${link.token}` : ''
const copyLink = () => {
navigator.clipboard.writeText(shareUrl)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
if (loading) return null
return (
<div>
<label className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500 block mb-2">{t('journey.share.publicShare')}</label>
{!link ? (
<button
onClick={createLink}
className="w-full flex items-center justify-center gap-1.5 py-2.5 rounded-lg border border-dashed border-zinc-300 dark:border-zinc-600 text-[12px] font-medium text-zinc-500 hover:border-zinc-400 hover:text-zinc-700 dark:hover:border-zinc-500 dark:hover:text-zinc-300 transition-colors"
>
<Link size={14} /> {t('journey.share.createLink')}
</button>
) : (
<div className="flex flex-col gap-3">
{/* URL + Copy */}
<div className="flex items-center gap-2 p-2.5 rounded-lg bg-zinc-50 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700">
<Link size={13} className="text-zinc-400 flex-shrink-0" />
<span className="flex-1 text-[11px] text-zinc-600 dark:text-zinc-400 truncate">{shareUrl}</span>
<button
onClick={copyLink}
className="flex-shrink-0 px-2.5 py-1 rounded-md bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[11px] font-medium hover:bg-zinc-700 dark:hover:bg-zinc-200"
>
{copied ? t('journey.share.copied') : t('journey.share.copy')}
</button>
</div>
{/* Permission toggles */}
<div className="flex flex-col gap-1.5">
{[
{ key: 'share_timeline' as const, label: t('journey.share.timeline'), icon: List },
{ key: 'share_gallery' as const, label: t('journey.share.gallery'), icon: Grid },
{ key: 'share_map' as const, label: t('journey.share.map'), icon: MapPin },
].map(({ key, label, icon: Icon }) => (
<button
key={key}
onClick={() => togglePerm(key)}
className={`flex items-center gap-2.5 px-3 py-2 rounded-lg border text-[12px] font-medium transition-all ${
link[key]
? 'border-zinc-900 dark:border-white bg-zinc-900 dark:bg-white text-white dark:text-zinc-900'
: 'border-zinc-200 dark:border-zinc-700 text-zinc-500 hover:border-zinc-400'
}`}
>
<Icon size={13} />
{label}
{link[key] && <Check size={12} className="ml-auto" />}
</button>
))}
</div>
{/* Delete link */}
<button
onClick={deleteLink}
className="text-[11px] font-medium text-red-500 hover:text-red-600 self-start"
>
{t('share.deleteLink')}
</button>
</div>
)}
</div>
)
}
@@ -45,8 +45,7 @@ export default function InAppNotificationBell(): React.ReactElement {
<button <button
onClick={handleOpen} onClick={handleOpen}
title={t('notifications.title')} title={t('notifications.title')}
className="relative p-2 rounded-lg transition-colors" className="relative p-2 rounded-lg transition-colors text-content-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'}
> >
@@ -72,7 +71,7 @@ export default function InAppNotificationBell(): React.ReactElement {
<> <>
<div style={{ position: 'fixed', inset: 0, zIndex: 9998 }} onClick={() => setOpen(false)} /> <div style={{ position: 'fixed', inset: 0, zIndex: 9998 }} onClick={() => setOpen(false)} />
<div <div
className="rounded-xl shadow-xl border overflow-hidden" className="rounded-xl shadow-xl border overflow-hidden bg-surface-card border-edge"
style={{ style={{
position: 'fixed', position: 'fixed',
top: 'var(--nav-h)', top: 'var(--nav-h)',
@@ -81,18 +80,15 @@ export default function InAppNotificationBell(): React.ReactElement {
maxWidth: 'calc(100vw - 16px)', maxWidth: 'calc(100vw - 16px)',
maxHeight: 'min(480px, calc(100vh - var(--nav-h) - 16px))', maxHeight: 'min(480px, calc(100vh - var(--nav-h) - 16px))',
zIndex: 9999, zIndex: 9999,
background: 'var(--bg-card)',
borderColor: 'var(--border-primary)',
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
}} }}
> >
{/* Header */} {/* Header */}
<div <div
className="flex items-center justify-between px-4 py-3 flex-shrink-0" className="flex items-center justify-between px-4 py-3 flex-shrink-0 border-b border-edge-secondary"
style={{ borderBottom: '1px solid var(--border-secondary)' }}
> >
<span className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}> <span className="text-sm font-semibold text-content">
{t('notifications.title')} {t('notifications.title')}
{unreadCount > 0 && ( {unreadCount > 0 && (
<span className="ml-2 px-1.5 py-0.5 rounded-full text-xs font-medium" <span className="ml-2 px-1.5 py-0.5 rounded-full text-xs font-medium"
@@ -106,8 +102,7 @@ export default function InAppNotificationBell(): React.ReactElement {
<button <button
onClick={markAllRead} onClick={markAllRead}
title={t('notifications.markAllRead')} title={t('notifications.markAllRead')}
className="p-1.5 rounded-lg transition-colors" className="p-1.5 rounded-lg transition-colors text-content-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'}
> >
@@ -118,8 +113,7 @@ export default function InAppNotificationBell(): React.ReactElement {
<button <button
onClick={deleteAll} onClick={deleteAll}
title={t('notifications.deleteAll')} title={t('notifications.deleteAll')}
className="p-1.5 rounded-lg transition-colors" className="p-1.5 rounded-lg transition-colors text-content-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'}
> >
@@ -137,9 +131,9 @@ export default function InAppNotificationBell(): React.ReactElement {
</div> </div>
) : notifications.length === 0 ? ( ) : notifications.length === 0 ? (
<div className="flex flex-col items-center justify-center py-10 px-4 text-center gap-2"> <div className="flex flex-col items-center justify-center py-10 px-4 text-center gap-2">
<Bell className="w-8 h-8" style={{ color: 'var(--text-faint)' }} /> <Bell className="w-8 h-8 text-content-faint" />
<p className="text-sm font-medium" style={{ color: 'var(--text-muted)' }}>{t('notifications.empty')}</p> <p className="text-sm font-medium text-content-muted">{t('notifications.empty')}</p>
<p className="text-xs" style={{ color: 'var(--text-faint)' }}>{t('notifications.emptyDescription')}</p> <p className="text-xs text-content-faint">{t('notifications.emptyDescription')}</p>
</div> </div>
) : ( ) : (
notifications.slice(0, 10).map(n => ( notifications.slice(0, 10).map(n => (
@@ -151,10 +145,8 @@ export default function InAppNotificationBell(): React.ReactElement {
{/* Footer */} {/* Footer */}
<button <button
onClick={handleShowAll} onClick={handleShowAll}
className="w-full py-2.5 text-xs font-medium transition-colors flex-shrink-0" className="w-full py-2.5 text-xs font-medium transition-colors flex-shrink-0 border-t border-edge-secondary text-content"
style={{ style={{
borderTop: '1px solid var(--border-secondary)',
color: 'var(--text-primary)',
background: 'transparent', background: 'transparent',
}} }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'} onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
+17 -21
View File
@@ -110,8 +110,7 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
<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="trek-back-btn 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 text-content-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="trek-back-icon w-4 h-4" /> <ArrowLeft className="trek-back-icon w-4 h-4" />
@@ -126,8 +125,8 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
{tripTitle && ( {tripTitle && (
<> <>
<span className="hidden sm:inline" style={{ color: 'var(--text-faint)' }}>/</span> <span className="hidden sm:inline text-content-faint">/</span>
<span className="hidden sm:inline text-sm font-medium truncate max-w-48" style={{ color: 'var(--text-muted)' }}> <span className="hidden sm:inline text-sm font-medium truncate max-w-48 text-content-muted">
{tripTitle} {tripTitle}
</span> </span>
</> </>
@@ -176,8 +175,8 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
{/* Share button */} {/* Share button */}
{onShare && ( {onShare && (
<button onClick={onShare} <button onClick={onShare}
className="flex items-center gap-1.5 py-1.5 px-3 rounded-lg border transition-colors text-sm font-medium flex-shrink-0" className="flex items-center gap-1.5 py-1.5 px-3 rounded-lg border transition-colors text-sm font-medium flex-shrink-0 border-edge text-content-secondary"
style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)', background: 'var(--bg-card)' }} style={{ background: 'var(--bg-card)' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'} onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-card)'}> onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-card)'}>
<Users className="w-4 h-4" /> <Users className="w-4 h-4" />
@@ -198,8 +197,7 @@ 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 relative w-8 h-8 items-center justify-center" className="p-2 rounded-lg transition-colors flex-shrink-0 hidden sm:flex relative w-8 h-8 items-center justify-center text-content-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'}>
<Sun className="w-4 h-4 absolute transition-[transform,opacity] duration-300 ease-[cubic-bezier(0.23,1,0.32,1)]" <Sun className="w-4 h-4 absolute transition-[transform,opacity] duration-300 ease-[cubic-bezier(0.23,1,0.32,1)]"
@@ -227,21 +225,21 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
{user.username?.charAt(0).toUpperCase()} {user.username?.charAt(0).toUpperCase()}
</div> </div>
)} )}
<span className="text-sm hidden sm:inline max-w-24 truncate" style={{ color: 'var(--text-secondary)' }}> <span className="text-sm hidden sm:inline max-w-24 truncate text-content-secondary">
{user.username} {user.username}
</span> </span>
<ChevronDown className="w-4 h-4" style={{ color: 'var(--text-faint)' }} /> <ChevronDown className="w-4 h-4 text-content-faint" />
</button> </button>
{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="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="trek-menu-enter w-52 rounded-xl shadow-xl border overflow-hidden bg-surface-card border-edge" style={{ position: 'fixed', top: 'var(--nav-h)', right: 8, zIndex: 9999 }}>
<div className="px-4 py-3 border-b" style={{ borderColor: 'var(--border-secondary)' }}> <div className="px-4 py-3 border-b border-edge-secondary">
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>{user.username}</p> <p className="text-sm font-medium text-content">{user.username}</p>
<p className="text-xs truncate" style={{ color: 'var(--text-muted)' }}>{user.email}</p> <p className="text-xs truncate text-content-muted">{user.email}</p>
{user.role === 'admin' && ( {user.role === 'admin' && (
<span className="inline-flex items-center gap-1 text-xs font-medium mt-1" style={{ color: 'var(--text-secondary)' }}> <span className="inline-flex items-center gap-1 text-xs font-medium mt-1 text-content-secondary">
<Shield className="w-3 h-3" /> {t('nav.administrator')} <Shield className="w-3 h-3" /> {t('nav.administrator')}
</span> </span>
)} )}
@@ -249,8 +247,7 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
<div className="py-1"> <div className="py-1">
<Link to="/settings" onClick={() => setUserMenuOpen(false)} <Link to="/settings" onClick={() => setUserMenuOpen(false)}
className="flex items-center gap-2 px-4 py-2 text-sm transition-colors" className="flex items-center gap-2 px-4 py-2 text-sm transition-colors text-content-secondary"
style={{ color: 'var(--text-secondary)' }}
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'}>
<Settings className="w-4 h-4" /> <Settings className="w-4 h-4" />
@@ -259,8 +256,7 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
{user.role === 'admin' && ( {user.role === 'admin' && (
<Link to="/admin" onClick={() => setUserMenuOpen(false)} <Link to="/admin" onClick={() => setUserMenuOpen(false)}
className="flex items-center gap-2 px-4 py-2 text-sm transition-colors" className="flex items-center gap-2 px-4 py-2 text-sm transition-colors text-content-secondary"
style={{ color: 'var(--text-secondary)' }}
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'}>
<Shield className="w-4 h-4" /> <Shield className="w-4 h-4" />
@@ -269,14 +265,14 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
)} )}
</div> </div>
<div className="py-1 border-t" style={{ borderColor: 'var(--border-secondary)' }}> <div className="py-1 border-t border-edge-secondary">
<button onClick={handleLogout} <button onClick={handleLogout}
className="flex items-center gap-2 w-full px-4 py-2 text-sm text-red-500 hover:bg-red-500/10 transition-colors"> className="flex items-center gap-2 w-full px-4 py-2 text-sm text-red-500 hover:bg-red-500/10 transition-colors">
<LogOut className="w-4 h-4" /> <LogOut className="w-4 h-4" />
{t('nav.logout')} {t('nav.logout')}
</button> </button>
{appVersion && ( {appVersion && (
<div className="px-4 pt-2 pb-2.5 text-center" style={{ marginTop: 4, borderTop: '1px solid var(--border-secondary)' }}> <div className="px-4 pt-2 pb-2.5 text-center border-t border-edge-secondary" style={{ marginTop: 4 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6 }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6 }}>
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 5, background: 'var(--bg-tertiary)', borderRadius: 99, padding: '4px 12px' }}> <div style={{ display: 'inline-flex', alignItems: 'center', gap: 5, background: 'var(--bg-tertiary)', borderRadius: 99, padding: '4px 12px' }}>
<img src={dark ? '/text-light.svg' : '/text-dark.svg'} alt="TREK" style={{ height: 10, opacity: 0.5 }} /> <img src={dark ? '/text-light.svg' : '/text-dark.svg'} alt="TREK" style={{ height: 10, opacity: 0.5 }} />
+10 -21
View File
@@ -47,27 +47,23 @@ export default function PageSidebar({
return ( return (
<div <div
className="rounded-2xl overflow-hidden flex flex-col lg:flex-row relative" className="rounded-2xl overflow-hidden flex flex-col lg:flex-row relative bg-surface-card border border-edge"
style={{ style={{
background: 'var(--bg-card)',
border: '1px solid var(--border-primary)',
minHeight: 'min(820px, calc(100vh - var(--nav-h) - 120px))', minHeight: 'min(820px, calc(100vh - var(--nav-h) - 120px))',
}} }}
> >
{/* Mobile top bar with hamburger */} {/* Mobile top bar with hamburger */}
<div <div
className="lg:hidden flex items-center justify-between px-4 py-3 border-b" className="lg:hidden flex items-center justify-between px-4 py-3 border-b border-edge"
style={{ borderColor: 'var(--border-primary)' }}
> >
<button <button
onClick={() => setMobileOpen(true)} onClick={() => setMobileOpen(true)}
className="w-9 h-9 rounded-lg flex items-center justify-center transition-colors hover:bg-[var(--bg-hover)]" className="w-9 h-9 rounded-lg flex items-center justify-center transition-colors hover:bg-[var(--bg-hover)] text-content"
aria-label="Open navigation" aria-label="Open navigation"
style={{ color: 'var(--text-primary)' }}
> >
<Menu size={18} /> <Menu size={18} />
</button> </button>
<div className="flex items-center gap-2 text-sm font-semibold" style={{ color: 'var(--text-primary)' }}> <div className="flex items-center gap-2 text-sm font-semibold text-content">
{activeLabel} {activeLabel}
</div> </div>
<div className="w-9" /> <div className="w-9" />
@@ -75,11 +71,9 @@ export default function PageSidebar({
{/* Desktop sidebar (always visible on lg) */} {/* Desktop sidebar (always visible on lg) */}
<aside <aside
className="hidden lg:flex flex-col shrink-0 relative" className="hidden lg:flex flex-col shrink-0 relative bg-surface-secondary border-r border-edge"
style={{ style={{
width: 260, width: 260,
background: 'var(--bg-secondary)',
borderRight: '1px solid var(--border-primary)',
padding: '24px 14px', padding: '24px 14px',
}} }}
> >
@@ -102,25 +96,22 @@ export default function PageSidebar({
/> />
<aside <aside
ref={drawerRef} ref={drawerRef}
className="lg:hidden fixed top-0 left-0 bottom-0 z-50 flex flex-col shadow-2xl" className="lg:hidden fixed top-0 left-0 bottom-0 z-50 flex flex-col shadow-2xl bg-surface-secondary"
style={{ style={{
width: 280, width: 280,
background: 'var(--bg-secondary)',
padding: '18px 14px', padding: '18px 14px',
}} }}
> >
<div className="flex items-center justify-between mb-3 px-2"> <div className="flex items-center justify-between mb-3 px-2">
<span <span
className="text-[11px] font-bold tracking-widest uppercase" className="text-[11px] font-bold tracking-widest uppercase text-content-muted"
style={{ color: 'var(--text-muted)' }}
> >
{sidebarLabel} {sidebarLabel}
</span> </span>
<button <button
onClick={() => setMobileOpen(false)} onClick={() => setMobileOpen(false)}
className="w-8 h-8 rounded-lg flex items-center justify-center transition-colors hover:bg-[var(--bg-hover)]" className="w-8 h-8 rounded-lg flex items-center justify-center transition-colors hover:bg-[var(--bg-hover)] text-content"
aria-label="Close navigation" aria-label="Close navigation"
style={{ color: 'var(--text-primary)' }}
> >
<X size={16} /> <X size={16} />
</button> </button>
@@ -164,8 +155,7 @@ function SidebarInner({
<> <>
{sidebarLabel && ( {sidebarLabel && (
<div <div
className="text-[11px] font-bold tracking-widest uppercase mb-3 px-3" className="text-[11px] font-bold tracking-widest uppercase mb-3 px-3 text-content-muted"
style={{ color: 'var(--text-muted)' }}
> >
{sidebarLabel} {sidebarLabel}
</div> </div>
@@ -199,8 +189,7 @@ function SidebarInner({
</nav> </nav>
{footer && ( {footer && (
<div <div
className="mt-4 pt-3 px-3 text-[10px] tracking-wide" className="mt-4 pt-3 px-3 text-[10px] tracking-wide text-content-faint border-t border-edge"
style={{ color: 'var(--text-faint)', borderTop: '1px solid var(--border-primary)' }}
> >
{footer} {footer}
</div> </div>
@@ -421,7 +421,7 @@ export default function ReservationOverlay({ reservations, showConnections, show
> >
<Tooltip direction="top" offset={[0, -8]} opacity={1} className="map-tooltip"> <Tooltip direction="top" offset={[0, -8]} opacity={1} className="map-tooltip">
<div style={{ fontWeight: 600, fontSize: 12 }}>{item.from.name}</div> <div style={{ fontWeight: 600, fontSize: 12 }}>{item.from.name}</div>
{item.res.title && <div style={{ fontSize: 11, color: 'var(--text-muted)' }}>{item.res.title}</div>} {item.res.title && <div className="text-content-muted" style={{ fontSize: 11 }}>{item.res.title}</div>}
</Tooltip> </Tooltip>
</Marker>, </Marker>,
<Marker <Marker
@@ -434,7 +434,7 @@ export default function ReservationOverlay({ reservations, showConnections, show
> >
<Tooltip direction="top" offset={[0, -8]} opacity={1} className="map-tooltip"> <Tooltip direction="top" offset={[0, -8]} opacity={1} className="map-tooltip">
<div style={{ fontWeight: 600, fontSize: 12 }}>{item.to.name}</div> <div style={{ fontWeight: 600, fontSize: 12 }}>{item.to.name}</div>
{item.res.title && <div style={{ fontSize: 11, color: 'var(--text-muted)' }}>{item.res.title}</div>} {item.res.title && <div className="text-content-muted" style={{ fontSize: 11 }}>{item.res.title}</div>}
</Tooltip> </Tooltip>
</Marker>, </Marker>,
])} ])}
@@ -53,10 +53,9 @@ export default function InAppNotificationItem({ notification, onClose }: Notific
return ( return (
<div <div
className="relative px-4 py-3 transition-colors" className="relative px-4 py-3 transition-colors border-b border-edge-secondary"
style={{ style={{
background: notification.is_read ? 'transparent' : (dark ? 'rgba(99,102,241,0.07)' : 'rgba(99,102,241,0.05)'), background: notification.is_read ? 'transparent' : (dark ? 'rgba(99,102,241,0.07)' : 'rgba(99,102,241,0.05)'),
borderBottom: '1px solid var(--border-secondary)',
}} }}
> >
@@ -71,8 +70,8 @@ export default function InAppNotificationItem({ notification, onClose }: Notific
/> />
) : ( ) : (
<div <div
className="w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold" className="w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold text-content-muted"
style={{ background: dark ? '#27272a' : '#f1f5f9', color: 'var(--text-muted)' }} style={{ background: dark ? '#27272a' : '#f1f5f9' }}
> >
{notification.sender_username {notification.sender_username
? notification.sender_username.charAt(0).toUpperCase() ? notification.sender_username.charAt(0).toUpperCase()
@@ -85,11 +84,11 @@ export default function InAppNotificationItem({ notification, onClose }: Notific
{/* Content */} {/* Content */}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-2">
<p className="text-sm font-medium leading-snug" style={{ color: 'var(--text-primary)' }}> <p className="text-sm font-medium leading-snug text-content">
{hasUnknownTitle ? notification.title_key : titleText} {hasUnknownTitle ? notification.title_key : titleText}
</p> </p>
<div className="flex items-center gap-0.5 flex-shrink-0"> <div className="flex items-center gap-0.5 flex-shrink-0">
<span className="text-xs mr-1" style={{ color: 'var(--text-faint)' }}> <span className="text-xs mr-1 text-content-faint">
{relativeTime(notification.created_at, locale)} {relativeTime(notification.created_at, locale)}
</span> </span>
{!notification.is_read && ( {!notification.is_read && (
@@ -117,7 +116,7 @@ export default function InAppNotificationItem({ notification, onClose }: Notific
</div> </div>
</div> </div>
<p className="text-xs mt-0.5 leading-relaxed" style={{ color: 'var(--text-muted)' }}> <p className="text-xs mt-0.5 leading-relaxed text-content-muted">
{hasUnknownBody ? notification.text_key : bodyText} {hasUnknownBody ? notification.text_key : bodyText}
</p> </p>
@@ -22,8 +22,7 @@ export default function ScopeGroupPicker({ selected, onChange }: Props): React.R
<button <button
type="button" type="button"
onClick={() => onChange(allSelected ? [] : allScopeKeys)} onClick={() => onChange(allSelected ? [] : allScopeKeys)}
className="text-xs px-2 py-0.5 rounded border transition-colors hover:bg-slate-100 dark:hover:bg-slate-700" className="text-xs px-2 py-0.5 rounded border transition-colors hover:bg-slate-100 dark:hover:bg-slate-700 border-edge text-content-secondary">
style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
{allSelected ? t('settings.oauth.modal.deselectAll') : t('settings.oauth.modal.selectAll')} {allSelected ? t('settings.oauth.modal.deselectAll') : t('settings.oauth.modal.selectAll')}
</button> </button>
</div> </div>
@@ -33,13 +32,12 @@ export default function ScopeGroupPicker({ selected, onChange }: Props): React.R
const allGroupSelected = groupScopeKeys.every(s => selected.includes(s)) const allGroupSelected = groupScopeKeys.every(s => selected.includes(s))
const someGroupSelected = groupScopeKeys.some(s => selected.includes(s)) const someGroupSelected = groupScopeKeys.some(s => selected.includes(s))
return ( return (
<div key={group} className="rounded-lg border overflow-hidden" style={{ borderColor: 'var(--border-primary)' }}> <div key={group} className="rounded-lg border overflow-hidden border-edge">
<div className="flex items-center gap-1 px-3 py-2" style={{ background: 'var(--bg-secondary)' }}> <div className="flex items-center gap-1 px-3 py-2 bg-surface-secondary">
<button <button
type="button" type="button"
onClick={() => setOpen(prev => ({ ...prev, [group]: !prev[group] }))} onClick={() => setOpen(prev => ({ ...prev, [group]: !prev[group] }))}
className="flex items-center gap-1 flex-1 text-xs font-semibold hover:opacity-70 transition-opacity text-left" className="flex items-center gap-1 flex-1 text-xs font-semibold hover:opacity-70 transition-opacity text-left text-content-secondary">
style={{ color: 'var(--text-secondary)' }}>
{open[group] {open[group]
? <ChevronDown className="w-3 h-3 flex-shrink-0" /> ? <ChevronDown className="w-3 h-3 flex-shrink-0" />
: <ChevronRight className="w-3 h-3 flex-shrink-0" />} : <ChevronRight className="w-3 h-3 flex-shrink-0" />}
@@ -80,7 +78,7 @@ export default function ScopeGroupPicker({ selected, onChange }: Props): React.R
className="mt-0.5 rounded flex-shrink-0" className="mt-0.5 rounded flex-shrink-0"
/> />
<div> <div>
<p className="text-xs font-medium" style={{ color: 'var(--text-primary)' }}>{label}</p> <p className="text-xs font-medium text-content">{label}</p>
<p className="text-xs" style={{ color: 'var(--text-tertiary)' }}>{description}</p> <p className="text-xs" style={{ color: 'var(--text-tertiary)' }}>{description}</p>
</div> </div>
</label> </label>
@@ -87,7 +87,7 @@ export default function ApplyTemplateButton({ tripId, style, className }: ApplyT
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-tertiary)'} onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-tertiary)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'} onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
> >
<Package size={13} style={{ color: 'var(--text-faint)' }} /> <Package size={13} className="text-content-faint" />
<div style={{ flex: 1, textAlign: 'left' }}> <div style={{ flex: 1, textAlign: 'left' }}>
<div style={{ fontWeight: 600 }}>{tmpl.name}</div> <div style={{ fontWeight: 600 }}>{tmpl.name}</div>
<div style={{ fontSize: 10, color: 'var(--text-faint)' }}> <div style={{ fontSize: 10, color: 'var(--text-faint)' }}>
@@ -318,7 +318,7 @@ function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingE
if (!canEdit) return if (!canEdit) return
const raw = e.target.value.replace(/[^0-9]/g, '') const raw = e.target.value.replace(/[^0-9]/g, '')
const v = raw === '' ? null : parseInt(raw) const v = raw === '' ? null : parseInt(raw)
try { await updatePackingItem(tripId, item.id, { weight_grams: v }) } catch {} try { await updatePackingItem(tripId, item.id, { weight_grams: v }) } catch { toast.error(t('packing.toast.saveError')) }
}} }}
placeholder="—" placeholder="—"
style={{ width: 36, border: 'none', fontSize: 12, textAlign: 'right', fontFamily: 'inherit', outline: 'none', color: 'var(--text-secondary)', background: 'transparent', padding: 0 }} style={{ width: 36, border: 'none', fontSize: 12, textAlign: 'right', fontFamily: 'inherit', outline: 'none', color: 'var(--text-secondary)', background: 'transparent', padding: 0 }}
@@ -334,7 +334,7 @@ function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingE
background: item.bag_id ? `${bags.find(b => b.id === item.bag_id)?.color || 'var(--border-primary)'}30` : 'transparent', background: item.bag_id ? `${bags.find(b => b.id === item.bag_id)?.color || 'var(--border-primary)'}30` : 'transparent',
}} }}
> >
{!item.bag_id && <Package size={9} style={{ color: 'var(--text-faint)' }} />} {!item.bag_id && <Package size={9} className="text-content-faint" />}
</button> </button>
{showBagPicker && ( {showBagPicker && (
<div style={{ <div style={{
@@ -343,14 +343,14 @@ function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingE
boxShadow: '0 4px 16px rgba(0,0,0,0.12)', padding: 4, minWidth: 160, boxShadow: '0 4px 16px rgba(0,0,0,0.12)', padding: 4, minWidth: 160,
}}> }}>
{item.bag_id && ( {item.bag_id && (
<button onClick={async () => { setShowBagPicker(false); try { await updatePackingItem(tripId, item.id, { bag_id: null }) } catch {} }} <button onClick={async () => { setShowBagPicker(false); try { await updatePackingItem(tripId, item.id, { bag_id: null }) } catch { toast.error(t('packing.toast.saveError')) } }}
style={{ display: 'flex', alignItems: 'center', gap: 7, width: '100%', padding: '6px 10px', background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, fontFamily: 'inherit', color: 'var(--text-faint)', borderRadius: 7 }}> style={{ display: 'flex', alignItems: 'center', gap: 7, width: '100%', padding: '6px 10px', background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, fontFamily: 'inherit', color: 'var(--text-faint)', borderRadius: 7 }}>
<span style={{ width: 10, height: 10, borderRadius: '50%', border: '2px dashed var(--border-primary)' }} /> <span style={{ width: 10, height: 10, borderRadius: '50%', border: '2px dashed var(--border-primary)' }} />
{t('packing.noBag')} {t('packing.noBag')}
</button> </button>
)} )}
{bags.map(b => ( {bags.map(b => (
<button key={b.id} onClick={async () => { setShowBagPicker(false); try { await updatePackingItem(tripId, item.id, { bag_id: b.id }) } catch {} }} <button key={b.id} onClick={async () => { setShowBagPicker(false); try { await updatePackingItem(tripId, item.id, { bag_id: b.id }) } catch { toast.error(t('packing.toast.saveError')) } }}
style={{ style={{
display: 'flex', alignItems: 'center', gap: 7, width: '100%', padding: '6px 10px', display: 'flex', alignItems: 'center', gap: 7, width: '100%', padding: '6px 10px',
background: item.bag_id === b.id ? 'var(--bg-tertiary)' : 'none', background: item.bag_id === b.id ? 'var(--bg-tertiary)' : 'none',
@@ -370,7 +370,7 @@ function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingE
onKeyDown={async e => { onKeyDown={async e => {
if (e.key === 'Enter' && bagInlineName.trim()) { if (e.key === 'Enter' && bagInlineName.trim()) {
const newBag = await onCreateBag(bagInlineName.trim()) const newBag = await onCreateBag(bagInlineName.trim())
if (newBag) { try { await updatePackingItem(tripId, item.id, { bag_id: newBag.id }) } catch {} } if (newBag) { try { await updatePackingItem(tripId, item.id, { bag_id: newBag.id }) } catch { toast.error(t('packing.toast.saveError')) } }
setBagInlineName(''); setBagInlineCreate(false); setShowBagPicker(false) setBagInlineName(''); setBagInlineCreate(false); setShowBagPicker(false)
} }
if (e.key === 'Escape') { setBagInlineCreate(false); setBagInlineName('') } if (e.key === 'Escape') { setBagInlineCreate(false); setBagInlineName('') }
@@ -380,7 +380,7 @@ function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingE
<button onClick={async () => { <button onClick={async () => {
if (bagInlineName.trim()) { if (bagInlineName.trim()) {
const newBag = await onCreateBag(bagInlineName.trim()) const newBag = await onCreateBag(bagInlineName.trim())
if (newBag) { try { await updatePackingItem(tripId, item.id, { bag_id: newBag.id }) } catch {} } if (newBag) { try { await updatePackingItem(tripId, item.id, { bag_id: newBag.id }) } catch { toast.error(t('packing.toast.saveError')) } }
setBagInlineName(''); setBagInlineCreate(false); setShowBagPicker(false) setBagInlineName(''); setBagInlineCreate(false); setShowBagPicker(false)
} }
}} }}
@@ -517,14 +517,18 @@ function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, on
} }
const handleCheckAll = async () => { const handleCheckAll = async () => {
for (const item of Array.from(items)) { try {
if (!item.checked) await togglePackingItem(tripId, item.id, true) for (const item of Array.from(items)) {
} if (!item.checked) await togglePackingItem(tripId, item.id, true)
}
} catch { toast.error(t('packing.toast.saveError')) }
} }
const handleUncheckAll = async () => { const handleUncheckAll = async () => {
for (const item of Array.from(items)) { try {
if (item.checked) await togglePackingItem(tripId, item.id, false) for (const item of Array.from(items)) {
} if (item.checked) await togglePackingItem(tripId, item.id, false)
}
} catch { toast.error(t('packing.toast.saveError')) }
} }
const handleDeleteAll = async () => { const handleDeleteAll = async () => {
await onDeleteAll(items) await onDeleteAll(items)
@@ -630,7 +634,7 @@ function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, on
{m.username[0]} {m.username[0]}
</div> </div>
<span style={{ flex: 1 }}>{m.username}</span> <span style={{ flex: 1 }}>{m.username}</span>
{isAssigned && <Check size={12} style={{ color: 'var(--text-muted)' }} />} {isAssigned && <Check size={12} className="text-content-muted" />}
</button> </button>
) )
})} })}
@@ -856,16 +860,20 @@ function usePackingList({ tripId, items, openImportSignal = 0, clearCheckedSigna
} }
const handleDeleteCategory = async (catItems) => { const handleDeleteCategory = async (catItems) => {
let failed = false
for (const item of catItems) { for (const item of catItems) {
try { await deletePackingItem(tripId, item.id) } catch {} try { await deletePackingItem(tripId, item.id) } catch { failed = true }
} }
if (failed) toast.error(t('packing.toast.deleteError'))
} }
const handleClearChecked = async () => { const handleClearChecked = async () => {
if (!confirm(t('packing.confirm.clearChecked', { count: abgehakt }))) return if (!confirm(t('packing.confirm.clearChecked', { count: abgehakt }))) return
let failed = false
for (const item of items.filter(i => i.checked)) { for (const item of items.filter(i => i.checked)) {
try { await deletePackingItem(tripId, item.id) } catch {} try { await deletePackingItem(tripId, item.id) } catch { failed = true }
} }
if (failed) toast.error(t('packing.toast.deleteError'))
} }
// Bag tracking // Bag tracking
@@ -1152,7 +1160,7 @@ function PackingHeader(S: PackingState) {
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-tertiary)'} onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-tertiary)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'} onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
> >
<Package size={13} style={{ color: 'var(--text-faint)' }} /> <Package size={13} className="text-content-faint" />
<div style={{ flex: 1, textAlign: 'left' }}> <div style={{ flex: 1, textAlign: 'left' }}>
<div style={{ fontWeight: 600 }}>{tmpl.name}</div> <div style={{ fontWeight: 600 }}>{tmpl.name}</div>
<div style={{ fontSize: 10, color: 'var(--text-faint)' }}>{tmpl.item_count} {t('admin.packingTemplates.items')}</div> <div style={{ fontSize: 10, color: 'var(--text-faint)' }}>{tmpl.item_count} {t('admin.packingTemplates.items')}</div>
@@ -2,6 +2,7 @@ import { useState, useCallback } from 'react'
import { useDropzone } from 'react-dropzone' import { useDropzone } from 'react-dropzone'
import { Upload, X, Image } from 'lucide-react' import { Upload, X, Image } from 'lucide-react'
import { useTranslation } from '../../i18n' import { useTranslation } from '../../i18n'
import { useToast } from '../shared/Toast'
import type { Place, Day } from '../../types' import type { Place, Day } from '../../types'
interface PhotoUploadProps { interface PhotoUploadProps {
@@ -14,6 +15,7 @@ interface PhotoUploadProps {
export function PhotoUpload({ tripId, days, places, onUpload, onClose }: PhotoUploadProps) { export function PhotoUpload({ tripId, days, places, onUpload, onClose }: PhotoUploadProps) {
const { t } = useTranslation() const { t } = useTranslation()
const toast = useToast()
const [files, setFiles] = useState([]) const [files, setFiles] = useState([])
const [dayId, setDayId] = useState('') const [dayId, setDayId] = useState('')
const [placeId, setPlaceId] = useState('') const [placeId, setPlaceId] = useState('')
@@ -59,6 +61,7 @@ export function PhotoUpload({ tripId, days, places, onUpload, onClose }: PhotoUp
setFiles([]) setFiles([])
} catch (err: unknown) { } catch (err: unknown) {
console.error('Upload failed:', err) console.error('Upload failed:', err)
toast.error(t('common.error'))
} finally { } finally {
setUploading(false) setUploading(false)
setProgress(0) setProgress(0)
@@ -221,7 +221,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
return ( return (
<div style={{ marginBottom: 0 }}> <div style={{ marginBottom: 0 }}>
{day.date && lat && lng && <div style={{ height: 1, background: 'var(--border-faint)', margin: '12px 0' }} />} {day.date && lat && lng && <div style={{ height: 1, background: 'var(--border-faint)', margin: '12px 0' }} />}
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 6 }}>{t('day.reservations')}</div> <div className="text-content-faint" style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 6 }}>{t('day.reservations')}</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{dayReservations.map(r => { {dayReservations.map(r => {
const linkedAssignment = dayAssignments.find(a => a.id === r.assignment_id) const linkedAssignment = dayAssignments.find(a => a.id === r.assignment_id)
@@ -257,7 +257,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
{/* ── Accommodation ── */} {/* ── Accommodation ── */}
<div> <div>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 8 }}>{t('day.accommodation')}</div> <div className="text-content-faint" style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 8 }}>{t('day.accommodation')}</div>
<AccommodationList dayAccommodations={dayAccommodations} day={day} reservations={reservations} <AccommodationList dayAccommodations={dayAccommodations} day={day} reservations={reservations}
canEditDays={canEditDays} fmtTime={fmtTime} blurCodes={blurCodes} t={t} canEditDays={canEditDays} fmtTime={fmtTime} blurCodes={blurCodes} t={t}
@@ -286,7 +286,7 @@ interface ChipProps {
function Chip({ icon: Icon, value }: ChipProps) { function Chip({ icon: Icon, value }: ChipProps) {
return ( return (
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 4, padding: '4px 8px', borderRadius: 8, background: 'var(--bg-secondary)', fontSize: 11, color: 'var(--text-muted)' }}> <div className="bg-surface-secondary text-content-muted" style={{ display: 'inline-flex', alignItems: 'center', gap: 4, padding: '4px 8px', borderRadius: 8, fontSize: 11 }}>
<Icon size={11} style={{ flexShrink: 0, opacity: 0.6 }} /> <Icon size={11} style={{ flexShrink: 0, opacity: 0.6 }} />
<span style={{ fontWeight: 500 }}>{value}</span> <span style={{ fontWeight: 500 }}>{value}</span>
</div> </div>
@@ -1211,7 +1211,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
return ( return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', position: 'relative', fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }}> <div style={{ display: 'flex', flexDirection: 'column', height: '100%', position: 'relative', fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }}>
{/* Toolbar */} {/* Toolbar */}
<div style={{ padding: '12px 16px', borderBottom: '1px solid var(--border-faint)', flexShrink: 0 }}> <div className="border-b border-edge-faint" style={{ padding: '12px 16px', flexShrink: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: 8 }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: 8 }}>
<div style={{ position: 'relative', flexShrink: 0 }}> <div style={{ position: 'relative', flexShrink: 0 }}>
<button <button
@@ -1228,10 +1228,11 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
}} }}
onMouseEnter={() => setPdfHover(true)} onMouseEnter={() => setPdfHover(true)}
onMouseLeave={() => setPdfHover(false)} onMouseLeave={() => setPdfHover(false)}
className="bg-accent text-accent-text"
style={{ style={{
display: 'flex', alignItems: 'center', gap: 5, display: 'flex', alignItems: 'center', gap: 5,
padding: '5px 10px', borderRadius: 8, border: 'none', padding: '5px 10px', borderRadius: 8, border: 'none',
background: 'var(--accent)', color: 'var(--accent-text)', fontSize: 11, fontWeight: 500, fontSize: 11, fontWeight: 500,
cursor: 'pointer', fontFamily: 'inherit', cursor: 'pointer', fontFamily: 'inherit',
}} }}
> >
@@ -1632,7 +1633,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
border: dragOverDayId === day.id ? '2px dashed rgba(17,24,39,0.2)' : '2px dashed transparent', border: dragOverDayId === day.id ? '2px dashed rgba(17,24,39,0.2)' : '2px dashed transparent',
}} }}
> >
<span style={{ fontSize: 12, color: 'var(--text-faint)' }}>{t('dayplan.emptyDay')}</span> <span className="text-content-faint" style={{ fontSize: 12 }}>{t('dayplan.emptyDay')}</span>
</div> </div>
) : ( ) : (
merged.map((item, idx) => { merged.map((item, idx) => {
@@ -2600,8 +2601,8 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
{/* Schließen */} {/* Schließen */}
<div style={{ textAlign: 'right' }}> <div style={{ textAlign: 'right' }}>
<button onClick={() => setTransportDetail(null)} style={{ <button onClick={() => setTransportDetail(null)} className="bg-accent text-accent-text" style={{
fontSize: 12, background: 'var(--accent)', color: 'var(--accent-text)', fontSize: 12,
border: 'none', borderRadius: 8, padding: '6px 16px', cursor: 'pointer', fontWeight: 600, fontFamily: 'inherit', border: 'none', borderRadius: 8, padding: '6px 16px', cursor: 'pointer', fontWeight: 600, fontFamily: 'inherit',
}}> }}>
{t('common.close')} {t('common.close')}
@@ -2617,9 +2618,9 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
{/* Budget-Fußzeile */} {/* Budget-Fußzeile */}
{totalCost > 0 && ( {totalCost > 0 && (
<div style={{ flexShrink: 0, padding: '10px 16px', borderTop: '1px solid var(--border-faint)', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> <div className="border-t border-edge-faint" style={{ flexShrink: 0, padding: '10px 16px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ fontSize: 11, color: 'var(--text-faint)' }}>{t('dayplan.totalCost')}</span> <span className="text-content-faint" style={{ fontSize: 11 }}>{t('dayplan.totalCost')}</span>
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>{totalCost.toFixed(currencyDecimals(currency))} {currency}</span> <span className="text-content" style={{ fontSize: 13, fontWeight: 600 }}>{totalCost.toFixed(currencyDecimals(currency))} {currency}</span>
</div> </div>
)} )}
<ContextMenu menu={ctxMenu.menu} onClose={ctxMenu.close} /> <ContextMenu menu={ctxMenu.menu} onClose={ctxMenu.close} />
@@ -8,9 +8,10 @@ import PlaceAvatar from '../shared/PlaceAvatar'
import { mapsApi } from '../../api/client' import { mapsApi } from '../../api/client'
import { useSettingsStore } from '../../store/settingsStore' import { useSettingsStore } from '../../store/settingsStore'
import { getCategoryIcon } from '../shared/categoryIcons' import { getCategoryIcon } from '../shared/categoryIcons'
import { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n' import { useTranslation } from '../../i18n'
import type { Place, Category, Day, Assignment, Reservation, TripFile, AssignmentsMap } from '../../types' import type { Place, Category, Day, Assignment, Reservation, TripFile, AssignmentsMap } from '../../types'
import { splitReservationDateTime } from '../../utils/formatters' import { splitReservationDateTime, formatTime } from '../../utils/formatters'
const detailsCache = new Map() const detailsCache = new Map()
@@ -76,24 +77,6 @@ function convertHoursLine(line, timeFormat) {
return line return line
} }
function formatTime(timeStr, locale, timeFormat) {
if (!timeStr) return ''
try {
const parts = timeStr.split(':')
const h = Number(parts[0]) || 0
const m = Number(parts[1]) || 0
if (isNaN(h)) return timeStr
if (timeFormat === '12h') {
const period = h >= 12 ? 'PM' : 'AM'
const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h
return `${h12}:${String(m).padStart(2, '0')} ${period}`
}
const str = `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`
return locale?.startsWith('de') ? `${str} Uhr` : str
} catch { return timeStr }
}
function formatFileSize(bytes) { function formatFileSize(bytes) {
if (!bytes) return '' if (!bytes) return ''
if (bytes < 1024) return `${bytes} B` if (bytes < 1024) return `${bytes} B`
@@ -137,6 +120,7 @@ export default function PlaceInspector({
leftWidth = 0, rightWidth = 0, leftWidth = 0, rightWidth = 0,
}: PlaceInspectorProps) { }: PlaceInspectorProps) {
const { t, locale, language } = useTranslation() const { t, locale, language } = useTranslation()
const toast = useToast()
const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h' const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h'
const [hoursExpanded, setHoursExpanded] = useState(false) const [hoursExpanded, setHoursExpanded] = useState(false)
const [filesExpanded, setFilesExpanded] = useState(false) const [filesExpanded, setFilesExpanded] = useState(false)
@@ -197,11 +181,12 @@ export default function PlaceInspector({
setFilesExpanded(true) setFilesExpanded(true)
} catch (err: unknown) { } catch (err: unknown) {
console.error('Upload failed', err) console.error('Upload failed', err)
toast.error(t('files.uploadError'))
} finally { } finally {
setIsUploading(false) setIsUploading(false)
if (fileInputRef.current) fileInputRef.current.value = '' if (fileInputRef.current) fileInputRef.current.value = ''
} }
}, [onFileUpload, place.id]) }, [onFileUpload, place.id, toast, t])
return ( return (
<div <div
@@ -268,14 +253,14 @@ export default function PlaceInspector({
{/* Description / Summary */} {/* Description / Summary */}
{(place.description || googleDetails?.summary) && ( {(place.description || googleDetails?.summary) && (
<div className="collab-note-md" style={{ background: 'var(--bg-hover)', borderRadius: 10, overflow: 'hidden', fontSize: 12, color: 'var(--text-muted)', lineHeight: '1.5', padding: '8px 12px' }}> <div className="collab-note-md bg-surface-hover text-content-muted" style={{ borderRadius: 10, overflow: 'hidden', fontSize: 12, lineHeight: '1.5', padding: '8px 12px' }}>
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{place.description || googleDetails?.summary || ''}</Markdown> <Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{place.description || googleDetails?.summary || ''}</Markdown>
</div> </div>
)} )}
{/* Notes */} {/* Notes */}
{place.notes && ( {place.notes && (
<div className="collab-note-md" style={{ background: 'var(--bg-hover)', borderRadius: 10, overflow: 'hidden', fontSize: 12, color: 'var(--text-muted)', lineHeight: '1.5', padding: '8px 12px', wordBreak: 'break-word', overflowWrap: 'anywhere' }}> <div className="collab-note-md bg-surface-hover text-content-muted" style={{ borderRadius: 10, overflow: 'hidden', fontSize: 12, lineHeight: '1.5', padding: '8px 12px', wordBreak: 'break-word', overflowWrap: 'anywhere' }}>
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{place.notes}</Markdown> <Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{place.notes}</Markdown>
</div> </div>
)} )}
@@ -294,7 +279,7 @@ export default function PlaceInspector({
</div> </div>
{/* Footer actions */} {/* Footer actions */}
<div style={{ padding: '10px 16px', borderTop: '1px solid var(--border-faint)', display: 'flex', gap: 6, alignItems: 'center', flexWrap: 'wrap' }}> <div className="border-t border-edge-faint" style={{ padding: '10px 16px', display: 'flex', gap: 6, alignItems: 'center', flexWrap: 'wrap' }}>
{selectedDayId && ( {selectedDayId && (
assignmentInDay ? ( assignmentInDay ? (
<ActionButton onClick={() => onRemoveAssignment(selectedDayId, assignmentInDay.id)} variant="ghost" icon={<Minus size={13} />} <ActionButton onClick={() => onRemoveAssignment(selectedDayId, assignmentInDay.id)} variant="ghost" icon={<Minus size={13} />}
+11 -10
View File
@@ -370,13 +370,14 @@ function PlacesHeader(S: SidebarState) {
catDropOpen, setCatDropOpen, toggleCategoryFilter, setCategoryFiltersLocal, onCategoryFilterChange, catDropOpen, setCatDropOpen, toggleCategoryFilter, setCategoryFiltersLocal, onCategoryFilterChange,
} = S } = S
return ( return (
<div style={{ padding: '14px 16px 10px', borderBottom: '1px solid var(--border-faint)', flexShrink: 0 }}> <div className="border-b border-edge-faint" style={{ padding: '14px 16px 10px', flexShrink: 0 }}>
{canEditPlaces && <button {canEditPlaces && <button
onClick={onAddPlace} onClick={onAddPlace}
className="bg-accent text-accent-text"
style={{ style={{
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
width: '100%', padding: '8px 12px', borderRadius: 12, border: 'none', width: '100%', padding: '8px 12px', borderRadius: 12, border: 'none',
background: 'var(--accent)', color: 'var(--accent-text)', fontSize: 13, fontWeight: 500, fontSize: 13, fontWeight: 500,
cursor: 'pointer', fontFamily: 'inherit', marginBottom: 10, cursor: 'pointer', fontFamily: 'inherit', marginBottom: 10,
}} }}
> >
@@ -386,11 +387,11 @@ function PlacesHeader(S: SidebarState) {
<div style={{ display: 'flex', gap: 6, marginBottom: 10 }}> <div style={{ display: 'flex', gap: 6, marginBottom: 10 }}>
<button <button
onClick={() => setFileImportOpen(true)} onClick={() => setFileImportOpen(true)}
className="border border-dashed border-edge text-content-faint"
style={{ style={{
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
flex: 1, padding: '5px 12px', borderRadius: 8, flex: 1, padding: '5px 12px', borderRadius: 8,
border: '1px dashed var(--border-primary)', background: 'none', background: 'none', fontSize: 11, fontWeight: 500,
color: 'var(--text-faint)', fontSize: 11, fontWeight: 500,
cursor: 'pointer', fontFamily: 'inherit', cursor: 'pointer', fontFamily: 'inherit',
}} }}
> >
@@ -398,18 +399,18 @@ function PlacesHeader(S: SidebarState) {
</button> </button>
<button <button
onClick={() => setListImportOpen(true)} onClick={() => setListImportOpen(true)}
className="border border-dashed border-edge text-content-faint"
style={{ style={{
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
flex: 1, padding: '5px 12px', borderRadius: 8, flex: 1, padding: '5px 12px', borderRadius: 8,
border: '1px dashed var(--border-primary)', background: 'none', background: 'none', fontSize: 11, fontWeight: 500,
color: 'var(--text-faint)', fontSize: 11, fontWeight: 500,
cursor: 'pointer', fontFamily: 'inherit', cursor: 'pointer', fontFamily: 'inherit',
}} }}
> >
<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' }} /> <div className="bg-edge" style={{ height: 1, margin: '2px 0 10px' }} />
</>} </>}
{/* Filter-Tabs */} {/* Filter-Tabs */}
@@ -680,10 +681,10 @@ function PlacesList(S: SidebarState) {
<div className="trek-stagger" style={{ flex: 1, overflowY: 'auto', minHeight: 0 }} ref={scrollContainerRef} onScroll={(e) => onScrollTopChange?.((e.currentTarget as HTMLElement).scrollTop)}> <div className="trek-stagger" style={{ flex: 1, overflowY: 'auto', minHeight: 0 }} ref={scrollContainerRef} onScroll={(e) => onScrollTopChange?.((e.currentTarget as HTMLElement).scrollTop)}>
{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 className="text-content-faint" style={{ fontSize: 13 }}>
{filter === 'unplanned' ? t('places.allPlanned') : t('places.noneFound')} {filter === 'unplanned' ? t('places.allPlanned') : t('places.noneFound')}
</span> </span>
{canEditPlaces && <button onClick={onAddPlace} style={{ fontSize: 12, color: 'var(--text-primary)', background: 'none', border: 'none', cursor: 'pointer', textDecoration: 'underline', fontFamily: 'inherit' }}> {canEditPlaces && <button onClick={onAddPlace} className="text-content" style={{ fontSize: 12, background: 'none', border: 'none', cursor: 'pointer', textDecoration: 'underline', fontFamily: 'inherit' }}>
{t('places.addPlace')} {t('places.addPlace')}
</button>} </button>}
</div> </div>
@@ -910,7 +911,7 @@ const PlacesSidebar = React.memo(function PlacesSidebar(props: PlacesSidebarProp
<PlacesSelectionBar {...S} /> <PlacesSelectionBar {...S} />
) : ( ) : (
<div style={{ padding: '6px 16px', flexShrink: 0 }}> <div style={{ padding: '6px 16px', flexShrink: 0 }}>
<span style={{ fontSize: 11, color: 'var(--text-faint)' }}>{filtered.length === 1 ? t('places.countSingular') : t('places.count', { count: filtered.length })}</span> <span className="text-content-faint" style={{ fontSize: 11 }}>{filtered.length === 1 ? t('places.countSingular') : t('places.count', { count: filtered.length })}</span>
</div> </div>
)} )}
@@ -538,13 +538,13 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
<a href="#" onClick={(e) => { e.preventDefault(); openFile(f.url).catch(() => {}) }} style={{ color: 'var(--text-faint)', display: 'flex', flexShrink: 0, cursor: 'pointer' }}><ExternalLink size={11} /></a> <a href="#" onClick={(e) => { e.preventDefault(); openFile(f.url).catch(() => {}) }} style={{ color: 'var(--text-faint)', display: 'flex', flexShrink: 0, cursor: 'pointer' }}><ExternalLink size={11} /></a>
<button type="button" onClick={async () => { <button type="button" onClick={async () => {
if (f.reservation_id === reservation?.id) { if (f.reservation_id === reservation?.id) {
try { await apiClient.put(`/trips/${tripId}/files/${f.id}`, { reservation_id: null }) } catch {} try { await apiClient.put(`/trips/${tripId}/files/${f.id}`, { reservation_id: null }) } catch { toast.error(t('reservations.toast.updateError')) }
} }
try { try {
const linksRes = await apiClient.get(`/trips/${tripId}/files/${f.id}/links`) const linksRes = await apiClient.get(`/trips/${tripId}/files/${f.id}/links`)
const link = (linksRes.data.links || []).find((l: any) => l.reservation_id === reservation?.id) const link = (linksRes.data.links || []).find((l: any) => l.reservation_id === reservation?.id)
if (link) await apiClient.delete(`/trips/${tripId}/files/${f.id}/link/${link.id}`) if (link) await apiClient.delete(`/trips/${tripId}/files/${f.id}/link/${link.id}`)
} catch {} } catch { toast.error(t('reservations.toast.updateError')) }
setLinkedFileIds(prev => prev.filter(id => id !== f.id)) setLinkedFileIds(prev => prev.filter(id => id !== f.id))
if (tripId) loadFiles(tripId) if (tripId) loadFiles(tripId)
}} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', padding: 0, flexShrink: 0 }}> }} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', padding: 0, flexShrink: 0 }}>
@@ -594,7 +594,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
setLinkedFileIds(prev => [...prev, f.id]) setLinkedFileIds(prev => [...prev, f.id])
setShowFilePicker(false) setShowFilePicker(false)
if (tripId) loadFiles(tripId) if (tripId) loadFiles(tripId)
} catch {} } catch { toast.error(t('reservations.toast.updateError')) }
}} }}
style={{ style={{
display: 'flex', alignItems: 'center', gap: 8, width: '100%', padding: '6px 10px', display: 'flex', alignItems: 'center', gap: 8, width: '100%', padding: '6px 10px',
@@ -617,13 +617,13 @@ export default function ReservationsPanel({ tripId, reservations, days, assignme
<div style={{ flex: 1, overflowY: 'auto', padding: '24px 28px 80px' }} className="max-md:!px-4 max-md:!pt-4"> <div style={{ flex: 1, overflowY: 'auto', padding: '24px 28px 80px' }} className="max-md:!px-4 max-md:!pt-4">
{total === 0 && reservations.length === 0 ? ( {total === 0 && reservations.length === 0 ? (
<div style={{ textAlign: 'center', padding: '60px 20px' }}> <div style={{ textAlign: 'center', padding: '60px 20px' }}>
<BookMarked size={36} style={{ color: 'var(--text-faint)', display: 'block', margin: '0 auto 12px' }} /> <BookMarked size={36} className="text-content-faint" style={{ display: 'block', margin: '0 auto 12px' }} />
<p style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-secondary)', margin: '0 0 4px' }}>{t('reservations.empty')}</p> <p className="text-content-secondary" style={{ fontSize: 14, fontWeight: 600, margin: '0 0 4px' }}>{t('reservations.empty')}</p>
<p style={{ fontSize: 12, color: 'var(--text-faint)', margin: 0 }}>{t('reservations.emptyHint')}</p> <p className="text-content-faint" style={{ fontSize: 12, margin: 0 }}>{t('reservations.emptyHint')}</p>
</div> </div>
) : total === 0 ? ( ) : total === 0 ? (
<div style={{ textAlign: 'center', padding: '60px 20px' }}> <div style={{ textAlign: 'center', padding: '60px 20px' }}>
<p style={{ fontSize: 13, color: 'var(--text-faint)' }}>{t('places.noneFound')}</p> <p className="text-content-faint" style={{ fontSize: 13 }}>{t('places.noneFound')}</p>
</div> </div>
) : ( ) : (
<> <>
@@ -509,13 +509,13 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
<a href="#" onClick={(e) => { e.preventDefault(); openFile(f.url).catch(() => {}) }} style={{ color: 'var(--text-faint)', display: 'flex', flexShrink: 0, cursor: 'pointer' }}><ExternalLink size={11} /></a> <a href="#" onClick={(e) => { e.preventDefault(); openFile(f.url).catch(() => {}) }} style={{ color: 'var(--text-faint)', display: 'flex', flexShrink: 0, cursor: 'pointer' }}><ExternalLink size={11} /></a>
<button type="button" onClick={async () => { <button type="button" onClick={async () => {
if (f.reservation_id === reservation?.id) { if (f.reservation_id === reservation?.id) {
try { await apiClient.put(`/trips/${tripId}/files/${f.id}`, { reservation_id: null }) } catch {} try { await apiClient.put(`/trips/${tripId}/files/${f.id}`, { reservation_id: null }) } catch { toast.error(t('reservations.toast.updateError')) }
} }
try { try {
const linksRes = await apiClient.get(`/trips/${tripId}/files/${f.id}/links`) const linksRes = await apiClient.get(`/trips/${tripId}/files/${f.id}/links`)
const link = (linksRes.data.links || []).find((l: any) => l.reservation_id === reservation?.id) const link = (linksRes.data.links || []).find((l: any) => l.reservation_id === reservation?.id)
if (link) await apiClient.delete(`/trips/${tripId}/files/${f.id}/link/${link.id}`) if (link) await apiClient.delete(`/trips/${tripId}/files/${f.id}/link/${link.id}`)
} catch {} } catch { toast.error(t('reservations.toast.updateError')) }
setLinkedFileIds(prev => prev.filter(id => id !== f.id)) setLinkedFileIds(prev => prev.filter(id => id !== f.id))
if (tripId) loadFiles(tripId) if (tripId) loadFiles(tripId)
}} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', padding: 0, flexShrink: 0 }}> }} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', padding: 0, flexShrink: 0 }}>
@@ -565,7 +565,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
setLinkedFileIds(prev => [...prev, f.id]) setLinkedFileIds(prev => [...prev, f.id])
setShowFilePicker(false) setShowFilePicker(false)
if (tripId) loadFiles(tripId) if (tripId) loadFiles(tripId)
} catch {} } catch { toast.error(t('reservations.toast.updateError')) }
}} }}
style={{ style={{
display: 'flex', alignItems: 'center', gap: 8, width: '100%', padding: '6px 10px', display: 'flex', alignItems: 'center', gap: 8, width: '100%', padding: '6px 10px',
+33 -33
View File
@@ -239,14 +239,14 @@ export default function AboutTab({ appVersion }: Props): React.ReactElement {
50% { transform: scale(1.15); } 50% { transform: scale(1.15); }
} }
`}</style> `}</style>
<p style={{ fontSize: 13, lineHeight: 1.6, color: 'var(--text-secondary)', marginBottom: 6, marginTop: -4 }}> <p className="text-content-secondary" style={{ fontSize: 13, lineHeight: 1.6, marginBottom: 6, marginTop: -4 }}>
{t('settings.about.description')} {t('settings.about.description')}
</p> </p>
<p style={{ fontSize: 12, lineHeight: 1.6, color: 'var(--text-faint)', marginBottom: 16 }}> <p className="text-content-faint" style={{ fontSize: 12, lineHeight: 1.6, marginBottom: 16 }}>
{t('settings.about.madeWith')}{' '} {t('settings.about.madeWith')}{' '}
<Heart size={11} fill="#991b1b" stroke="#991b1b" style={{ display: 'inline-block', verticalAlign: '-1px', animation: 'heartPulse 1.5s ease-in-out infinite' }} /> <Heart size={11} fill="#991b1b" stroke="#991b1b" style={{ display: 'inline-block', verticalAlign: '-1px', animation: 'heartPulse 1.5s ease-in-out infinite' }} />
{' '}{t('settings.about.madeBy')}{' '} {' '}{t('settings.about.madeBy')}{' '}
<span style={{ display: 'inline-flex', alignItems: 'center', background: 'var(--bg-tertiary)', borderRadius: 99, padding: '1px 7px', fontSize: 10, fontWeight: 600, color: 'var(--text-faint)', verticalAlign: '1px' }}>v{appVersion}</span> <span className="text-content-faint" style={{ display: 'inline-flex', alignItems: 'center', background: 'var(--bg-tertiary)', borderRadius: 99, padding: '1px 7px', fontSize: 10, fontWeight: 600, verticalAlign: '1px' }}>v{appVersion}</span>
</p> </p>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3"> <div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
@@ -254,8 +254,8 @@ 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-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]" 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)] bg-surface-card"
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }} style={{ 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' }}
> >
@@ -263,17 +263,17 @@ export default function AboutTab({ appVersion }: Props): React.ReactElement {
<Coffee size={20} style={{ color: '#ff5e5b' }} /> <Coffee size={20} style={{ color: '#ff5e5b' }} />
</div> </div>
<div> <div>
<div className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>Ko-fi</div> <div className="text-sm font-semibold text-content">Ko-fi</div>
<div className="text-xs" style={{ color: 'var(--text-faint)' }}>{t('admin.github.support')}</div> <div className="text-xs text-content-faint">{t('admin.github.support')}</div>
</div> </div>
<ExternalLink size={14} className="ml-auto flex-shrink-0" style={{ color: 'var(--text-faint)' }} /> <ExternalLink size={14} className="ml-auto flex-shrink-0 text-content-faint" />
</a> </a>
<a <a
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-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]" 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)] bg-surface-card"
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }} style={{ 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' }}
> >
@@ -281,17 +281,17 @@ export default function AboutTab({ appVersion }: Props): React.ReactElement {
<Heart size={20} style={{ color: '#ffdd00' }} /> <Heart size={20} style={{ color: '#ffdd00' }} />
</div> </div>
<div> <div>
<div className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>Buy Me a Coffee</div> <div className="text-sm font-semibold text-content">Buy Me a Coffee</div>
<div className="text-xs" style={{ color: 'var(--text-faint)' }}>{t('admin.github.support')}</div> <div className="text-xs text-content-faint">{t('admin.github.support')}</div>
</div> </div>
<ExternalLink size={14} className="ml-auto flex-shrink-0" style={{ color: 'var(--text-faint)' }} /> <ExternalLink size={14} className="ml-auto flex-shrink-0 text-content-faint" />
</a> </a>
<a <a
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-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]" 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)] bg-surface-card"
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }} style={{ 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' }}
> >
@@ -299,10 +299,10 @@ export default function AboutTab({ appVersion }: Props): React.ReactElement {
<svg width="20" height="20" viewBox="0 0 24 24" fill="#5865F2"><path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"/></svg> <svg width="20" height="20" viewBox="0 0 24 24" fill="#5865F2"><path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"/></svg>
</div> </div>
<div> <div>
<div className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>Discord</div> <div className="text-sm font-semibold text-content">Discord</div>
<div className="text-xs" style={{ color: 'var(--text-faint)' }}>Join the community</div> <div className="text-xs text-content-faint">Join the community</div>
</div> </div>
<ExternalLink size={14} className="ml-auto flex-shrink-0" style={{ color: 'var(--text-faint)' }} /> <ExternalLink size={14} className="ml-auto flex-shrink-0 text-content-faint" />
</a> </a>
</div> </div>
@@ -311,8 +311,8 @@ 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-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]" 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)] bg-surface-card"
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }} style={{ 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' }}
> >
@@ -320,17 +320,17 @@ export default function AboutTab({ appVersion }: Props): React.ReactElement {
<Bug size={20} style={{ color: '#ef4444' }} /> <Bug size={20} style={{ color: '#ef4444' }} />
</div> </div>
<div> <div>
<div className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>{t('settings.about.reportBug')}</div> <div className="text-sm font-semibold text-content">{t('settings.about.reportBug')}</div>
<div className="text-xs" style={{ color: 'var(--text-faint)' }}>{t('settings.about.reportBugHint')}</div> <div className="text-xs text-content-faint">{t('settings.about.reportBugHint')}</div>
</div> </div>
<ExternalLink size={14} className="ml-auto flex-shrink-0" style={{ color: 'var(--text-faint)' }} /> <ExternalLink size={14} className="ml-auto flex-shrink-0 text-content-faint" />
</a> </a>
<a <a
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-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]" 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)] bg-surface-card"
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }} style={{ 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' }}
> >
@@ -338,17 +338,17 @@ export default function AboutTab({ appVersion }: Props): React.ReactElement {
<Lightbulb size={20} style={{ color: '#f59e0b' }} /> <Lightbulb size={20} style={{ color: '#f59e0b' }} />
</div> </div>
<div> <div>
<div className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>{t('settings.about.featureRequest')}</div> <div className="text-sm font-semibold text-content">{t('settings.about.featureRequest')}</div>
<div className="text-xs" style={{ color: 'var(--text-faint)' }}>{t('settings.about.featureRequestHint')}</div> <div className="text-xs text-content-faint">{t('settings.about.featureRequestHint')}</div>
</div> </div>
<ExternalLink size={14} className="ml-auto flex-shrink-0" style={{ color: 'var(--text-faint)' }} /> <ExternalLink size={14} className="ml-auto flex-shrink-0 text-content-faint" />
</a> </a>
<a <a
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-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]" 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)] bg-surface-card"
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }} style={{ 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' }}
> >
@@ -356,10 +356,10 @@ export default function AboutTab({ appVersion }: Props): React.ReactElement {
<BookOpen size={20} style={{ color: '#6366f1' }} /> <BookOpen size={20} style={{ color: '#6366f1' }} />
</div> </div>
<div> <div>
<div className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>Wiki</div> <div className="text-sm font-semibold text-content">Wiki</div>
<div className="text-xs" style={{ color: 'var(--text-faint)' }}>{t('settings.about.wikiHint')}</div> <div className="text-xs text-content-faint">{t('settings.about.wikiHint')}</div>
</div> </div>
<ExternalLink size={14} className="ml-auto flex-shrink-0" style={{ color: 'var(--text-faint)' }} /> <ExternalLink size={14} className="ml-auto flex-shrink-0 text-content-faint" />
</a> </a>
</div> </div>
+37 -41
View File
@@ -172,7 +172,7 @@ export default function AccountTab(): React.ReactElement {
{/* Change Password */} {/* Change Password */}
{!oidcOnlyMode && ( {!oidcOnlyMode && (
<div style={{ paddingTop: 16, marginTop: 16, borderTop: '1px solid var(--border-secondary)' }}> <div className="pt-4 mt-4 border-t border-edge-secondary">
<label className="block text-sm font-medium text-slate-700 mb-3">{t('settings.changePassword')}</label> <label className="block text-sm font-medium text-slate-700 mb-3">{t('settings.changePassword')}</label>
<div className="space-y-3"> <div className="space-y-3">
<input <input
@@ -224,25 +224,24 @@ export default function AccountTab(): React.ReactElement {
)} )}
{/* MFA */} {/* MFA */}
<div style={{ paddingTop: 16, marginTop: 16, borderTop: '1px solid var(--border-secondary)' }}> <div className="pt-4 mt-4 border-t border-edge-secondary">
<div className="flex items-center gap-2 mb-3"> <div className="flex items-center gap-2 mb-3">
<KeyRound className="w-5 h-5" style={{ color: 'var(--text-secondary)' }} /> <KeyRound className="w-5 h-5 text-content-secondary" />
<h3 className="font-semibold text-base m-0" style={{ color: 'var(--text-primary)' }}>{t('settings.mfa.title')}</h3> <h3 className="font-semibold text-base m-0 text-content">{t('settings.mfa.title')}</h3>
</div> </div>
<div className="space-y-3"> <div className="space-y-3">
{mfaRequiredByPolicy && ( {mfaRequiredByPolicy && (
<div className="flex gap-3 p-3 rounded-lg border text-sm" <div className="flex gap-3 p-3 rounded-lg border text-sm bg-surface-secondary border-edge text-content">
style={{ background: 'var(--bg-secondary)', borderColor: 'var(--border-primary)', color: 'var(--text-primary)' }}>
<AlertTriangle className="w-5 h-5 flex-shrink-0 text-amber-600" /> <AlertTriangle className="w-5 h-5 flex-shrink-0 text-amber-600" />
<p className="m-0 leading-relaxed">{t('settings.mfa.requiredByPolicy')}</p> <p className="m-0 leading-relaxed">{t('settings.mfa.requiredByPolicy')}</p>
</div> </div>
)} )}
<p className="text-sm m-0" style={{ color: 'var(--text-muted)', lineHeight: 1.5 }}>{t('settings.mfa.description')}</p> <p className="text-sm m-0 text-content-muted" style={{ lineHeight: 1.5 }}>{t('settings.mfa.description')}</p>
{demoMode ? ( {demoMode ? (
<p className="text-sm text-amber-700 m-0">{t('settings.mfa.demoBlocked')}</p> <p className="text-sm text-amber-700 m-0">{t('settings.mfa.demoBlocked')}</p>
) : ( ) : (
<> <>
<p className="text-sm font-medium m-0" style={{ color: 'var(--text-secondary)' }}> <p className="text-sm font-medium m-0 text-content-secondary">
{user?.mfa_enabled ? t('settings.mfa.enabled') : t('settings.mfa.disabled')} {user?.mfa_enabled ? t('settings.mfa.enabled') : t('settings.mfa.disabled')}
</p> </p>
@@ -263,8 +262,7 @@ export default function AccountTab(): React.ReactElement {
setMfaLoading(false) setMfaLoading(false)
} }
}} }}
className="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors" className="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors border border-edge bg-surface-card text-content"
style={{ border: '1px solid var(--border-primary)', background: 'var(--bg-card)', color: 'var(--text-primary)' }}
> >
{mfaLoading ? <div className="w-4 h-4 border-2 border-slate-300 border-t-slate-700 rounded-full animate-spin" /> : <KeyRound size={14} />} {mfaLoading ? <div className="w-4 h-4 border-2 border-slate-300 border-t-slate-700 rounded-full animate-spin" /> : <KeyRound size={14} />}
{t('settings.mfa.setup')} {t('settings.mfa.setup')}
@@ -273,11 +271,11 @@ export default function AccountTab(): React.ReactElement {
{!user?.mfa_enabled && mfaQr && ( {!user?.mfa_enabled && mfaQr && (
<div className="space-y-3"> <div className="space-y-3">
<p className="text-sm" style={{ color: 'var(--text-muted)' }}>{t('settings.mfa.scanQr')}</p> <p className="text-sm text-content-muted">{t('settings.mfa.scanQr')}</p>
<div className="rounded-lg border mx-auto block overflow-hidden" style={{ width: 'fit-content', borderColor: 'var(--border-primary)' }} dangerouslySetInnerHTML={{ __html: mfaQr! }} /> <div className="rounded-lg border mx-auto block overflow-hidden border-edge" style={{ width: 'fit-content' }} dangerouslySetInnerHTML={{ __html: mfaQr! }} />
<div> <div>
<label className="block text-xs font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>{t('settings.mfa.secretLabel')}</label> <label className="block text-xs font-medium mb-1 text-content-secondary">{t('settings.mfa.secretLabel')}</label>
<code className="block text-xs p-2 rounded break-all" style={{ background: 'var(--bg-hover)', color: 'var(--text-primary)' }}>{mfaSecret}</code> <code className="block text-xs p-2 rounded break-all bg-surface-hover text-content">{mfaSecret}</code>
</div> </div>
<input <input
type="text" type="text"
@@ -318,8 +316,7 @@ export default function AccountTab(): React.ReactElement {
<button <button
type="button" type="button"
onClick={() => { setMfaQr(null); setMfaSecret(null); setMfaSetupCode('') }} onClick={() => { setMfaQr(null); setMfaSecret(null); setMfaSetupCode('') }}
className="px-4 py-2 rounded-lg text-sm border" className="px-4 py-2 rounded-lg text-sm border border-edge text-content-secondary"
style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}
> >
{t('settings.mfa.cancelSetup')} {t('settings.mfa.cancelSetup')}
</button> </button>
@@ -329,8 +326,8 @@ export default function AccountTab(): React.ReactElement {
{user?.mfa_enabled && ( {user?.mfa_enabled && (
<div className="space-y-3"> <div className="space-y-3">
<p className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>{t('settings.mfa.disableTitle')}</p> <p className="text-sm font-medium text-content-secondary">{t('settings.mfa.disableTitle')}</p>
<p className="text-xs" style={{ color: 'var(--text-muted)' }}>{t('settings.mfa.disableHint')}</p> <p className="text-xs text-content-muted">{t('settings.mfa.disableHint')}</p>
<input <input
type="password" type="password"
value={mfaDisablePwd} value={mfaDisablePwd}
@@ -373,22 +370,22 @@ export default function AccountTab(): React.ReactElement {
)} )}
{backupCodes && backupCodes.length > 0 && ( {backupCodes && backupCodes.length > 0 && (
<div className="space-y-3 p-3 rounded-lg border" style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-hover)' }}> <div className="space-y-3 p-3 rounded-lg border border-edge bg-surface-hover">
<p className="text-sm font-semibold m-0" style={{ color: 'var(--text-primary)' }}>{t('settings.mfa.backupTitle')}</p> <p className="text-sm font-semibold m-0 text-content">{t('settings.mfa.backupTitle')}</p>
<p className="text-xs m-0" style={{ color: 'var(--text-muted)' }}>{t('settings.mfa.backupDescription')}</p> <p className="text-xs m-0 text-content-muted">{t('settings.mfa.backupDescription')}</p>
<pre className="text-xs m-0 p-2 rounded border overflow-auto" style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)', color: 'var(--text-primary)', maxHeight: 220 }}>{backupCodesText}</pre> <pre className="text-xs m-0 p-2 rounded border overflow-auto border-edge bg-surface-card text-content" style={{ maxHeight: 220 }}>{backupCodesText}</pre>
<p className="text-xs m-0" style={{ color: '#b45309' }}>{t('settings.mfa.backupWarning')}</p> <p className="text-xs m-0" style={{ color: '#b45309' }}>{t('settings.mfa.backupWarning')}</p>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<button type="button" onClick={copyBackupCodes} className="px-3 py-2 rounded-lg text-xs border flex items-center gap-1.5" style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}> <button type="button" onClick={copyBackupCodes} className="px-3 py-2 rounded-lg text-xs border flex items-center gap-1.5 border-edge text-content-secondary">
<Copy size={13} /> {t('settings.mfa.backupCopy')} <Copy size={13} /> {t('settings.mfa.backupCopy')}
</button> </button>
<button type="button" onClick={downloadBackupCodes} className="px-3 py-2 rounded-lg text-xs border flex items-center gap-1.5" style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}> <button type="button" onClick={downloadBackupCodes} className="px-3 py-2 rounded-lg text-xs border flex items-center gap-1.5 border-edge text-content-secondary">
<Download size={13} /> {t('settings.mfa.backupDownload')} <Download size={13} /> {t('settings.mfa.backupDownload')}
</button> </button>
<button type="button" onClick={printBackupCodes} className="px-3 py-2 rounded-lg text-xs border flex items-center gap-1.5" style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}> <button type="button" onClick={printBackupCodes} className="px-3 py-2 rounded-lg text-xs border flex items-center gap-1.5 border-edge text-content-secondary">
<Printer size={13} /> {t('settings.mfa.backupPrint')} <Printer size={13} /> {t('settings.mfa.backupPrint')}
</button> </button>
<button type="button" onClick={dismissBackupCodes} className="px-3 py-2 rounded-lg text-xs border" style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}> <button type="button" onClick={dismissBackupCodes} className="px-3 py-2 rounded-lg text-xs border border-edge text-content-secondary">
{t('common.ok')} {t('common.ok')}
</button> </button>
</div> </div>
@@ -405,11 +402,10 @@ export default function AccountTab(): React.ReactElement {
{user?.avatar_url ? ( {user?.avatar_url ? (
<img src={user.avatar_url} alt="" style={{ width: 64, height: 64, borderRadius: '50%', objectFit: 'cover' }} /> <img src={user.avatar_url} alt="" style={{ width: 64, height: 64, borderRadius: '50%', objectFit: 'cover' }} />
) : ( ) : (
<div style={{ <div className="bg-surface-hover text-content-secondary" style={{
width: 64, height: 64, borderRadius: '50%', width: 64, height: 64, borderRadius: '50%',
display: 'flex', alignItems: 'center', justifyContent: 'center', display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 24, fontWeight: 700, fontSize: 24, fontWeight: 700,
background: 'var(--bg-hover)', color: 'var(--text-secondary)',
}}> }}>
{user?.username?.charAt(0).toUpperCase()} {user?.username?.charAt(0).toUpperCase()}
</div> </div>
@@ -447,8 +443,8 @@ export default function AccountTab(): React.ReactElement {
)} )}
</div> </div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<div className="text-sm" style={{ color: 'var(--text-muted)' }}> <div className="text-sm text-content-muted">
<span className="font-medium" style={{ display: 'inline-flex', alignItems: 'center', gap: 4, color: 'var(--text-secondary)' }}> <span className="font-medium text-content-secondary" style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
{user?.role === 'admin' ? <><Shield size={13} /> {t('settings.roleAdmin')}</> : t('settings.roleUser')} {user?.role === 'admin' ? <><Shield size={13} /> {t('settings.roleAdmin')}</> : t('settings.roleUser')}
</span> </span>
{(user as UserWithOidc)?.oidc_issuer && ( {(user as UserWithOidc)?.oidc_issuer && (
@@ -462,7 +458,7 @@ export default function AccountTab(): React.ReactElement {
)} )}
</div> </div>
{(user as UserWithOidc)?.oidc_issuer && ( {(user as UserWithOidc)?.oidc_issuer && (
<p style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: -2 }}> <p className="text-content-faint" style={{ fontSize: 11, marginTop: -2 }}>
{t('settings.oidcLinked')} {(user as UserWithOidc).oidc_issuer!.replace('https://', '').replace(/\/+$/, '')} {t('settings.oidcLinked')} {(user as UserWithOidc).oidc_issuer!.replace('https://', '').replace(/\/+$/, '')}
</p> </p>
)} )}
@@ -510,25 +506,25 @@ export default function AccountTab(): React.ReactElement {
background: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(4px)', background: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(4px)',
display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24,
}} onClick={() => setShowDeleteConfirm(false)}> }} onClick={() => setShowDeleteConfirm(false)}>
<div style={{ <div className="bg-surface-card" style={{
background: 'var(--bg-card)', borderRadius: 16, padding: '28px 24px', borderRadius: 16, padding: '28px 24px',
maxWidth: 400, width: '100%', boxShadow: '0 20px 60px rgba(0,0,0,0.3)', maxWidth: 400, width: '100%', boxShadow: '0 20px 60px rgba(0,0,0,0.3)',
}} onClick={e => e.stopPropagation()}> }} onClick={e => e.stopPropagation()}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 16 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 16 }}>
<div style={{ width: 36, height: 36, borderRadius: 10, background: '#fef3c7', display: 'flex', alignItems: 'center', justifyContent: 'center' }}> <div style={{ width: 36, height: 36, borderRadius: 10, background: '#fef3c7', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Shield size={18} style={{ color: '#d97706' }} /> <Shield size={18} style={{ color: '#d97706' }} />
</div> </div>
<h3 style={{ margin: 0, fontSize: 16, fontWeight: 700, color: 'var(--text-primary)' }}>{t('settings.deleteBlockedTitle')}</h3> <h3 className="text-content" style={{ margin: 0, fontSize: 16, fontWeight: 700 }}>{t('settings.deleteBlockedTitle')}</h3>
</div> </div>
<p style={{ fontSize: 13, color: 'var(--text-muted)', lineHeight: 1.6, margin: '0 0 20px' }}> <p className="text-content-muted" style={{ fontSize: 13, lineHeight: 1.6, margin: '0 0 20px' }}>
{t('settings.deleteBlockedMessage')} {t('settings.deleteBlockedMessage')}
</p> </p>
<div style={{ display: 'flex', justifyContent: 'flex-end' }}> <div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<button <button
onClick={() => setShowDeleteConfirm(false)} onClick={() => setShowDeleteConfirm(false)}
className="border border-edge bg-surface-card text-content-secondary"
style={{ style={{
padding: '8px 16px', borderRadius: 8, fontSize: 13, fontWeight: 500, padding: '8px 16px', borderRadius: 8, fontSize: 13, fontWeight: 500,
border: '1px solid var(--border-primary)', background: 'var(--bg-card)', color: 'var(--text-secondary)',
cursor: 'pointer', fontFamily: 'inherit', cursor: 'pointer', fontFamily: 'inherit',
}} }}
> >
@@ -546,25 +542,25 @@ export default function AccountTab(): React.ReactElement {
background: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(4px)', background: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(4px)',
display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24,
}} onClick={() => setShowDeleteConfirm(false)}> }} onClick={() => setShowDeleteConfirm(false)}>
<div style={{ <div className="bg-surface-card" style={{
background: 'var(--bg-card)', borderRadius: 16, padding: '28px 24px', borderRadius: 16, padding: '28px 24px',
maxWidth: 400, width: '100%', boxShadow: '0 20px 60px rgba(0,0,0,0.3)', maxWidth: 400, width: '100%', boxShadow: '0 20px 60px rgba(0,0,0,0.3)',
}} onClick={e => e.stopPropagation()}> }} onClick={e => e.stopPropagation()}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 16 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 16 }}>
<div style={{ width: 36, height: 36, borderRadius: 10, background: '#fef2f2', display: 'flex', alignItems: 'center', justifyContent: 'center' }}> <div style={{ width: 36, height: 36, borderRadius: 10, background: '#fef2f2', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Trash2 size={18} style={{ color: '#ef4444' }} /> <Trash2 size={18} style={{ color: '#ef4444' }} />
</div> </div>
<h3 style={{ margin: 0, fontSize: 16, fontWeight: 700, color: 'var(--text-primary)' }}>{t('settings.deleteAccountTitle')}</h3> <h3 className="text-content" style={{ margin: 0, fontSize: 16, fontWeight: 700 }}>{t('settings.deleteAccountTitle')}</h3>
</div> </div>
<p style={{ fontSize: 13, color: 'var(--text-muted)', lineHeight: 1.6, margin: '0 0 20px' }}> <p className="text-content-muted" style={{ fontSize: 13, lineHeight: 1.6, margin: '0 0 20px' }}>
{t('settings.deleteAccountWarning')} {t('settings.deleteAccountWarning')}
</p> </p>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}> <div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<button <button
onClick={() => setShowDeleteConfirm(false)} onClick={() => setShowDeleteConfirm(false)}
className="border border-edge bg-surface-card text-content-secondary"
style={{ style={{
padding: '8px 16px', borderRadius: 8, fontSize: 13, fontWeight: 500, padding: '8px 16px', borderRadius: 8, fontSize: 13, fontWeight: 500,
border: '1px solid var(--border-primary)', background: 'var(--bg-card)', color: 'var(--text-secondary)',
cursor: 'pointer', fontFamily: 'inherit', cursor: 'pointer', fontFamily: 'inherit',
}} }}
> >
@@ -301,21 +301,21 @@ function IntegrationsMcpSection(props: any) {
<Section title={t('settings.mcp.title')} icon={Terminal}> <Section title={t('settings.mcp.title')} icon={Terminal}>
{/* Endpoint URL */} {/* Endpoint URL */}
<div> <div>
<label className="block text-sm font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>{t('settings.mcp.endpoint')}</label> <label className="block text-sm font-medium mb-1.5 text-content-secondary">{t('settings.mcp.endpoint')}</label>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<code className="flex-1 px-3 py-2 rounded-lg text-sm font-mono border" style={{ background: 'var(--bg-secondary)', borderColor: 'var(--border-primary)', color: 'var(--text-primary)' }}> <code className="flex-1 px-3 py-2 rounded-lg text-sm font-mono border bg-surface-secondary border-edge text-content">
{mcpEndpoint} {mcpEndpoint}
</code> </code>
<button onClick={() => handleCopy(mcpEndpoint, 'endpoint')} <button onClick={() => handleCopy(mcpEndpoint, 'endpoint')}
className="p-2 rounded-lg border transition-colors hover:bg-slate-100 dark:hover:bg-slate-700" className="p-2 rounded-lg border border-edge transition-colors hover:bg-slate-100 dark:hover:bg-slate-700"
style={{ borderColor: 'var(--border-primary)' }} title={t('settings.mcp.copy')}> title={t('settings.mcp.copy')}>
{copiedKey === 'endpoint' ? <Check className="w-4 h-4 text-green-500" /> : <Copy className="w-4 h-4" style={{ color: 'var(--text-secondary)' }} />} {copiedKey === 'endpoint' ? <Check className="w-4 h-4 text-green-500" /> : <Copy className="w-4 h-4 text-content-secondary" />}
</button> </button>
</div> </div>
</div> </div>
{/* Sub-tab bar */} {/* Sub-tab bar */}
<div className="flex gap-1 rounded-lg p-1" style={{ background: 'var(--bg-secondary)', border: '1px solid var(--border-primary)' }}> <div className="flex gap-1 rounded-lg p-1 border bg-surface-secondary border-edge">
<button <button
onClick={() => setActiveMcpTab('oauth')} onClick={() => setActiveMcpTab('oauth')}
className={`flex-1 px-3 py-1.5 text-sm font-medium rounded-md transition-colors ${ className={`flex-1 px-3 py-1.5 text-sm font-medium rounded-md transition-colors ${
@@ -340,25 +340,23 @@ function IntegrationsMcpSection(props: any) {
{activeMcpTab === 'oauth' && ( {activeMcpTab === 'oauth' && (
<> <>
{/* JSON config — OAuth (collapsible) */} {/* JSON config — OAuth (collapsible) */}
<div className="rounded-lg border overflow-hidden" style={{ borderColor: 'var(--border-primary)' }}> <div className="rounded-lg border overflow-hidden border-edge">
<button <button
onClick={() => setConfigOpenOAuth(o => !o)} onClick={() => setConfigOpenOAuth(o => !o)}
className="w-full flex items-center justify-between px-3 py-2.5 transition-colors hover:bg-slate-50 dark:hover:bg-slate-800" className="w-full flex items-center justify-between px-3 py-2.5 transition-colors hover:bg-slate-50 dark:hover:bg-slate-800 bg-surface-secondary">
style={{ background: 'var(--bg-secondary)' }}> <span className="text-sm font-medium text-content-secondary">{t('settings.mcp.clientConfig')}</span>
<span className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>{t('settings.mcp.clientConfig')}</span>
{configOpenOAuth ? <ChevronDown className="w-4 h-4" style={{ color: 'var(--text-tertiary)' }} /> : <ChevronRight className="w-4 h-4" style={{ color: 'var(--text-tertiary)' }} />} {configOpenOAuth ? <ChevronDown className="w-4 h-4" style={{ color: 'var(--text-tertiary)' }} /> : <ChevronRight className="w-4 h-4" style={{ color: 'var(--text-tertiary)' }} />}
</button> </button>
{configOpenOAuth && ( {configOpenOAuth && (
<div className="p-3 border-t" style={{ borderColor: 'var(--border-primary)' }}> <div className="p-3 border-t border-edge">
<div className="flex justify-end mb-1.5"> <div className="flex justify-end mb-1.5">
<button onClick={() => handleCopy(mcpJsonConfigOAuth, 'json-oauth')} <button onClick={() => handleCopy(mcpJsonConfigOAuth, 'json-oauth')}
className="flex items-center gap-1.5 px-2.5 py-1 rounded text-xs border transition-colors hover:bg-slate-100 dark:hover:bg-slate-700" className="flex items-center gap-1.5 px-2.5 py-1 rounded text-xs border transition-colors hover:bg-slate-100 dark:hover:bg-slate-700 border-edge text-content-secondary">
style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
{copiedKey === 'json-oauth' ? <Check className="w-3 h-3 text-green-500" /> : <Copy className="w-3 h-3" />} {copiedKey === 'json-oauth' ? <Check className="w-3 h-3 text-green-500" /> : <Copy className="w-3 h-3" />}
{copiedKey === 'json-oauth' ? t('settings.mcp.copied') : t('settings.mcp.copy')} {copiedKey === 'json-oauth' ? t('settings.mcp.copied') : t('settings.mcp.copy')}
</button> </button>
</div> </div>
<pre className="p-3 rounded-lg text-xs font-mono overflow-x-auto border" style={{ background: 'var(--bg-secondary)', borderColor: 'var(--border-primary)', color: 'var(--text-primary)' }}> <pre className="p-3 rounded-lg text-xs font-mono overflow-x-auto border bg-surface-secondary border-edge text-content">
{mcpJsonConfigOAuth} {mcpJsonConfigOAuth}
</pre> </pre>
<p className="mt-1.5 text-xs" style={{ color: 'var(--text-tertiary)' }}>{t('settings.mcp.clientConfigHintOAuth')}</p> <p className="mt-1.5 text-xs" style={{ color: 'var(--text-tertiary)' }}>{t('settings.mcp.clientConfigHintOAuth')}</p>
@@ -377,11 +375,11 @@ function IntegrationsMcpSection(props: any) {
</div> </div>
{oauthClients.length === 0 ? ( {oauthClients.length === 0 ? (
<p className="text-sm py-3 text-center rounded-lg border" style={{ color: 'var(--text-tertiary)', borderColor: 'var(--border-primary)' }}> <p className="text-sm py-3 text-center rounded-lg border border-edge" style={{ color: 'var(--text-tertiary)' }}>
{t('settings.oauth.noClients')} {t('settings.oauth.noClients')}
</p> </p>
) : ( ) : (
<div className="rounded-lg border overflow-hidden" style={{ borderColor: 'var(--border-primary)' }}> <div className="rounded-lg border overflow-hidden border-edge">
{oauthClients.map((client, i) => ( {oauthClients.map((client, i) => (
<div key={client.id} className="px-4 py-3" <div key={client.id} className="px-4 py-3"
style={{ borderBottom: i < oauthClients.length - 1 ? '1px solid var(--border-primary)' : undefined }}> style={{ borderBottom: i < oauthClients.length - 1 ? '1px solid var(--border-primary)' : undefined }}>
@@ -389,7 +387,7 @@ function IntegrationsMcpSection(props: any) {
<KeyRound className="w-4 h-4 flex-shrink-0" style={{ color: 'var(--text-tertiary)' }} /> <KeyRound className="w-4 h-4 flex-shrink-0" style={{ color: 'var(--text-tertiary)' }} />
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<p className="text-sm font-medium truncate" style={{ color: 'var(--text-primary)' }}>{client.name}</p> <p className="text-sm font-medium truncate text-content">{client.name}</p>
{client.allows_client_credentials && ( {client.allows_client_credentials && (
<span className="px-1.5 py-0.5 rounded text-[10px] font-medium flex-shrink-0" <span className="px-1.5 py-0.5 rounded text-[10px] font-medium flex-shrink-0"
style={{ background: 'rgba(99,102,241,0.12)', color: '#4f46e5', border: '1px solid rgba(99,102,241,0.3)' }}> style={{ background: 'rgba(99,102,241,0.12)', color: '#4f46e5', border: '1px solid rgba(99,102,241,0.3)' }}>
@@ -403,13 +401,13 @@ function IntegrationsMcpSection(props: any) {
</p> </p>
<div className="flex flex-wrap gap-1 mt-1.5"> <div className="flex flex-wrap gap-1 mt-1.5">
{(oauthScopesExpanded[client.id] ? client.allowed_scopes : client.allowed_scopes.slice(0, 5)).map(s => ( {(oauthScopesExpanded[client.id] ? client.allowed_scopes : client.allowed_scopes.slice(0, 5)).map(s => (
<span key={s} className="px-1.5 py-0.5 rounded text-xs" style={{ background: 'var(--bg-secondary)', color: 'var(--text-tertiary)', border: '1px solid var(--border-primary)' }}>{s}</span> <span key={s} className="px-1.5 py-0.5 rounded text-xs border bg-surface-secondary border-edge" style={{ color: 'var(--text-tertiary)' }}>{s}</span>
))} ))}
{client.allowed_scopes.length > 5 && ( {client.allowed_scopes.length > 5 && (
<button <button
onClick={() => setOauthScopesExpanded(prev => ({ ...prev, [client.id]: !prev[client.id] }))} onClick={() => setOauthScopesExpanded(prev => ({ ...prev, [client.id]: !prev[client.id] }))}
className="px-1.5 py-0.5 rounded text-xs transition-colors hover:bg-slate-100 dark:hover:bg-slate-700" className="px-1.5 py-0.5 rounded text-xs transition-colors hover:bg-slate-100 dark:hover:bg-slate-700 border border-edge"
style={{ color: 'var(--text-tertiary)', border: '1px solid var(--border-primary)' }}> style={{ color: 'var(--text-tertiary)' }}>
{oauthScopesExpanded[client.id] ? '' : `+${client.allowed_scopes.length - 5}`} {oauthScopesExpanded[client.id] ? '' : `+${client.allowed_scopes.length - 5}`}
</button> </button>
)} )}
@@ -435,21 +433,21 @@ function IntegrationsMcpSection(props: any) {
{/* Active OAuth Sessions */} {/* Active OAuth Sessions */}
{oauthSessions.length > 0 && ( {oauthSessions.length > 0 && (
<div> <div>
<label className="text-sm font-medium block mb-2" style={{ color: 'var(--text-secondary)' }}>{t('settings.oauth.activeSessions')}</label> <label className="text-sm font-medium block mb-2 text-content-secondary">{t('settings.oauth.activeSessions')}</label>
<div className="rounded-lg border overflow-hidden" style={{ borderColor: 'var(--border-primary)' }}> <div className="rounded-lg border overflow-hidden border-edge">
{oauthSessions.map((session, i) => ( {oauthSessions.map((session, i) => (
<div key={session.id} className="flex items-center gap-3 px-4 py-3" <div key={session.id} className="flex items-center gap-3 px-4 py-3"
style={{ borderBottom: i < oauthSessions.length - 1 ? '1px solid var(--border-primary)' : undefined }}> style={{ borderBottom: i < oauthSessions.length - 1 ? '1px solid var(--border-primary)' : undefined }}>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate" style={{ color: 'var(--text-primary)' }}>{session.client_name}</p> <p className="text-sm font-medium truncate text-content">{session.client_name}</p>
<p className="text-xs mt-0.5" style={{ color: 'var(--text-tertiary)' }}> <p className="text-xs mt-0.5" style={{ color: 'var(--text-tertiary)' }}>
{t('settings.oauth.sessionScopes')}: {session.scopes.join(', ')} {t('settings.oauth.sessionScopes')}: {session.scopes.join(', ')}
<span className="ml-3">{t('settings.oauth.sessionExpires')} {new Date(session.access_token_expires_at).toLocaleDateString(locale)}</span> <span className="ml-3">{t('settings.oauth.sessionExpires')} {new Date(session.access_token_expires_at).toLocaleDateString(locale)}</span>
</p> </p>
</div> </div>
<button onClick={() => setOauthRevokeId(session.id)} <button onClick={() => setOauthRevokeId(session.id)}
className="px-2.5 py-1 rounded text-xs border transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20" className="px-2.5 py-1 rounded text-xs border transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 border-edge"
style={{ borderColor: 'var(--border-primary)', color: 'var(--text-tertiary)' }}> style={{ color: 'var(--text-tertiary)' }}>
{t('settings.oauth.revoke')} {t('settings.oauth.revoke')}
</button> </button>
</div> </div>
@@ -469,25 +467,23 @@ function IntegrationsMcpSection(props: any) {
</div> </div>
{/* JSON config — API Token (collapsible) */} {/* JSON config — API Token (collapsible) */}
<div className="rounded-lg border overflow-hidden" style={{ borderColor: 'var(--border-primary)' }}> <div className="rounded-lg border overflow-hidden border-edge">
<button <button
onClick={() => setConfigOpenToken(o => !o)} onClick={() => setConfigOpenToken(o => !o)}
className="w-full flex items-center justify-between px-3 py-2.5 transition-colors hover:bg-slate-50 dark:hover:bg-slate-800" className="w-full flex items-center justify-between px-3 py-2.5 transition-colors hover:bg-slate-50 dark:hover:bg-slate-800 bg-surface-secondary">
style={{ background: 'var(--bg-secondary)' }}> <span className="text-sm font-medium text-content-secondary">{t('settings.mcp.clientConfig')}</span>
<span className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>{t('settings.mcp.clientConfig')}</span>
{configOpenToken ? <ChevronDown className="w-4 h-4" style={{ color: 'var(--text-tertiary)' }} /> : <ChevronRight className="w-4 h-4" style={{ color: 'var(--text-tertiary)' }} />} {configOpenToken ? <ChevronDown className="w-4 h-4" style={{ color: 'var(--text-tertiary)' }} /> : <ChevronRight className="w-4 h-4" style={{ color: 'var(--text-tertiary)' }} />}
</button> </button>
{configOpenToken && ( {configOpenToken && (
<div className="p-3 border-t" style={{ borderColor: 'var(--border-primary)' }}> <div className="p-3 border-t border-edge">
<div className="flex justify-end mb-1.5"> <div className="flex justify-end mb-1.5">
<button onClick={() => handleCopy(mcpJsonConfig, 'json-token')} <button onClick={() => handleCopy(mcpJsonConfig, 'json-token')}
className="flex items-center gap-1.5 px-2.5 py-1 rounded text-xs border transition-colors hover:bg-slate-100 dark:hover:bg-slate-700" className="flex items-center gap-1.5 px-2.5 py-1 rounded text-xs border transition-colors hover:bg-slate-100 dark:hover:bg-slate-700 border-edge text-content-secondary">
style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
{copiedKey === 'json-token' ? <Check className="w-3 h-3 text-green-500" /> : <Copy className="w-3 h-3" />} {copiedKey === 'json-token' ? <Check className="w-3 h-3 text-green-500" /> : <Copy className="w-3 h-3" />}
{copiedKey === 'json-token' ? t('settings.mcp.copied') : t('settings.mcp.copy')} {copiedKey === 'json-token' ? t('settings.mcp.copied') : t('settings.mcp.copy')}
</button> </button>
</div> </div>
<pre className="p-3 rounded-lg text-xs font-mono overflow-x-auto border" style={{ background: 'var(--bg-secondary)', borderColor: 'var(--border-primary)', color: 'var(--text-primary)' }}> <pre className="p-3 rounded-lg text-xs font-mono overflow-x-auto border bg-surface-secondary border-edge text-content">
{mcpJsonConfig} {mcpJsonConfig}
</pre> </pre>
<p className="mt-1.5 text-xs" style={{ color: 'var(--text-tertiary)' }}>{t('settings.mcp.clientConfigHint')}</p> <p className="mt-1.5 text-xs" style={{ color: 'var(--text-tertiary)' }}>{t('settings.mcp.clientConfigHint')}</p>
@@ -497,8 +493,8 @@ function IntegrationsMcpSection(props: any) {
<div className="flex justify-end"> <div className="flex justify-end">
<button onClick={() => { setMcpModalOpen(true); setMcpCreatedToken(null); setMcpNewName('') }} <button onClick={() => { setMcpModalOpen(true); setMcpCreatedToken(null); setMcpNewName('') }}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors opacity-60" className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors opacity-60 text-content-secondary"
style={{ background: 'var(--bg-tertiary, #e5e7eb)', color: 'var(--text-secondary)' }}> style={{ background: 'var(--bg-tertiary, #e5e7eb)' }}>
<Plus className="w-3.5 h-3.5" /> {t('settings.mcp.createToken')} <Plus className="w-3.5 h-3.5" /> {t('settings.mcp.createToken')}
</button> </button>
</div> </div>
@@ -508,12 +504,12 @@ function IntegrationsMcpSection(props: any) {
{t('settings.mcp.noTokens')} {t('settings.mcp.noTokens')}
</p> </p>
) : ( ) : (
<div className="rounded-lg border overflow-hidden" style={{ borderColor: 'var(--border-primary)' }}> <div className="rounded-lg border overflow-hidden border-edge">
{mcpTokens.map((token, i) => ( {mcpTokens.map((token, i) => (
<div key={token.id} className="flex items-center gap-3 px-4 py-3" <div key={token.id} className="flex items-center gap-3 px-4 py-3"
style={{ borderBottom: i < mcpTokens.length - 1 ? '1px solid var(--border-primary)' : undefined }}> style={{ borderBottom: i < mcpTokens.length - 1 ? '1px solid var(--border-primary)' : undefined }}>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate" style={{ color: 'var(--text-primary)' }}>{token.name}</p> <p className="text-sm font-medium truncate text-content">{token.name}</p>
<p className="text-xs font-mono mt-0.5" style={{ color: 'var(--text-tertiary)' }}> <p className="text-xs font-mono mt-0.5" style={{ color: 'var(--text-tertiary)' }}>
{token.token_prefix}... {token.token_prefix}...
<span className="ml-3 font-sans">{t('settings.mcp.tokenCreatedAt')} {new Date(token.created_at).toLocaleDateString(locale)}</span> <span className="ml-3 font-sans">{t('settings.mcp.tokenCreatedAt')} {new Date(token.created_at).toLocaleDateString(locale)}</span>
@@ -547,22 +543,21 @@ function McpTokenModals(props: any) {
{mcpModalOpen && ( {mcpModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" style={{ background: 'rgba(0,0,0,0.5)' }} <div className="fixed inset-0 z-50 flex items-center justify-center p-4" style={{ background: 'rgba(0,0,0,0.5)' }}
onClick={e => { if (e.target === e.currentTarget && !mcpCreatedToken) setMcpModalOpen(false) }}> onClick={e => { if (e.target === e.currentTarget && !mcpCreatedToken) setMcpModalOpen(false) }}>
<div className="rounded-xl shadow-xl w-full max-w-md p-6 space-y-4" style={{ background: 'var(--bg-card)' }}> <div className="rounded-xl shadow-xl w-full max-w-md p-6 space-y-4 bg-surface-card">
{!mcpCreatedToken ? ( {!mcpCreatedToken ? (
<> <>
<h3 className="text-lg font-semibold" style={{ color: 'var(--text-primary)' }}>{t('settings.mcp.modal.createTitle')}</h3> <h3 className="text-lg font-semibold text-content">{t('settings.mcp.modal.createTitle')}</h3>
<div> <div>
<label className="block text-sm font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>{t('settings.mcp.modal.tokenName')}</label> <label className="block text-sm font-medium mb-1.5 text-content-secondary">{t('settings.mcp.modal.tokenName')}</label>
<input type="text" value={mcpNewName} onChange={e => setMcpNewName(e.target.value)} <input type="text" value={mcpNewName} onChange={e => setMcpNewName(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleCreateMcpToken()} onKeyDown={e => e.key === 'Enter' && handleCreateMcpToken()}
placeholder={t('settings.mcp.modal.tokenNamePlaceholder')} placeholder={t('settings.mcp.modal.tokenNamePlaceholder')}
className="w-full px-3 py-2.5 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-slate-400" className="w-full px-3 py-2.5 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-slate-400 border-edge bg-surface-secondary text-content"
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-secondary)', color: 'var(--text-primary)' }}
autoFocus /> autoFocus />
</div> </div>
<div className="flex gap-2 justify-end pt-1"> <div className="flex gap-2 justify-end pt-1">
<button onClick={() => setMcpModalOpen(false)} <button onClick={() => setMcpModalOpen(false)}
className="px-4 py-2 rounded-lg text-sm border" style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}> className="px-4 py-2 rounded-lg text-sm border border-edge text-content-secondary">
{t('common.cancel')} {t('common.cancel')}
</button> </button>
<button onClick={handleCreateMcpToken} disabled={!mcpNewName.trim() || mcpCreating} <button onClick={handleCreateMcpToken} disabled={!mcpNewName.trim() || mcpCreating}
@@ -573,18 +568,18 @@ function McpTokenModals(props: any) {
</> </>
) : ( ) : (
<> <>
<h3 className="text-lg font-semibold" style={{ color: 'var(--text-primary)' }}>{t('settings.mcp.modal.createdTitle')}</h3> <h3 className="text-lg font-semibold text-content">{t('settings.mcp.modal.createdTitle')}</h3>
<div className="flex items-start gap-2 p-3 rounded-lg border border-amber-200" style={{ background: 'rgba(251,191,36,0.1)' }}> <div className="flex items-start gap-2 p-3 rounded-lg border border-amber-200" style={{ background: 'rgba(251,191,36,0.1)' }}>
<span className="text-amber-500 mt-0.5"></span> <span className="text-amber-500 mt-0.5"></span>
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>{t('settings.mcp.modal.createdWarning')}</p> <p className="text-sm text-content-secondary">{t('settings.mcp.modal.createdWarning')}</p>
</div> </div>
<div className="relative"> <div className="relative">
<pre className="p-3 pr-10 rounded-lg text-xs font-mono break-all border whitespace-pre-wrap" style={{ background: 'var(--bg-secondary)', borderColor: 'var(--border-primary)', color: 'var(--text-primary)' }}> <pre className="p-3 pr-10 rounded-lg text-xs font-mono break-all border whitespace-pre-wrap bg-surface-secondary border-edge text-content">
{mcpCreatedToken} {mcpCreatedToken}
</pre> </pre>
<button onClick={() => handleCopy(mcpCreatedToken, 'new-token')} <button onClick={() => handleCopy(mcpCreatedToken, 'new-token')}
className="absolute top-2 right-2 p-1.5 rounded transition-colors hover:bg-slate-200 dark:hover:bg-slate-600" className="absolute top-2 right-2 p-1.5 rounded transition-colors hover:bg-slate-200 dark:hover:bg-slate-600 text-content-secondary"
style={{ color: 'var(--text-secondary)' }} title={t('settings.mcp.copy')}> title={t('settings.mcp.copy')}>
{copiedKey === 'new-token' ? <Check className="w-4 h-4 text-green-500" /> : <Copy className="w-4 h-4" />} {copiedKey === 'new-token' ? <Check className="w-4 h-4 text-green-500" /> : <Copy className="w-4 h-4" />}
</button> </button>
</div> </div>
@@ -604,12 +599,12 @@ function McpTokenModals(props: any) {
{mcpDeleteId !== null && ( {mcpDeleteId !== null && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" style={{ background: 'rgba(0,0,0,0.5)' }} <div className="fixed inset-0 z-50 flex items-center justify-center p-4" style={{ background: 'rgba(0,0,0,0.5)' }}
onClick={e => { if (e.target === e.currentTarget) setMcpDeleteId(null) }}> onClick={e => { if (e.target === e.currentTarget) setMcpDeleteId(null) }}>
<div className="rounded-xl shadow-xl w-full max-w-sm p-6 space-y-4" style={{ background: 'var(--bg-card)' }}> <div className="rounded-xl shadow-xl w-full max-w-sm p-6 space-y-4 bg-surface-card">
<h3 className="text-base font-semibold" style={{ color: 'var(--text-primary)' }}>{t('settings.mcp.deleteTokenTitle')}</h3> <h3 className="text-base font-semibold text-content">{t('settings.mcp.deleteTokenTitle')}</h3>
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>{t('settings.mcp.deleteTokenMessage')}</p> <p className="text-sm text-content-secondary">{t('settings.mcp.deleteTokenMessage')}</p>
<div className="flex gap-2 justify-end"> <div className="flex gap-2 justify-end">
<button onClick={() => setMcpDeleteId(null)} <button onClick={() => setMcpDeleteId(null)}
className="px-4 py-2 rounded-lg text-sm border" style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}> className="px-4 py-2 rounded-lg text-sm border border-edge text-content-secondary">
{t('common.cancel')} {t('common.cancel')}
</button> </button>
<button onClick={() => handleDeleteMcpToken(mcpDeleteId)} <button onClick={() => handleDeleteMcpToken(mcpDeleteId)}
@@ -635,10 +630,10 @@ function OAuthClientModals(props: any) {
{oauthCreateOpen && ( {oauthCreateOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" style={{ background: 'rgba(0,0,0,0.5)' }} <div className="fixed inset-0 z-50 flex items-center justify-center p-4" style={{ background: 'rgba(0,0,0,0.5)' }}
onClick={e => { if (e.target === e.currentTarget && !oauthCreatedClient) setOauthCreateOpen(false) }}> onClick={e => { if (e.target === e.currentTarget && !oauthCreatedClient) setOauthCreateOpen(false) }}>
<div className="rounded-xl shadow-xl w-full max-w-lg p-6 space-y-4 overflow-y-auto max-h-[90vh]" style={{ background: 'var(--bg-card)' }}> <div className="rounded-xl shadow-xl w-full max-w-lg p-6 space-y-4 overflow-y-auto max-h-[90vh] bg-surface-card">
{!oauthCreatedClient ? ( {!oauthCreatedClient ? (
<> <>
<h3 className="text-lg font-semibold" style={{ color: 'var(--text-primary)' }}>{t('settings.oauth.modal.createTitle')}</h3> <h3 className="text-lg font-semibold text-content">{t('settings.oauth.modal.createTitle')}</h3>
<div> <div>
<label className="block text-xs font-medium mb-2" style={{ color: 'var(--text-tertiary)' }}>{t('settings.oauth.modal.presets')}</label> <label className="block text-xs font-medium mb-2" style={{ color: 'var(--text-tertiary)' }}>{t('settings.oauth.modal.presets')}</label>
@@ -652,8 +647,7 @@ function OAuthClientModals(props: any) {
setOauthNewUris(preset.uris) setOauthNewUris(preset.uris)
setOauthNewScopes(preset.scopes) setOauthNewScopes(preset.scopes)
}} }}
className="px-2.5 py-1 rounded-md text-xs font-medium border transition-colors hover:bg-slate-100 dark:hover:bg-slate-700" className="px-2.5 py-1 rounded-md text-xs font-medium border transition-colors hover:bg-slate-100 dark:hover:bg-slate-700 border-edge text-content-secondary bg-surface-secondary">
style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)', background: 'var(--bg-secondary)' }}>
{preset.label} {preset.label}
</button> </button>
))} ))}
@@ -661,11 +655,10 @@ function OAuthClientModals(props: any) {
</div> </div>
<div> <div>
<label className="block text-sm font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>{t('settings.oauth.modal.clientName')}</label> <label className="block text-sm font-medium mb-1.5 text-content-secondary">{t('settings.oauth.modal.clientName')}</label>
<input type="text" value={oauthNewName} onChange={e => setOauthNewName(e.target.value)} <input type="text" value={oauthNewName} onChange={e => setOauthNewName(e.target.value)}
placeholder={t('settings.oauth.modal.clientNamePlaceholder')} placeholder={t('settings.oauth.modal.clientNamePlaceholder')}
className="w-full px-3 py-2.5 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-slate-400" className="w-full px-3 py-2.5 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-slate-400 border-edge bg-surface-secondary text-content"
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-secondary)', color: 'var(--text-primary)' }}
autoFocus /> autoFocus />
</div> </div>
@@ -673,32 +666,31 @@ function OAuthClientModals(props: any) {
<input type="checkbox" checked={oauthIsMachine} onChange={e => setOauthIsMachine(e.target.checked)} <input type="checkbox" checked={oauthIsMachine} onChange={e => setOauthIsMachine(e.target.checked)}
className="mt-0.5 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500" /> className="mt-0.5 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500" />
<div> <div>
<span className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>{t('settings.oauth.modal.machineClient')}</span> <span className="text-sm font-medium text-content-secondary">{t('settings.oauth.modal.machineClient')}</span>
<p className="text-xs mt-0.5" style={{ color: 'var(--text-tertiary)' }}>{t('settings.oauth.modal.machineClientHint')}</p> <p className="text-xs mt-0.5" style={{ color: 'var(--text-tertiary)' }}>{t('settings.oauth.modal.machineClientHint')}</p>
</div> </div>
</label> </label>
{!oauthIsMachine && ( {!oauthIsMachine && (
<div> <div>
<label className="block text-sm font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>{t('settings.oauth.modal.redirectUris')}</label> <label className="block text-sm font-medium mb-1.5 text-content-secondary">{t('settings.oauth.modal.redirectUris')}</label>
<textarea value={oauthNewUris} onChange={e => setOauthNewUris(e.target.value)} <textarea value={oauthNewUris} onChange={e => setOauthNewUris(e.target.value)}
placeholder={t('settings.oauth.modal.redirectUrisPlaceholder')} placeholder={t('settings.oauth.modal.redirectUrisPlaceholder')}
rows={3} rows={3}
className="w-full px-3 py-2.5 border rounded-lg text-sm font-mono resize-none focus:outline-none focus:ring-2 focus:ring-slate-400" className="w-full px-3 py-2.5 border rounded-lg text-sm font-mono resize-none focus:outline-none focus:ring-2 focus:ring-slate-400 border-edge bg-surface-secondary text-content" />
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-secondary)', color: 'var(--text-primary)' }} />
<p className="mt-1 text-xs" style={{ color: 'var(--text-tertiary)' }}>{t('settings.oauth.modal.redirectUrisHint')}</p> <p className="mt-1 text-xs" style={{ color: 'var(--text-tertiary)' }}>{t('settings.oauth.modal.redirectUrisHint')}</p>
</div> </div>
)} )}
<div> <div>
<label className="block text-sm font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>{t('settings.oauth.modal.scopes')}</label> <label className="block text-sm font-medium mb-1 text-content-secondary">{t('settings.oauth.modal.scopes')}</label>
<p className="text-xs mb-2" style={{ color: 'var(--text-tertiary)' }}>{t('settings.oauth.modal.scopesHint')}</p> <p className="text-xs mb-2" style={{ color: 'var(--text-tertiary)' }}>{t('settings.oauth.modal.scopesHint')}</p>
<ScopeGroupPicker selected={oauthNewScopes} onChange={setOauthNewScopes} /> <ScopeGroupPicker selected={oauthNewScopes} onChange={setOauthNewScopes} />
</div> </div>
<div className="flex gap-2 justify-end pt-1"> <div className="flex gap-2 justify-end pt-1">
<button onClick={() => setOauthCreateOpen(false)} <button onClick={() => setOauthCreateOpen(false)}
className="px-4 py-2 rounded-lg text-sm border" style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}> className="px-4 py-2 rounded-lg text-sm border border-edge text-content-secondary">
{t('common.cancel')} {t('common.cancel')}
</button> </button>
<button onClick={handleCreateOAuthClient} <button onClick={handleCreateOAuthClient}
@@ -710,43 +702,41 @@ function OAuthClientModals(props: any) {
</> </>
) : ( ) : (
<> <>
<h3 className="text-lg font-semibold" style={{ color: 'var(--text-primary)' }}>{t('settings.oauth.modal.createdTitle')}</h3> <h3 className="text-lg font-semibold text-content">{t('settings.oauth.modal.createdTitle')}</h3>
<div className="flex items-start gap-2 p-3 rounded-lg border border-amber-200" style={{ background: 'rgba(251,191,36,0.1)' }}> <div className="flex items-start gap-2 p-3 rounded-lg border border-amber-200" style={{ background: 'rgba(251,191,36,0.1)' }}>
<span className="text-amber-500 mt-0.5"></span> <span className="text-amber-500 mt-0.5"></span>
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>{t('settings.oauth.modal.createdWarning')}</p> <p className="text-sm text-content-secondary">{t('settings.oauth.modal.createdWarning')}</p>
</div> </div>
<div className="space-y-3"> <div className="space-y-3">
<div> <div>
<label className="block text-xs font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>{t('settings.oauth.clientId')}</label> <label className="block text-xs font-medium mb-1 text-content-secondary">{t('settings.oauth.clientId')}</label>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<code className="flex-1 px-3 py-2 rounded-lg text-xs font-mono border" style={{ background: 'var(--bg-secondary)', borderColor: 'var(--border-primary)', color: 'var(--text-primary)' }}> <code className="flex-1 px-3 py-2 rounded-lg text-xs font-mono border bg-surface-secondary border-edge text-content">
{oauthCreatedClient.client_id} {oauthCreatedClient.client_id}
</code> </code>
<button onClick={() => handleCopy(oauthCreatedClient.client_id, 'new-client-id')} <button onClick={() => handleCopy(oauthCreatedClient.client_id, 'new-client-id')}
className="p-2 rounded-lg border transition-colors hover:bg-slate-100 dark:hover:bg-slate-700" className="p-2 rounded-lg border transition-colors hover:bg-slate-100 dark:hover:bg-slate-700 border-edge">
style={{ borderColor: 'var(--border-primary)' }}> {copiedKey === 'new-client-id' ? <Check className="w-4 h-4 text-green-500" /> : <Copy className="w-4 h-4 text-content-secondary" />}
{copiedKey === 'new-client-id' ? <Check className="w-4 h-4 text-green-500" /> : <Copy className="w-4 h-4" style={{ color: 'var(--text-secondary)' }} />}
</button> </button>
</div> </div>
</div> </div>
<div> <div>
<label className="block text-xs font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>{t('settings.oauth.clientSecret')}</label> <label className="block text-xs font-medium mb-1 text-content-secondary">{t('settings.oauth.clientSecret')}</label>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<code className="flex-1 px-3 py-2 rounded-lg text-xs font-mono border break-all" style={{ background: 'var(--bg-secondary)', borderColor: 'var(--border-primary)', color: 'var(--text-primary)' }}> <code className="flex-1 px-3 py-2 rounded-lg text-xs font-mono border break-all bg-surface-secondary border-edge text-content">
{oauthCreatedClient.client_secret} {oauthCreatedClient.client_secret}
</code> </code>
<button onClick={() => handleCopy(oauthCreatedClient.client_secret!, 'new-client-secret')} <button onClick={() => handleCopy(oauthCreatedClient.client_secret!, 'new-client-secret')}
className="p-2 rounded-lg border transition-colors hover:bg-slate-100 dark:hover:bg-slate-700" className="p-2 rounded-lg border transition-colors hover:bg-slate-100 dark:hover:bg-slate-700 border-edge">
style={{ borderColor: 'var(--border-primary)' }}> {copiedKey === 'new-client-secret' ? <Check className="w-4 h-4 text-green-500" /> : <Copy className="w-4 h-4 text-content-secondary" />}
{copiedKey === 'new-client-secret' ? <Check className="w-4 h-4 text-green-500" /> : <Copy className="w-4 h-4" style={{ color: 'var(--text-secondary)' }} />}
</button> </button>
</div> </div>
</div> </div>
</div> </div>
{oauthCreatedClient?.allows_client_credentials && ( {oauthCreatedClient?.allows_client_credentials && (
<div className="p-3 rounded-lg border text-xs font-mono" style={{ background: 'var(--bg-secondary)', borderColor: 'var(--border-primary)', color: 'var(--text-tertiary)' }}> <div className="p-3 rounded-lg border text-xs font-mono bg-surface-secondary border-edge" style={{ color: 'var(--text-tertiary)' }}>
{t('settings.oauth.modal.machineClientUsage')} {t('settings.oauth.modal.machineClientUsage')}
</div> </div>
)} )}
@@ -767,12 +757,12 @@ function OAuthClientModals(props: any) {
{oauthDeleteId !== null && ( {oauthDeleteId !== null && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" style={{ background: 'rgba(0,0,0,0.5)' }} <div className="fixed inset-0 z-50 flex items-center justify-center p-4" style={{ background: 'rgba(0,0,0,0.5)' }}
onClick={e => { if (e.target === e.currentTarget) setOauthDeleteId(null) }}> onClick={e => { if (e.target === e.currentTarget) setOauthDeleteId(null) }}>
<div className="rounded-xl shadow-xl w-full max-w-sm p-6 space-y-4" style={{ background: 'var(--bg-card)' }}> <div className="rounded-xl shadow-xl w-full max-w-sm p-6 space-y-4 bg-surface-card">
<h3 className="text-base font-semibold" style={{ color: 'var(--text-primary)' }}>{t('settings.oauth.deleteClient')}</h3> <h3 className="text-base font-semibold text-content">{t('settings.oauth.deleteClient')}</h3>
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>{t('settings.oauth.deleteClientMessage')}</p> <p className="text-sm text-content-secondary">{t('settings.oauth.deleteClientMessage')}</p>
<div className="flex gap-2 justify-end"> <div className="flex gap-2 justify-end">
<button onClick={() => setOauthDeleteId(null)} <button onClick={() => setOauthDeleteId(null)}
className="px-4 py-2 rounded-lg text-sm border" style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}> className="px-4 py-2 rounded-lg text-sm border border-edge text-content-secondary">
{t('common.cancel')} {t('common.cancel')}
</button> </button>
<button onClick={() => handleDeleteOAuthClient(oauthDeleteId)} <button onClick={() => handleDeleteOAuthClient(oauthDeleteId)}
@@ -788,12 +778,12 @@ function OAuthClientModals(props: any) {
{oauthRotateId !== null && ( {oauthRotateId !== null && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" style={{ background: 'rgba(0,0,0,0.5)' }} <div className="fixed inset-0 z-50 flex items-center justify-center p-4" style={{ background: 'rgba(0,0,0,0.5)' }}
onClick={e => { if (e.target === e.currentTarget) setOauthRotateId(null) }}> onClick={e => { if (e.target === e.currentTarget) setOauthRotateId(null) }}>
<div className="rounded-xl shadow-xl w-full max-w-sm p-6 space-y-4" style={{ background: 'var(--bg-card)' }}> <div className="rounded-xl shadow-xl w-full max-w-sm p-6 space-y-4 bg-surface-card">
<h3 className="text-base font-semibold" style={{ color: 'var(--text-primary)' }}>{t('settings.oauth.rotateSecret')}</h3> <h3 className="text-base font-semibold text-content">{t('settings.oauth.rotateSecret')}</h3>
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>{t('settings.oauth.rotateSecretMessage')}</p> <p className="text-sm text-content-secondary">{t('settings.oauth.rotateSecretMessage')}</p>
<div className="flex gap-2 justify-end"> <div className="flex gap-2 justify-end">
<button onClick={() => setOauthRotateId(null)} <button onClick={() => setOauthRotateId(null)}
className="px-4 py-2 rounded-lg text-sm border" style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}> className="px-4 py-2 rounded-lg text-sm border border-edge text-content-secondary">
{t('common.cancel')} {t('common.cancel')}
</button> </button>
<button onClick={() => handleRotateSecret(oauthRotateId)} disabled={oauthRotating} <button onClick={() => handleRotateSecret(oauthRotateId)} disabled={oauthRotating}
@@ -808,22 +798,21 @@ function OAuthClientModals(props: any) {
{/* Rotated Secret display */} {/* Rotated Secret display */}
{oauthRotatedSecret !== null && ( {oauthRotatedSecret !== null && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" style={{ background: 'rgba(0,0,0,0.5)' }}> <div className="fixed inset-0 z-50 flex items-center justify-center p-4" style={{ background: 'rgba(0,0,0,0.5)' }}>
<div className="rounded-xl shadow-xl w-full max-w-md p-6 space-y-4" style={{ background: 'var(--bg-card)' }}> <div className="rounded-xl shadow-xl w-full max-w-md p-6 space-y-4 bg-surface-card">
<h3 className="text-lg font-semibold" style={{ color: 'var(--text-primary)' }}>{t('settings.oauth.rotateSecretDoneTitle')}</h3> <h3 className="text-lg font-semibold text-content">{t('settings.oauth.rotateSecretDoneTitle')}</h3>
<div className="flex items-start gap-2 p-3 rounded-lg border border-amber-200" style={{ background: 'rgba(251,191,36,0.1)' }}> <div className="flex items-start gap-2 p-3 rounded-lg border border-amber-200" style={{ background: 'rgba(251,191,36,0.1)' }}>
<span className="text-amber-500 mt-0.5"></span> <span className="text-amber-500 mt-0.5"></span>
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>{t('settings.oauth.rotateSecretDoneWarning')}</p> <p className="text-sm text-content-secondary">{t('settings.oauth.rotateSecretDoneWarning')}</p>
</div> </div>
<div> <div>
<label className="block text-xs font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>{t('settings.oauth.clientSecret')}</label> <label className="block text-xs font-medium mb-1 text-content-secondary">{t('settings.oauth.clientSecret')}</label>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<code className="flex-1 px-3 py-2 rounded-lg text-xs font-mono border break-all" style={{ background: 'var(--bg-secondary)', borderColor: 'var(--border-primary)', color: 'var(--text-primary)' }}> <code className="flex-1 px-3 py-2 rounded-lg text-xs font-mono border break-all bg-surface-secondary border-edge text-content">
{oauthRotatedSecret} {oauthRotatedSecret}
</code> </code>
<button onClick={() => handleCopy(oauthRotatedSecret, 'rotated-secret')} <button onClick={() => handleCopy(oauthRotatedSecret, 'rotated-secret')}
className="p-2 rounded-lg border transition-colors hover:bg-slate-100 dark:hover:bg-slate-700" className="p-2 rounded-lg border transition-colors hover:bg-slate-100 dark:hover:bg-slate-700 border-edge">
style={{ borderColor: 'var(--border-primary)' }}> {copiedKey === 'rotated-secret' ? <Check className="w-4 h-4 text-green-500" /> : <Copy className="w-4 h-4 text-content-secondary" />}
{copiedKey === 'rotated-secret' ? <Check className="w-4 h-4 text-green-500" /> : <Copy className="w-4 h-4" style={{ color: 'var(--text-secondary)' }} />}
</button> </button>
</div> </div>
</div> </div>
@@ -841,12 +830,12 @@ function OAuthClientModals(props: any) {
{oauthRevokeId !== null && ( {oauthRevokeId !== null && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" style={{ background: 'rgba(0,0,0,0.5)' }} <div className="fixed inset-0 z-50 flex items-center justify-center p-4" style={{ background: 'rgba(0,0,0,0.5)' }}
onClick={e => { if (e.target === e.currentTarget) setOauthRevokeId(null) }}> onClick={e => { if (e.target === e.currentTarget) setOauthRevokeId(null) }}>
<div className="rounded-xl shadow-xl w-full max-w-sm p-6 space-y-4" style={{ background: 'var(--bg-card)' }}> <div className="rounded-xl shadow-xl w-full max-w-sm p-6 space-y-4 bg-surface-card">
<h3 className="text-base font-semibold" style={{ color: 'var(--text-primary)' }}>{t('settings.oauth.revokeSession')}</h3> <h3 className="text-base font-semibold text-content">{t('settings.oauth.revokeSession')}</h3>
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>{t('settings.oauth.revokeSessionMessage')}</p> <p className="text-sm text-content-secondary">{t('settings.oauth.revokeSessionMessage')}</p>
<div className="flex gap-2 justify-end"> <div className="flex gap-2 justify-end">
<button onClick={() => setOauthRevokeId(null)} <button onClick={() => setOauthRevokeId(null)}
className="px-4 py-2 rounded-lg text-sm border" style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}> className="px-4 py-2 rounded-lg text-sm border border-edge text-content-secondary">
{t('common.cancel')} {t('common.cancel')}
</button> </button>
<button onClick={() => handleRevokeSession(oauthRevokeId)} <button onClick={() => handleRevokeSession(oauthRevokeId)}
+2 -2
View File
@@ -409,7 +409,7 @@ function DetailPane({ item, tripId, categories, members, onClose }: {
value={String(assignedUserId ?? '')} value={String(assignedUserId ?? '')}
onChange={v => setAssignedUserId(v ? Number(v) : null)} onChange={v => setAssignedUserId(v ? Number(v) : null)}
options={[ options={[
{ value: '', label: t('todo.unassigned'), icon: <User size={14} style={{ color: 'var(--text-faint)' }} /> }, { value: '', label: t('todo.unassigned'), icon: <User size={14} className="text-content-faint" /> },
...members.map(m => ({ ...members.map(m => ({
value: String(m.id), value: String(m.id),
label: m.username, label: m.username,
@@ -600,7 +600,7 @@ function NewTaskPane({ tripId, categories, members, defaultCategory, onCreated,
value={String(assignedUserId ?? '')} value={String(assignedUserId ?? '')}
onChange={v => setAssignedUserId(v ? Number(v) : null)} onChange={v => setAssignedUserId(v ? Number(v) : null)}
options={[ options={[
{ value: '', label: t('todo.unassigned'), icon: <User size={14} style={{ color: 'var(--text-faint)' }} /> }, { value: '', label: t('todo.unassigned'), icon: <User size={14} className="text-content-faint" /> },
...members.map(m => ({ ...members.map(m => ({
value: String(m.id), label: m.username, value: String(m.id), label: m.username,
icon: m.avatar ? ( icon: m.avatar ? (
+11 -8
View File
@@ -111,12 +111,14 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
const createdTrip = result ? result.trip : undefined const createdTrip = result ? result.trip : undefined
// Add selected members for newly created trips // Add selected members for newly created trips
if (selectedMembers.length > 0 && createdTrip?.id) { if (selectedMembers.length > 0 && createdTrip?.id) {
let memberAddFailed = false
for (const userId of selectedMembers) { for (const userId of selectedMembers) {
const user = allUsers.find(u => u.id === userId) const user = allUsers.find(u => u.id === userId)
if (user) { if (user) {
try { await tripsApi.addMember(createdTrip.id, user.username) } catch {} try { await tripsApi.addMember(createdTrip.id, user.username) } catch { memberAddFailed = true }
} }
} }
if (memberAddFailed) toast.error(t('trips.memberAddError'))
} }
// Upload pending cover for newly created trips // Upload pending cover for newly created trips
if (pendingCoverFile && createdTrip?.id) { if (pendingCoverFile && createdTrip?.id) {
@@ -126,7 +128,8 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
const data = await tripsApi.uploadCover(createdTrip.id, fd) const data = await tripsApi.uploadCover(createdTrip.id, fd)
onCoverUpdate?.(createdTrip.id, data.cover_image) onCoverUpdate?.(createdTrip.id, data.cover_image)
} catch { } catch {
// Cover upload failed but trip was created // Cover upload failed but trip was created — surface it without blocking the create
toast.error(t('dashboard.coverUploadError'))
} }
} }
onClose() onClose()
@@ -383,6 +386,7 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 8 }}> <div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 8 }}>
{existingMembers.map(m => ( {existingMembers.map(m => (
<span key={m.id} <span key={m.id}
className="bg-surface-secondary text-content border border-edge"
onClick={async () => { onClick={async () => {
if (m.id === currentUser?.id) return if (m.id === currentUser?.id) return
try { try {
@@ -393,12 +397,11 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
}} }}
style={{ style={{
display: 'flex', alignItems: 'center', gap: 5, padding: '4px 10px', borderRadius: 99, display: 'flex', alignItems: 'center', gap: 5, padding: '4px 10px', borderRadius: 99,
background: 'var(--bg-secondary)', fontSize: 12, fontWeight: 500, color: 'var(--text-primary)', fontSize: 12, fontWeight: 500,
cursor: m.id === currentUser?.id ? 'default' : 'pointer', cursor: m.id === currentUser?.id ? 'default' : 'pointer',
border: '1px solid var(--border-primary)',
}}> }}>
{m.username} {m.username}
{m.id !== currentUser?.id && <X size={11} style={{ color: 'var(--text-faint)' }} />} {m.id !== currentUser?.id && <X size={11} className="text-content-faint" />}
</span> </span>
))} ))}
</div> </div>
@@ -411,13 +414,13 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
if (!user) return null if (!user) return null
return ( return (
<span key={uid} onClick={() => setSelectedMembers(prev => prev.filter(id => id !== uid))} <span key={uid} onClick={() => setSelectedMembers(prev => prev.filter(id => id !== uid))}
className="bg-surface-secondary text-content border border-edge cursor-pointer"
style={{ style={{
display: 'flex', alignItems: 'center', gap: 5, padding: '4px 10px', borderRadius: 99, display: 'flex', alignItems: 'center', gap: 5, padding: '4px 10px', borderRadius: 99,
background: 'var(--bg-secondary)', fontSize: 12, fontWeight: 500, color: 'var(--text-primary)', cursor: 'pointer', fontSize: 12, fontWeight: 500,
border: '1px solid var(--border-primary)',
}}> }}>
{user.username} {user.username}
<X size={11} style={{ color: 'var(--text-faint)' }} /> <X size={11} className="text-content-faint" />
</span> </span>
) )
})} })}
@@ -67,7 +67,7 @@ function ShareLinkSection({ tripId, t }: { tripId: number; t: (key: string, para
const newPerms = { ...perms, [key]: val } const newPerms = { ...perms, [key]: val }
setPerms(newPerms) setPerms(newPerms)
if (shareToken) { if (shareToken) {
try { await shareApi.createLink(tripId, newPerms) } catch {} try { await shareApi.createLink(tripId, newPerms) } catch { toast.error(t('share.createError')) }
} }
} }
@@ -75,7 +75,7 @@ function ShareLinkSection({ tripId, t }: { tripId: number; t: (key: string, para
try { try {
await shareApi.deleteLink(tripId) await shareApi.deleteLink(tripId)
setShareToken(null) setShareToken(null)
} catch {} } catch { toast.error(t('common.error')) }
} }
const handleCopy = () => { const handleCopy = () => {
@@ -92,10 +92,10 @@ function ShareLinkSection({ tripId, t }: { tripId: number; t: (key: string, para
return ( return (
<div> <div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 10 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 10 }}>
<Link2 size={14} style={{ color: 'var(--text-muted)' }} /> <Link2 size={14} className="text-content-muted" />
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>{t('share.linkTitle')}</span> <span className="text-content" style={{ fontSize: 13, fontWeight: 600 }}>{t('share.linkTitle')}</span>
</div> </div>
<p style={{ fontSize: 11, color: 'var(--text-faint)', marginBottom: 10, lineHeight: 1.5 }}>{t('share.linkHint')}</p> <p className="text-content-faint" style={{ fontSize: 11, marginBottom: 10, lineHeight: 1.5 }}>{t('share.linkHint')}</p>
{/* Permission checkboxes */} {/* Permission checkboxes */}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 12 }}> <div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 12 }}>
@@ -124,12 +124,12 @@ function ShareLinkSection({ tripId, t }: { tripId: number; t: (key: string, para
{shareUrl ? ( {shareUrl ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<div style={{ <div className="bg-surface-tertiary border border-edge-faint" style={{
display: 'flex', alignItems: 'center', gap: 6, padding: '8px 10px', display: 'flex', alignItems: 'center', gap: 6, padding: '8px 10px',
background: 'var(--bg-tertiary)', borderRadius: 8, border: '1px solid var(--border-faint)', borderRadius: 8,
}}> }}>
<input type="text" value={shareUrl} readOnly style={{ <input type="text" value={shareUrl} readOnly className="text-content" style={{
flex: 1, border: 'none', background: 'none', fontSize: 11, color: 'var(--text-primary)', flex: 1, border: 'none', background: 'none', fontSize: 11,
outline: 'none', fontFamily: 'monospace', outline: 'none', fontFamily: 'monospace',
}} /> }} />
<button onClick={handleCopy} style={{ <button onClick={handleCopy} style={{
@@ -150,10 +150,10 @@ function ShareLinkSection({ tripId, t }: { tripId: number; t: (key: string, para
</button> </button>
</div> </div>
) : ( ) : (
<button onClick={handleCreate} style={{ <button onClick={handleCreate} className="border border-dashed border-edge text-content-muted" style={{
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
width: '100%', padding: '8px 0', borderRadius: 8, border: '1px dashed var(--border-primary)', width: '100%', padding: '8px 0', borderRadius: 8,
background: 'none', color: 'var(--text-muted)', fontSize: 12, fontWeight: 500, background: 'none', fontSize: 12, fontWeight: 500,
cursor: 'pointer', fontFamily: 'inherit', cursor: 'pointer', fontFamily: 'inherit',
}}> }}>
<Link2 size={12} /> {t('share.createLink')} <Link2 size={12} /> {t('share.createLink')}
@@ -266,14 +266,14 @@ export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle }:
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{/* Trip name */} {/* Trip name */}
<div style={{ padding: '10px 14px', background: 'var(--bg-secondary)', borderRadius: 10, border: '1px solid var(--border-secondary)' }}> <div className="bg-surface-secondary border border-edge-secondary" style={{ padding: '10px 14px', borderRadius: 10 }}>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 2 }}>{t('nav.trip')}</div> <div className="text-content-faint" style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 2 }}>{t('nav.trip')}</div>
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-primary)' }}>{tripTitle}</div> <div className="text-content" style={{ fontSize: 14, fontWeight: 600 }}>{tripTitle}</div>
</div> </div>
{/* Add member dropdown */} {/* Add member dropdown */}
{canManageMembers && <div> {canManageMembers && <div>
<label style={{ display: 'block', fontSize: 12, fontWeight: 600, color: 'var(--text-secondary)', marginBottom: 8 }}> <label className="text-content-secondary" style={{ display: 'block', fontSize: 12, fontWeight: 600, marginBottom: 8 }}>
{t('members.inviteUser')} {t('members.inviteUser')}
</label> </label>
<div style={{ display: 'flex', gap: 8 }}> <div style={{ display: 'flex', gap: 8 }}>
@@ -306,15 +306,15 @@ export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle }:
</button> </button>
</div> </div>
{availableUsers.length === 0 && allUsers.length > 0 && canManageMembers && ( {availableUsers.length === 0 && allUsers.length > 0 && canManageMembers && (
<p style={{ fontSize: 11.5, color: 'var(--text-faint)', margin: '6px 0 0' }}>{t('members.allHaveAccess')}</p> <p className="text-content-faint" style={{ fontSize: 11.5, margin: '6px 0 0' }}>{t('members.allHaveAccess')}</p>
)} )}
</div>} </div>}
{/* Members list */} {/* Members list */}
<div> <div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 10 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 10 }}>
<Users size={13} style={{ color: 'var(--text-faint)' }} /> <Users size={13} className="text-content-faint" />
<span style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-secondary)' }}> <span className="text-content-secondary" style={{ fontSize: 12, fontWeight: 600 }}>
{t('members.access')} ({allMembers.length} {allMembers.length === 1 ? t('members.person') : t('members.persons')}) {t('members.access')} ({allMembers.length} {allMembers.length === 1 ? t('members.person') : t('members.persons')})
</span> </span>
</div> </div>
@@ -322,7 +322,7 @@ export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle }:
{loading ? ( {loading ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{[1, 2].map(i => ( {[1, 2].map(i => (
<div key={i} style={{ height: 48, background: 'var(--bg-tertiary)', borderRadius: 10, animation: 'pulse 1.5s ease-in-out infinite' }} /> <div key={i} className="bg-surface-tertiary" style={{ height: 48, borderRadius: 10, animation: 'pulse 1.5s ease-in-out infinite' }} />
))} ))}
</div> </div>
) : ( ) : (
@@ -331,16 +331,15 @@ export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle }:
const isSelf = member.id === user?.id const isSelf = member.id === user?.id
const canRemove = isSelf || (canManageMembers && member.role !== 'owner') const canRemove = isSelf || (canManageMembers && member.role !== 'owner')
return ( return (
<div key={member.id} style={{ <div key={member.id} className="bg-surface-secondary border border-edge-secondary" style={{
display: 'flex', alignItems: 'center', gap: 10, display: 'flex', alignItems: 'center', gap: 10,
padding: '8px 12px', borderRadius: 10, background: 'var(--bg-secondary)', padding: '8px 12px', borderRadius: 10,
border: '1px solid var(--border-secondary)',
}}> }}>
<Avatar username={member.username} avatarUrl={member.avatar_url} /> <Avatar username={member.username} avatarUrl={member.avatar_url} />
<div style={{ flex: 1, minWidth: 0 }}> <div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}> <div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>{member.username}</span> <span className="text-content" style={{ fontSize: 13, fontWeight: 600 }}>{member.username}</span>
{isSelf && <span style={{ fontSize: 10, color: 'var(--text-faint)' }}>({t('members.you')})</span>} {isSelf && <span className="text-content-faint" style={{ fontSize: 10 }}>({t('members.you')})</span>}
{member.role === 'owner' && ( {member.role === 'owner' && (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 3, fontSize: 10, fontWeight: 700, color: '#d97706', background: '#fef9c3', padding: '1px 6px', borderRadius: 99 }}> <span style={{ display: 'inline-flex', alignItems: 'center', gap: 3, fontSize: 10, fontWeight: 700, color: '#d97706', background: '#fef9c3', padding: '1px 6px', borderRadius: 99 }}>
<Crown size={9} /> {t('members.owner')} <Crown size={9} /> {t('members.owner')}
@@ -370,7 +369,7 @@ export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle }:
</div> </div>
{/* Right column: Share Link */} {/* Right column: Share Link */}
{canManageShare && <div style={{ borderLeft: '1px solid var(--border-faint)', paddingLeft: 24 }}> {canManageShare && <div className="border-l border-edge-faint" style={{ paddingLeft: 24 }}>
<ShareLinkSection tripId={tripId} t={t} /> <ShareLinkSection tripId={tripId} t={t} />
</div>} </div>}
@@ -91,7 +91,7 @@ export default function VacayCalendar() {
{/* Floating toolbar */} {/* Floating toolbar */}
<div className="sticky bottom-3 sm:bottom-4 mt-3 sm:mt-4 flex items-center justify-center z-30 px-2"> <div className="sticky bottom-3 sm:bottom-4 mt-3 sm:mt-4 flex items-center justify-center z-30 px-2">
<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 bg-surface-card border-edge" style={{ 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-[background-color,color,border-color] duration-150 ease-[cubic-bezier(0.23,1,0.32,1)]" 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)]"
@@ -60,12 +60,12 @@ export default function VacayMonthCard({
}, []) }, [])
return ( return (
<div className="rounded-xl border overflow-hidden" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}> <div className="rounded-xl border overflow-hidden bg-surface-card border-edge">
<div className="px-3 py-2 border-b" style={{ borderColor: 'var(--border-secondary)' }}> <div className="px-3 py-2 border-b border-edge-secondary">
<span className="text-xs font-semibold" style={{ color: 'var(--text-primary)', textTransform: 'capitalize' }}>{monthName}</span> <span className="text-xs font-semibold capitalize text-content">{monthName}</span>
</div> </div>
<div className="grid grid-cols-7 border-b" style={{ borderColor: 'var(--border-secondary)' }}> <div className="grid grid-cols-7 border-b border-edge-secondary">
{weekdays.map((wd, i) => { {weekdays.map((wd, i) => {
// Map column index back to JS day (0=Sun..6=Sat) to check if it's a weekend column // Map column index back to JS day (0=Sun..6=Sat) to check if it's a weekend column
const jsDay = (i + weekStart) % 7 const jsDay = (i + weekStart) % 7
+22 -24
View File
@@ -64,11 +64,11 @@ export default function VacayPersons() {
const editingUserColor = users.find(u => u.id === colorEditUserId)?.color || '#6366f1' const editingUserColor = users.find(u => u.id === colorEditUserId)?.color || '#6366f1'
return ( return (
<div className="rounded-xl border p-3" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}> <div className="rounded-xl border p-3 bg-surface-card border-edge">
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<span className="text-[11px] font-medium uppercase tracking-wider" style={{ color: 'var(--text-faint)' }}>{t('vacay.persons')}</span> <span className="text-[11px] font-medium uppercase tracking-wider text-content-faint">{t('vacay.persons')}</span>
<button onClick={() => { setShowInvite(true); loadAvailable() }} <button onClick={() => { setShowInvite(true); loadAvailable() }}
className="p-0.5 rounded transition-colors" style={{ color: 'var(--text-faint)' }}> className="p-0.5 rounded transition-colors text-content-faint">
<UserPlus size={14} /> <UserPlus size={14} />
</button> </button>
</div> </div>
@@ -91,12 +91,12 @@ export default function VacayPersons() {
style={{ backgroundColor: u.color, cursor: 'pointer' }} style={{ backgroundColor: u.color, cursor: 'pointer' }}
title={t('vacay.changeColor')} title={t('vacay.changeColor')}
/> />
<span className="text-xs font-medium flex-1 truncate" style={{ color: 'var(--text-primary)' }}> <span className="text-xs font-medium flex-1 truncate text-content">
{u.username} {u.username}
{u.id === currentUser?.id && <span style={{ color: 'var(--text-faint)' }}> ({t('vacay.you')})</span>} {u.id === currentUser?.id && <span className="text-content-faint"> ({t('vacay.you')})</span>}
</span> </span>
{isSelected && isFused && ( {isSelected && isFused && (
<Check size={12} style={{ color: 'var(--text-primary)' }} /> <Check size={12} className="text-content" />
)} )}
</div> </div>
) )
@@ -104,15 +104,14 @@ export default function VacayPersons() {
{/* Pending invites */} {/* Pending invites */}
{pendingInvites.map(inv => ( {pendingInvites.map(inv => (
<div key={inv.id} className="flex items-center gap-2 px-2.5 py-1.5 rounded-lg group" <div key={inv.id} className="flex items-center gap-2 px-2.5 py-1.5 rounded-lg group bg-surface-secondary"
style={{ background: 'var(--bg-secondary)', opacity: 0.7 }}> style={{ opacity: 0.7 }}>
<Clock size={12} style={{ color: 'var(--text-faint)' }} /> <Clock size={12} className="text-content-faint" />
<span className="text-xs flex-1 truncate" style={{ color: 'var(--text-muted)' }}> <span className="text-xs flex-1 truncate text-content-muted">
{inv.username} <span className="text-[10px]">({t('vacay.pending')})</span> {inv.username} <span className="text-[10px]">({t('vacay.pending')})</span>
</span> </span>
<button onClick={() => cancelInvite(inv.user_id)} <button onClick={() => cancelInvite(inv.user_id)}
className="opacity-0 group-hover:opacity-100 text-[10px] px-1.5 py-0.5 rounded transition-all" className="opacity-0 group-hover:opacity-100 text-[10px] px-1.5 py-0.5 rounded transition-all text-content-faint">
style={{ color: 'var(--text-faint)' }}>
{t('common.cancel')} {t('common.cancel')}
</button> </button>
</div> </div>
@@ -123,18 +122,18 @@ export default function VacayPersons() {
{showInvite && ReactDOM.createPortal( {showInvite && ReactDOM.createPortal(
<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 }} <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="trek-modal-enter rounded-2xl shadow-2xl w-full max-w-sm" style={{ background: 'var(--bg-card)' }} <div className="trek-modal-enter rounded-2xl shadow-2xl w-full max-w-sm bg-surface-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 border-b border-edge-secondary">
<h2 className="text-base font-semibold" style={{ color: 'var(--text-primary)' }}>{t('vacay.inviteUser')}</h2> <h2 className="text-base font-semibold text-content">{t('vacay.inviteUser')}</h2>
<button onClick={() => setShowInvite(false)} className="p-1.5 rounded-lg transition-colors" style={{ color: 'var(--text-faint)' }}> <button onClick={() => setShowInvite(false)} className="p-1.5 rounded-lg transition-colors text-content-faint">
<X size={16} /> <X size={16} />
</button> </button>
</div> </div>
<div className="p-5 space-y-4"> <div className="p-5 space-y-4">
<p className="text-xs" style={{ color: 'var(--text-muted)' }}>{t('vacay.inviteHint')}</p> <p className="text-xs text-content-muted">{t('vacay.inviteHint')}</p>
{availableUsers.length === 0 ? ( {availableUsers.length === 0 ? (
<p className="text-xs text-center py-4" style={{ color: 'var(--text-faint)' }}>{t('vacay.noUsersAvailable')}</p> <p className="text-xs text-center py-4 text-content-faint">{t('vacay.noUsersAvailable')}</p>
) : ( ) : (
<CustomSelect <CustomSelect
value={selectedInviteUser} value={selectedInviteUser}
@@ -145,8 +144,7 @@ export default function VacayPersons() {
/> />
)} )}
<div className="flex gap-3 justify-end pt-2"> <div className="flex gap-3 justify-end pt-2">
<button onClick={() => setShowInvite(false)} className="px-4 py-2 text-sm rounded-lg" <button onClick={() => setShowInvite(false)} className="px-4 py-2 text-sm rounded-lg text-content-muted border border-edge">
style={{ color: 'var(--text-muted)', border: '1px solid var(--border-primary)' }}>
{t('common.cancel')} {t('common.cancel')}
</button> </button>
<button onClick={handleInvite} disabled={!selectedInviteUser || inviting} <button onClick={handleInvite} disabled={!selectedInviteUser || inviting}
@@ -166,11 +164,11 @@ export default function VacayPersons() {
{showColorPicker && ReactDOM.createPortal( {showColorPicker && ReactDOM.createPortal(
<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 }} <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="trek-modal-enter rounded-2xl shadow-2xl w-full max-w-xs" style={{ background: 'var(--bg-card)' }} <div className="trek-modal-enter rounded-2xl shadow-2xl w-full max-w-xs bg-surface-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 border-b border-edge-secondary">
<h2 className="text-base font-semibold" style={{ color: 'var(--text-primary)' }}>{t('vacay.changeColor')}</h2> <h2 className="text-base font-semibold text-content">{t('vacay.changeColor')}</h2>
<button onClick={() => { setShowColorPicker(false); setColorEditUserId(null) }} className="p-1.5 rounded-lg transition-colors" style={{ color: 'var(--text-faint)' }}> <button onClick={() => { setShowColorPicker(false); setColorEditUserId(null) }} className="p-1.5 rounded-lg transition-colors text-content-faint">
<X size={16} /> <X size={16} />
</button> </button>
</div> </div>
+21 -22
View File
@@ -52,7 +52,7 @@ export default function VacaySettings({ onClose }: VacaySettingsProps) {
{/* Weekend days selector */} {/* Weekend days selector */}
{plan.block_weekends !== false && ( {plan.block_weekends !== false && (
<div data-testid="weekend-days" style={{ paddingLeft: 36 }}> <div data-testid="weekend-days" style={{ paddingLeft: 36 }}>
<p className="text-xs font-medium mb-2" style={{ color: 'var(--text-muted)' }}>{t('vacay.weekendDays')}</p> <p className="text-xs font-medium mb-2 text-content-muted">{t('vacay.weekendDays')}</p>
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
{[ {[
{ day: 1, label: t('vacay.mon') }, { day: 1, label: t('vacay.mon') },
@@ -88,10 +88,10 @@ export default function VacaySettings({ onClose }: VacaySettingsProps) {
{/* Week start */} {/* Week start */}
<div> <div>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<CalendarDays size={16} style={{ color: 'var(--text-muted)', flexShrink: 0 }} /> <CalendarDays size={16} className="text-content-muted" style={{ flexShrink: 0 }} />
<div style={{ flex: 1 }}> <div style={{ flex: 1 }}>
<span className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>{t('vacay.weekStart')}</span> <span className="text-sm font-medium text-content">{t('vacay.weekStart')}</span>
<p className="text-xs mt-0.5" style={{ color: 'var(--text-faint)' }}>{t('vacay.weekStartHint')}</p> <p className="text-xs mt-0.5 text-content-faint">{t('vacay.weekStartHint')}</p>
</div> </div>
</div> </div>
<div style={{ paddingLeft: 36, marginTop: 8 }} className="flex gap-1.5"> <div style={{ paddingLeft: 36, marginTop: 8 }} className="flex gap-1.5">
@@ -136,9 +136,9 @@ export default function VacaySettings({ onClose }: VacaySettingsProps) {
/> />
{plan.company_holidays_enabled && ( {plan.company_holidays_enabled && (
<div className="ml-7 mt-2"> <div className="ml-7 mt-2">
<div className="flex items-center gap-1.5 px-2 py-1.5 rounded-md" style={{ background: 'var(--bg-secondary)' }}> <div className="flex items-center gap-1.5 px-2 py-1.5 rounded-md bg-surface-secondary">
<AlertCircle size={12} style={{ color: 'var(--text-faint)' }} /> <AlertCircle size={12} className="text-content-faint" />
<span className="text-[10px]" style={{ color: 'var(--text-faint)' }}>{t('vacay.companyHolidaysNoDeduct')}</span> <span className="text-[10px] text-content-faint">{t('vacay.companyHolidaysNoDeduct')}</span>
</div> </div>
</div> </div>
)} )}
@@ -156,7 +156,7 @@ export default function VacaySettings({ onClose }: VacaySettingsProps) {
{plan.holidays_enabled && ( {plan.holidays_enabled && (
<div className="ml-7 mt-2 space-y-2"> <div className="ml-7 mt-2 space-y-2">
{(plan.holiday_calendars ?? []).length === 0 && ( {(plan.holiday_calendars ?? []).length === 0 && (
<p className="text-xs" style={{ color: 'var(--text-faint)' }}>{t('vacay.noCalendars')}</p> <p className="text-xs text-content-faint">{t('vacay.noCalendars')}</p>
)} )}
{(plan.holiday_calendars ?? []).map(cal => ( {(plan.holiday_calendars ?? []).map(cal => (
<CalendarRow <CalendarRow
@@ -178,8 +178,7 @@ export default function VacaySettings({ onClose }: VacaySettingsProps) {
) : ( ) : (
<button <button
onClick={() => setShowAddForm(true)} onClick={() => setShowAddForm(true)}
className="flex items-center gap-1.5 text-xs px-2 py-1.5 rounded-md transition-colors" className="flex items-center gap-1.5 text-xs px-2 py-1.5 rounded-md transition-colors text-content-muted bg-surface-secondary"
style={{ color: 'var(--text-muted)', background: 'var(--bg-secondary)' }}
> >
<Plus size={12} /> <Plus size={12} />
{t('vacay.addCalendar')} {t('vacay.addCalendar')}
@@ -191,22 +190,22 @@ export default function VacaySettings({ onClose }: VacaySettingsProps) {
{/* Dissolve fusion */} {/* Dissolve fusion */}
{isFused && ( {isFused && (
<div className="pt-4 mt-2 border-t" style={{ borderColor: 'var(--border-secondary)' }}> <div className="pt-4 mt-2 border-t border-edge-secondary">
<div className="rounded-xl overflow-hidden" style={{ border: '1px solid rgba(239,68,68,0.2)' }}> <div className="rounded-xl overflow-hidden" style={{ border: '1px solid rgba(239,68,68,0.2)' }}>
<div className="px-4 py-3 flex items-center gap-3" style={{ background: 'rgba(239,68,68,0.06)' }}> <div className="px-4 py-3 flex items-center gap-3" style={{ background: 'rgba(239,68,68,0.06)' }}>
<div className="w-8 h-8 rounded-lg flex items-center justify-center" style={{ background: 'rgba(239,68,68,0.1)' }}> <div className="w-8 h-8 rounded-lg flex items-center justify-center" style={{ background: 'rgba(239,68,68,0.1)' }}>
<Unlink size={16} className="text-red-500" /> <Unlink size={16} className="text-red-500" />
</div> </div>
<div> <div>
<p className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>{t('vacay.dissolve')}</p> <p className="text-sm font-semibold text-content">{t('vacay.dissolve')}</p>
<p className="text-[11px]" style={{ color: 'var(--text-faint)' }}>{t('vacay.dissolveHint')}</p> <p className="text-[11px] text-content-faint">{t('vacay.dissolveHint')}</p>
</div> </div>
</div> </div>
<div className="px-4 py-3 flex items-center gap-2 flex-wrap" style={{ borderTop: '1px solid rgba(239,68,68,0.1)' }}> <div className="px-4 py-3 flex items-center gap-2 flex-wrap" style={{ borderTop: '1px solid rgba(239,68,68,0.1)' }}>
{users.map(u => ( {users.map(u => (
<div key={u.id} className="flex items-center gap-1.5 px-2 py-1 rounded-md" style={{ background: 'var(--bg-secondary)' }}> <div key={u.id} className="flex items-center gap-1.5 px-2 py-1 rounded-md bg-surface-secondary">
<span className="w-2 h-2 rounded-full" style={{ backgroundColor: u.color || '#6366f1' }} /> <span className="w-2 h-2 rounded-full" style={{ backgroundColor: u.color || '#6366f1' }} />
<span className="text-xs font-medium" style={{ color: 'var(--text-primary)' }}>{u.username}</span> <span className="text-xs font-medium text-content">{u.username}</span>
</div> </div>
))} ))}
</div> </div>
@@ -241,17 +240,17 @@ function SettingToggle({ icon: Icon, label, hint, value, onChange }: SettingTogg
return ( return (
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2 min-w-0"> <div className="flex items-center gap-2 min-w-0">
<Icon size={15} className="shrink-0" style={{ color: 'var(--text-muted)' }} /> <Icon size={15} className="shrink-0 text-content-muted" />
<div className="min-w-0"> <div className="min-w-0">
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>{label}</p> <p className="text-sm font-medium text-content">{label}</p>
<p className="text-[11px]" style={{ color: 'var(--text-faint)' }}>{hint}</p> <p className="text-[11px] text-content-faint">{hint}</p>
</div> </div>
</div> </div>
<button onClick={onChange} <button onClick={onChange}
className="relative shrink-0 inline-flex h-6 w-11 items-center rounded-full transition-colors" className="relative shrink-0 inline-flex h-6 w-11 items-center rounded-full transition-colors"
style={{ background: value ? 'var(--text-primary)' : 'var(--border-primary)' }}> style={{ background: value ? 'var(--text-primary)' : 'var(--border-primary)' }}>
<span className="absolute left-1 h-4 w-4 rounded-full transition-transform duration-200" <span className="absolute left-1 h-4 w-4 rounded-full transition-transform duration-200 bg-surface-card"
style={{ background: 'var(--bg-card)', transform: value ? 'translateX(20px)' : 'translateX(0)' }} /> style={{ transform: value ? 'translateX(20px)' : 'translateX(0)' }} />
</button> </button>
</div> </div>
) )
@@ -309,7 +308,7 @@ function CalendarRow({ cal, countries, onUpdate, onDelete }: {
const [showColorPicker, setShowColorPicker] = useState(false) const [showColorPicker, setShowColorPicker] = useState(false)
return ( return (
<div className="flex gap-3 items-start p-3 rounded-xl" style={{ background: 'var(--bg-secondary)' }}> <div className="flex gap-3 items-start p-3 rounded-xl bg-surface-secondary">
<div style={{ position: 'relative', flexShrink: 0 }}> <div style={{ position: 'relative', flexShrink: 0 }}>
<button <button
onClick={() => setShowColorPicker(!showColorPicker)} onClick={() => setShowColorPicker(!showColorPicker)}
@@ -394,7 +393,7 @@ function AddCalendarForm({ countries, onAdd, onCancel }: {
const [showColorPicker, setShowColorPicker] = useState(false) const [showColorPicker, setShowColorPicker] = useState(false)
return ( return (
<div className="flex gap-3 items-start p-3 rounded-xl border border-dashed" style={{ borderColor: 'var(--border-primary)' }}> <div className="flex gap-3 items-start p-3 rounded-xl border border-dashed border-edge">
<div style={{ position: 'relative', flexShrink: 0 }}> <div style={{ position: 'relative', flexShrink: 0 }}>
<button <button
onClick={() => setShowColorPicker(!showColorPicker)} onClick={() => setShowColorPicker(!showColorPicker)}
+19 -19
View File
@@ -14,16 +14,16 @@ export default function VacayStats() {
useEffect(() => { loadStats(selectedYear) }, [selectedYear]) useEffect(() => { loadStats(selectedYear) }, [selectedYear])
return ( return (
<div className="rounded-xl border p-3" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}> <div className="rounded-xl border p-3 bg-surface-card border-edge">
<div className="flex items-center gap-1.5 mb-3"> <div className="flex items-center gap-1.5 mb-3">
<Briefcase size={13} style={{ color: 'var(--text-faint)' }} /> <Briefcase size={13} className="text-content-faint" />
<span className="text-[11px] font-medium uppercase tracking-wider" style={{ color: 'var(--text-faint)' }}> <span className="text-[11px] font-medium uppercase tracking-wider text-content-faint">
{t('vacay.entitlement')} {selectedYear} {t('vacay.entitlement')} {selectedYear}
</span> </span>
</div> </div>
{stats.length === 0 ? ( {stats.length === 0 ? (
<p className="text-[11px] text-center py-3" style={{ color: 'var(--text-faint)' }}>{t('vacay.noData')}</p> <p className="text-[11px] text-center py-3 text-content-faint">{t('vacay.noData')}</p>
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
{stats.map(s => ( {stats.map(s => (
@@ -73,16 +73,16 @@ function StatCard({ stat: s, isMe, canEdit, selectedYear, onSave, t }: StatCardP
} }
return ( return (
<div className="rounded-lg p-2.5 space-y-2" style={{ border: '1px solid var(--border-secondary)' }}> <div className="rounded-lg p-2.5 space-y-2 border border-edge-secondary">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="w-2.5 h-2.5 rounded-full shrink-0" style={{ backgroundColor: s.person_color }} /> <span className="w-2.5 h-2.5 rounded-full shrink-0" style={{ backgroundColor: s.person_color }} />
<span className="text-xs font-semibold flex-1 truncate" style={{ color: 'var(--text-primary)' }}> <span className="text-xs font-semibold flex-1 truncate text-content">
{s.person_name} {s.person_name}
{isMe && <span style={{ color: 'var(--text-faint)' }}> ({t('vacay.you')})</span>} {isMe && <span className="text-content-faint"> ({t('vacay.you')})</span>}
</span> </span>
<span className="text-[10px] tabular-nums" style={{ color: 'var(--text-faint)' }}>{s.used}/{s.total_available}</span> <span className="text-[10px] tabular-nums text-content-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 bg-surface-secondary">
<div <div
className="trek-bar-fill h-full rounded-full transition-[width] duration-500 ease-[cubic-bezier(0.23,1,0.32,1)]" 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 }} style={{ width: `${pct}%`, backgroundColor: s.person_color }}
@@ -99,8 +99,8 @@ function StatCard({ stat: s, isMe, canEdit, selectedYear, onSave, t }: StatCardP
}} }}
onClick={() => { if (canEdit && !editing) setEditing(true) }} onClick={() => { if (canEdit && !editing) setEditing(true) }}
> >
<div className="text-[10px] mb-1" style={{ color: 'var(--text-faint)', height: 14, lineHeight: '14px' }}> <div className="text-[10px] mb-1 text-content-faint" style={{ height: 14, lineHeight: '14px' }}>
{t('vacay.entitlementDays')} {canEdit && !editing && <Pencil size={9} className="inline opacity-0 group-hover/days:opacity-100 transition-opacity" style={{ color: 'var(--text-faint)', verticalAlign: 'middle' }} />} {t('vacay.entitlementDays')} {canEdit && !editing && <Pencil size={9} className="inline opacity-0 group-hover/days:opacity-100 transition-opacity text-content-faint" style={{ verticalAlign: 'middle' }} />}
</div> </div>
{editing ? ( {editing ? (
<input <input
@@ -110,21 +110,21 @@ function StatCard({ stat: s, isMe, canEdit, selectedYear, onSave, t }: StatCardP
onBlur={handleSave} onBlur={handleSave}
onKeyDown={e => { if (e.key === 'Enter') handleSave(); if (e.key === 'Escape') { setEditing(false); setLocalDays(s.vacation_days) } }} onKeyDown={e => { if (e.key === 'Enter') handleSave(); if (e.key === 'Escape') { setEditing(false); setLocalDays(s.vacation_days) } }}
autoFocus autoFocus
className="w-full bg-transparent text-sm font-bold outline-none p-0 m-0 [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none" className="w-full bg-transparent text-sm font-bold outline-none p-0 m-0 [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none text-content"
style={{ color: 'var(--text-primary)', height: 18, lineHeight: '18px' }} style={{ height: 18, lineHeight: '18px' }}
/> />
) : ( ) : (
<div className="text-sm font-bold" style={{ color: 'var(--text-primary)', height: 18, lineHeight: '18px' }}>{s.vacation_days}</div> <div className="text-sm font-bold text-content" style={{ height: 18, lineHeight: '18px' }}>{s.vacation_days}</div>
)} )}
</div> </div>
{/* Used */} {/* Used */}
<div className="rounded-md px-2 py-2" style={{ background: 'var(--bg-secondary)' }}> <div className="rounded-md px-2 py-2 bg-surface-secondary">
<div className="text-[10px] mb-1" style={{ color: 'var(--text-faint)', height: 14, lineHeight: '14px' }}>{t('vacay.used')}</div> <div className="text-[10px] mb-1 text-content-faint" style={{ height: 14, lineHeight: '14px' }}>{t('vacay.used')}</div>
<div className="text-sm font-bold" style={{ color: 'var(--text-primary)', height: 18, lineHeight: '18px' }}>{s.used}</div> <div className="text-sm font-bold text-content" style={{ height: 18, lineHeight: '18px' }}>{s.used}</div>
</div> </div>
{/* Remaining */} {/* Remaining */}
<div className="rounded-md px-2 py-2" style={{ background: 'var(--bg-secondary)' }}> <div className="rounded-md px-2 py-2 bg-surface-secondary">
<div className="text-[10px] mb-1" style={{ color: 'var(--text-faint)', height: 14, lineHeight: '14px' }}>{t('vacay.remaining')}</div> <div className="text-[10px] mb-1 text-content-faint" style={{ height: 14, lineHeight: '14px' }}>{t('vacay.remaining')}</div>
<div className="text-sm font-bold" style={{ color: s.remaining < 0 ? '#ef4444' : s.remaining <= 3 ? '#f59e0b' : '#22c55e', height: 18, lineHeight: '18px' }}> <div className="text-sm font-bold" style={{ color: s.remaining < 0 ? '#ef4444' : s.remaining <= 3 ? '#f59e0b' : '#22c55e', height: 18, lineHeight: '18px' }}>
{s.remaining} {s.remaining}
</div> </div>
@@ -46,8 +46,7 @@ export default function ConfirmDialog({
onClick={onClose} onClick={onClose}
> >
<div <div
className="trek-modal-enter 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 bg-surface-card"
style={{ 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">
@@ -57,10 +56,10 @@ export default function ConfirmDialog({
</div> </div>
)} )}
<div className="flex-1"> <div className="flex-1">
<h3 className="text-base font-semibold" style={{ color: 'var(--text-primary)' }}> <h3 className="text-base font-semibold text-content">
{title || t('common.confirm')} {title || t('common.confirm')}
</h3> </h3>
<p className="mt-1 text-sm" style={{ color: 'var(--text-secondary)' }}> <p className="mt-1 text-sm text-content-secondary">
{message} {message}
</p> </p>
</div> </div>
@@ -69,11 +68,7 @@ export default function ConfirmDialog({
<div className="flex justify-end gap-3 mt-6"> <div className="flex justify-end gap-3 mt-6">
<button <button
onClick={onClose} onClick={onClose}
className="px-4 py-2 text-sm font-medium rounded-lg transition-colors" className="px-4 py-2 text-sm font-medium rounded-lg transition-colors text-content-secondary border border-edge-secondary"
style={{
color: 'var(--text-secondary)',
border: '1px solid var(--border-secondary)',
}}
> >
{cancelLabel || t('common.cancel')} {cancelLabel || t('common.cancel')}
</button> </button>
+10 -12
View File
@@ -47,25 +47,24 @@ export default function CopyTripDialog({ isOpen, tripTitle, onClose, onConfirm }
onClick={onClose} onClick={onClose}
> >
<div <div
className="trek-modal-enter rounded-2xl shadow-2xl w-full max-w-md p-6" className="trek-modal-enter rounded-2xl shadow-2xl w-full max-w-md p-6 bg-surface-card"
style={{ background: 'var(--bg-card)' }}
onClick={e => e.stopPropagation()} onClick={e => e.stopPropagation()}
> >
<h3 className="text-base font-semibold mb-1" style={{ color: 'var(--text-primary)' }}> <h3 className="text-base font-semibold mb-1 text-content">
{t('dashboard.confirm.copy.title')} {t('dashboard.confirm.copy.title')}
</h3> </h3>
<p className="text-sm mb-4" style={{ color: 'var(--text-secondary)' }}> <p className="text-sm mb-4 text-content-secondary">
{tripTitle} {tripTitle}
</p> </p>
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<div className="rounded-xl p-3" style={{ background: 'var(--bg-subtle)', border: '1px solid var(--border-secondary)' }}> <div className="rounded-xl p-3 border border-edge-secondary" style={{ background: 'var(--bg-subtle)' }}>
<p className="text-xs font-semibold uppercase tracking-wide mb-2" style={{ color: '#16a34a' }}> <p className="text-xs font-semibold uppercase tracking-wide mb-2" style={{ color: '#16a34a' }}>
{t('dashboard.confirm.copy.willCopy')} {t('dashboard.confirm.copy.willCopy')}
</p> </p>
<ul className="flex flex-col gap-1"> <ul className="flex flex-col gap-1">
{WILL_COPY_KEYS.map(key => ( {WILL_COPY_KEYS.map(key => (
<li key={key} className="flex items-center gap-2 text-sm" style={{ color: 'var(--text-secondary)' }}> <li key={key} className="flex items-center gap-2 text-sm text-content-secondary">
<Check size={13} className="flex-shrink-0" style={{ color: '#16a34a' }} /> <Check size={13} className="flex-shrink-0" style={{ color: '#16a34a' }} />
{t(key)} {t(key)}
</li> </li>
@@ -73,14 +72,14 @@ export default function CopyTripDialog({ isOpen, tripTitle, onClose, onConfirm }
</ul> </ul>
</div> </div>
<div className="rounded-xl p-3" style={{ background: 'var(--bg-subtle)', border: '1px solid var(--border-secondary)' }}> <div className="rounded-xl p-3 border border-edge-secondary" style={{ background: 'var(--bg-subtle)' }}>
<p className="text-xs font-semibold uppercase tracking-wide mb-2" style={{ color: 'var(--text-muted)' }}> <p className="text-xs font-semibold uppercase tracking-wide mb-2 text-content-muted">
{t('dashboard.confirm.copy.wontCopy')} {t('dashboard.confirm.copy.wontCopy')}
</p> </p>
<ul className="flex flex-col gap-1"> <ul className="flex flex-col gap-1">
{WONT_COPY_KEYS.map(key => ( {WONT_COPY_KEYS.map(key => (
<li key={key} className="flex items-center gap-2 text-sm" style={{ color: 'var(--text-secondary)' }}> <li key={key} className="flex items-center gap-2 text-sm text-content-secondary">
<X size={13} className="flex-shrink-0" style={{ color: 'var(--text-muted)' }} /> <X size={13} className="flex-shrink-0 text-content-muted" />
{t(key)} {t(key)}
</li> </li>
))} ))}
@@ -91,8 +90,7 @@ export default function CopyTripDialog({ isOpen, tripTitle, onClose, onConfirm }
<div className="flex justify-end gap-3 mt-5"> <div className="flex justify-end gap-3 mt-5">
<button <button
onClick={onClose} onClick={onClose}
className="px-4 py-2 text-sm font-medium rounded-lg transition-colors" className="px-4 py-2 text-sm font-medium rounded-lg transition-colors text-content-secondary border border-edge-secondary"
style={{ color: 'var(--text-secondary)', border: '1px solid var(--border-secondary)' }}
> >
{t('common.cancel')} {t('common.cancel')}
</button> </button>
+4 -4
View File
@@ -65,13 +65,13 @@ export default function Modal({
rounded-2xl overflow-hidden shadow-2xl w-full ${sizeClasses[size] || sizeClasses.md} rounded-2xl overflow-hidden shadow-2xl w-full ${sizeClasses[size] || sizeClasses.md}
flex flex-col flex flex-col
max-h-[calc(100dvh-var(--bottom-nav-h)-90px)] sm:max-h-[calc(100dvh-90px)] max-h-[calc(100dvh-var(--bottom-nav-h)-90px)] sm:max-h-[calc(100dvh-90px)]
bg-surface-card
`} `}
style={{ background: 'var(--bg-card)' }}
onClick={e => e.stopPropagation()} onClick={e => e.stopPropagation()}
> >
{/* Header — stays put even while the body scrolls */} {/* Header — stays put even while the body scrolls */}
<div className="flex items-center justify-between p-6 flex-shrink-0" style={{ borderBottom: '1px solid var(--border-secondary)' }}> <div className="flex items-center justify-between p-6 flex-shrink-0 border-b border-edge-secondary">
<h2 className="text-lg font-semibold" style={{ color: 'var(--text-primary)' }}>{title}</h2> <h2 className="text-lg font-semibold text-content">{title}</h2>
{!hideCloseButton && ( {!hideCloseButton && (
<button <button
onClick={onClose} onClick={onClose}
@@ -89,7 +89,7 @@ export default function Modal({
{/* Footer — sticky at the bottom of the modal, never compressed */} {/* Footer — sticky at the bottom of the modal, never compressed */}
{footer && ( {footer && (
<div className="p-6 flex-shrink-0" style={{ borderTop: '1px solid var(--border-secondary)' }}> <div className="p-6 flex-shrink-0 border-t border-edge-secondary">
{footer} {footer}
</div> </div>
)} )}
+3 -4
View File
@@ -28,8 +28,8 @@ export function Skeleton({
export function SpotlightSkeleton(): React.ReactElement { export function SpotlightSkeleton(): React.ReactElement {
return ( return (
<div <div
className="relative rounded-3xl overflow-hidden mb-8" className="relative rounded-3xl overflow-hidden mb-8 bg-surface-tertiary"
style={{ minHeight: 340, background: 'var(--bg-tertiary)' }} style={{ minHeight: 340 }}
> >
<div className="trek-skeleton absolute inset-0" style={{ borderRadius: 24 }} /> <div className="trek-skeleton absolute inset-0" style={{ borderRadius: 24 }} />
<div className="relative p-6 flex flex-col justify-end" style={{ minHeight: 340 }}> <div className="relative p-6 flex flex-col justify-end" style={{ minHeight: 340 }}>
@@ -44,8 +44,7 @@ export function SpotlightSkeleton(): React.ReactElement {
export function TripCardSkeleton(): React.ReactElement { export function TripCardSkeleton(): React.ReactElement {
return ( return (
<div <div
className="rounded-2xl border border-zinc-200 dark:border-zinc-700 overflow-hidden" className="rounded-2xl border border-zinc-200 dark:border-zinc-700 overflow-hidden bg-surface-card"
style={{ background: 'var(--bg-card)' }}
> >
<Skeleton height={140} radius={0} /> <Skeleton height={140} radius={0} />
<div className="p-4 flex flex-col gap-2"> <div className="p-4 flex flex-col gap-2">
+1 -4
View File
@@ -68,7 +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" className="trek-popover-enter bg-surface-card text-content border border-edge-faint"
style={{ style={{
position: 'fixed', position: 'fixed',
top: coords?.top ?? -9999, top: coords?.top ?? -9999,
@@ -76,15 +76,12 @@ export function Tooltip({ label, placement = 'bottom', delay = 250, disabled, ch
visibility: coords ? 'visible' : 'hidden', visibility: coords ? 'visible' : 'hidden',
pointerEvents: 'none', pointerEvents: 'none',
zIndex: 100000, zIndex: 100000,
background: 'var(--bg-card, #ffffff)',
color: 'var(--text-primary, #111827)',
fontSize: 11, fontSize: 11,
fontWeight: 500, fontWeight: 500,
padding: '5px 10px', padding: '5px 10px',
borderRadius: 8, borderRadius: 8,
whiteSpace: 'nowrap', whiteSpace: 'nowrap',
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)',
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', transformOrigin: placement === 'top' ? 'bottom center' : placement === 'bottom' ? 'top center' : placement === 'left' ? 'center right' : 'center left',
}} }}
+18 -49
View File
@@ -20,10 +20,11 @@ import PackingTemplateManager from '../components/Admin/PackingTemplateManager'
import AuditLogPanel from '../components/Admin/AuditLogPanel' import AuditLogPanel from '../components/Admin/AuditLogPanel'
import AdminMcpTokensPanel from '../components/Admin/AdminMcpTokensPanel' import AdminMcpTokensPanel from '../components/Admin/AdminMcpTokensPanel'
import PermissionsPanel from '../components/Admin/PermissionsPanel' import PermissionsPanel from '../components/Admin/PermissionsPanel'
import { Users, Map, Briefcase, Shield, Trash2, Edit2, FileText, Eye, EyeOff, Save, CheckCircle, XCircle, Loader2, UserPlus, ArrowUpCircle, ExternalLink, Download, Sun, Link2, Copy, Plus, RefreshCw, AlertTriangle, SlidersHorizontal, UserCog, Puzzle, Settings as SettingsIcon, Bell, Database, ScrollText, KeyRound, GitBranch, Bug } from 'lucide-react' import { Users, Map, Briefcase, Shield, Trash2, Edit2, FileText, Eye, EyeOff, Save, CheckCircle, XCircle, Loader2, UserPlus, ArrowUpCircle, ExternalLink, Sun, Link2, Copy, Plus, RefreshCw, AlertTriangle, SlidersHorizontal, UserCog, Puzzle, Settings as SettingsIcon, Bell, Database, ScrollText, KeyRound, GitBranch, Bug } from 'lucide-react'
import CustomSelect from '../components/shared/CustomSelect' import CustomSelect from '../components/shared/CustomSelect'
import PageSidebar, { type PageSidebarTab } from '../components/Layout/PageSidebar' import PageSidebar, { type PageSidebarTab } from '../components/Layout/PageSidebar'
import { useAdmin } from './admin/useAdmin' import { useAdmin } from './admin/useAdmin'
import AdminUpdateBanner from './admin/AdminUpdateBanner'
const ADMIN_EVENT_LABEL_KEYS: Record<string, string> = { const ADMIN_EVENT_LABEL_KEYS: Record<string, string> = {
version_available: 'settings.notifyVersionAvailable', version_available: 'settings.notifyVersionAvailable',
@@ -44,7 +45,7 @@ function AdminNotificationsPanel({ t, toast }: { t: (k: string) => string; toast
adminApi.getNotificationPreferences().then((data: any) => setMatrix(data)).catch(() => {}) adminApi.getNotificationPreferences().then((data: any) => setMatrix(data)).catch(() => {})
}, []) }, [])
if (!matrix) return <p style={{ fontSize: 12, color: 'var(--text-faint)', fontStyle: 'italic', padding: 16 }}>Loading</p> if (!matrix) return <p className="text-content-faint" style={{ fontSize: 12, fontStyle: 'italic', padding: 16 }}>Loading</p>
const visibleChannels = (['inapp', 'email', 'webhook', 'ntfy'] as const).filter(ch => { const visibleChannels = (['inapp', 'email', 'webhook', 'ntfy'] as const).filter(ch => {
if (!matrix.available_channels[ch]) return false if (!matrix.available_channels[ch]) return false
@@ -69,7 +70,7 @@ function AdminNotificationsPanel({ t, toast }: { t: (k: string) => string; toast
if (matrix.event_types.length === 0) { if (matrix.event_types.length === 0) {
return ( return (
<div className="bg-white rounded-xl border border-slate-200 p-6"> <div className="bg-white rounded-xl border border-slate-200 p-6">
<p style={{ fontSize: 13, color: 'var(--text-faint)' }}>{t('settings.notificationPreferences.noChannels')}</p> <p className="text-content-faint" style={{ fontSize: 13 }}>{t('settings.notificationPreferences.noChannels')}</p>
</div> </div>
) )
} }
@@ -82,12 +83,12 @@ function AdminNotificationsPanel({ t, toast }: { t: (k: string) => string; toast
<p className="text-xs text-slate-400 mt-1">{t('admin.notifications.adminNotificationsHint')}</p> <p className="text-xs text-slate-400 mt-1">{t('admin.notifications.adminNotificationsHint')}</p>
</div> </div>
<div className="p-6"> <div className="p-6">
{saving && <p style={{ fontSize: 11, color: 'var(--text-faint)', marginBottom: 8 }}>Saving</p>} {saving && <p className="text-content-faint" style={{ fontSize: 11, marginBottom: 8 }}>Saving</p>}
{/* Header row */} {/* Header row */}
<div style={{ display: 'grid', gridTemplateColumns: `1fr ${visibleChannels.map(() => '80px').join(' ')}`, gap: 4, paddingBottom: 6, marginBottom: 4, borderBottom: '1px solid var(--border-primary)' }}> <div className="border-b border-edge" style={{ display: 'grid', gridTemplateColumns: `1fr ${visibleChannels.map(() => '80px').join(' ')}`, gap: 4, paddingBottom: 6, marginBottom: 4 }}>
<span /> <span />
{visibleChannels.map(ch => ( {visibleChannels.map(ch => (
<span key={ch} style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', textAlign: 'center', textTransform: 'uppercase', letterSpacing: '0.04em' }}> <span key={ch} className="text-content-faint" style={{ fontSize: 11, fontWeight: 600, textAlign: 'center', textTransform: 'uppercase', letterSpacing: '0.04em' }}>
{t(ADMIN_CHANNEL_LABEL_KEYS[ch]) || ch} {t(ADMIN_CHANNEL_LABEL_KEYS[ch]) || ch}
</span> </span>
))} ))}
@@ -96,13 +97,13 @@ function AdminNotificationsPanel({ t, toast }: { t: (k: string) => string; toast
{matrix.event_types.map((eventType: string) => { {matrix.event_types.map((eventType: string) => {
const implementedForEvent = matrix.implemented_combos[eventType] ?? [] const implementedForEvent = matrix.implemented_combos[eventType] ?? []
return ( return (
<div key={eventType} style={{ display: 'grid', gridTemplateColumns: `1fr ${visibleChannels.map(() => '80px').join(' ')}`, gap: 4, alignItems: 'center', padding: '8px 0', borderBottom: '1px solid var(--border-primary)' }}> <div key={eventType} className="border-b border-edge" style={{ display: 'grid', gridTemplateColumns: `1fr ${visibleChannels.map(() => '80px').join(' ')}`, gap: 4, alignItems: 'center', padding: '8px 0' }}>
<span style={{ fontSize: 13, color: 'var(--text-primary)' }}> <span className="text-content" style={{ fontSize: 13 }}>
{t(ADMIN_EVENT_LABEL_KEYS[eventType]) || eventType} {t(ADMIN_EVENT_LABEL_KEYS[eventType]) || eventType}
</span> </span>
{visibleChannels.map(ch => { {visibleChannels.map(ch => {
if (!implementedForEvent.includes(ch)) { if (!implementedForEvent.includes(ch)) {
return <span key={ch} style={{ textAlign: 'center', color: 'var(--text-faint)', fontSize: 14 }}></span> return <span key={ch} className="text-content-faint" style={{ textAlign: 'center', fontSize: 14 }}></span>
} }
const isOn = matrix.preferences[eventType]?.[ch] ?? true const isOn = matrix.preferences[eventType]?.[ch] ?? true
return ( return (
@@ -130,12 +131,12 @@ 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 { 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) const animated = useCountUp(value, 900)
return ( return (
<div className="rounded-xl border p-4" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}> <div className="rounded-xl border p-4 bg-surface-card border-edge">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<Icon className="w-5 h-5" style={{ color: 'var(--text-primary)' }} /> <Icon className="w-5 h-5 text-content" />
<div> <div>
<p className="text-xl font-bold tabular-nums" style={{ color: 'var(--text-primary)' }}>{animated}</p> <p className="text-xl font-bold tabular-nums text-content">{animated}</p>
<p className="text-xs" style={{ color: 'var(--text-muted)' }}>{label}</p> <p className="text-xs text-content-muted">{label}</p>
</div> </div>
</div> </div>
</div> </div>
@@ -205,39 +206,7 @@ export default function AdminPage(): React.ReactElement {
{/* Update Banner */} {/* Update Banner */}
{updateInfo && ( {updateInfo && (
<div className="mb-6 p-4 rounded-xl border flex flex-col sm:flex-row items-start sm:items-center gap-4 bg-amber-50 dark:bg-amber-950/40 border-amber-300 dark:border-amber-700"> <AdminUpdateBanner updateInfo={updateInfo} t={t} onHowTo={() => setShowUpdateModal(true)} />
<div className="flex items-center gap-4 flex-1 min-w-0">
<div className="flex-shrink-0 w-10 h-10 rounded-full flex items-center justify-center bg-amber-500 dark:bg-amber-600">
<ArrowUpCircle className="w-5 h-5 text-white" />
</div>
<div>
<p className="text-sm font-semibold text-amber-900 dark:text-amber-200">{t('admin.update.available')}</p>
<p className="text-xs text-amber-700 dark:text-amber-400 mt-0.5">
{t('admin.update.text').replace('{version}', `v${updateInfo.latest}`).replace('{current}', `v${updateInfo.current}`)}
</p>
</div>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
{updateInfo.release_url && (
<a
href={updateInfo.release_url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 px-3 py-2 rounded-lg text-xs font-medium transition-colors text-amber-800 dark:text-amber-300 border border-amber-300 dark:border-amber-600 hover:bg-amber-100 dark:hover:bg-amber-900/50"
>
<ExternalLink className="w-3.5 h-3.5" />
{t('admin.update.button')}
</a>
)}
<button
onClick={() => setShowUpdateModal(true)}
className="flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-semibold transition-colors bg-slate-900 dark:bg-white text-white dark:text-slate-900 hover:bg-slate-700 dark:hover:bg-gray-200"
>
<Download className="w-4 h-4" />
{t('admin.update.howTo')}
</button>
</div>
</div>
)} )}
{/* Demo Baseline Button */} {/* Demo Baseline Button */}
@@ -332,7 +301,7 @@ export default function AdminPage(): React.ReactElement {
{u.username.charAt(0).toUpperCase()} {u.username.charAt(0).toUpperCase()}
</div> </div>
)} )}
<span className="absolute -bottom-0.5 -right-0.5 w-3 h-3 rounded-full border-2" style={{ borderColor: 'var(--bg-card)', background: u.online ? '#22c55e' : '#94a3b8' }} /> <span className="absolute -bottom-0.5 -right-0.5 w-3 h-3 rounded-full border-2 border-surface-card" style={{ background: u.online ? '#22c55e' : '#94a3b8' }} />
</div> </div>
<div> <div>
<p className="text-sm font-medium text-slate-900">{u.username}</p> <p className="text-sm font-medium text-slate-900">{u.username}</p>
@@ -1093,8 +1062,8 @@ export default function AdminPage(): React.ReactElement {
<h2 className="font-semibold text-slate-900">{t('admin.notifications.inappPanel.title')}</h2> <h2 className="font-semibold text-slate-900">{t('admin.notifications.inappPanel.title')}</h2>
<p className="text-xs text-slate-400 mt-1">{t('admin.notifications.inappPanel.hint')}</p> <p className="text-xs text-slate-400 mt-1">{t('admin.notifications.inappPanel.hint')}</p>
</div> </div>
<div className="relative inline-flex h-6 w-11 items-center rounded-full flex-shrink-0" <div className="relative inline-flex h-6 w-11 items-center rounded-full flex-shrink-0 bg-content"
style={{ background: 'var(--text-primary)', opacity: 0.5, cursor: 'not-allowed' }}> style={{ opacity: 0.5, cursor: 'not-allowed' }}>
<span className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200" <span className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
style={{ transform: 'translateX(20px)' }} /> style={{ transform: 'translateX(20px)' }} />
</div> </div>
+87 -172
View File
@@ -3,10 +3,13 @@ import { useTranslation } from '../i18n'
import Navbar from '../components/Layout/Navbar' import Navbar from '../components/Layout/Navbar'
import apiClient from '../api/client' import apiClient from '../api/client'
import CustomSelect from '../components/shared/CustomSelect' import CustomSelect from '../components/shared/CustomSelect'
import { Globe, MapPin, Briefcase, Calendar, Flag, ChevronRight, PanelLeftOpen, PanelLeftClose, X, Star, Plus, Trash2, Search } from 'lucide-react' import { Globe, MapPin, Briefcase, Calendar, Flag, PanelLeftOpen, PanelLeftClose, X, Star, Plus, Trash2, Search } from 'lucide-react'
import type { TranslationFn } from '../types' import type { TranslationFn } from '../types'
import { A2_TO_A3, countryCodeToFlag, type AtlasCountry, type AtlasStats, type AtlasData, type CountryDetail } from './atlas/atlasModel' import { A2_TO_A3, countryCodeToFlag, type AtlasCountry, type AtlasStats, type AtlasData, type CountryDetail } from './atlas/atlasModel'
import { useAtlas } from './atlas/useAtlas' import { useAtlas } from './atlas/useAtlas'
import AtlasCountrySearch from './atlas/AtlasCountrySearch'
import { useToast } from '../components/shared/Toast'
import { getApiErrorMessage } from '../types'
function MobileStats({ data, stats, countries, resolveName, t, dark }: { data: AtlasData | null; stats: AtlasStats; countries: AtlasCountry[]; resolveName: (code: string) => string; t: TranslationFn; dark: boolean }): React.ReactElement { function MobileStats({ data, stats, countries, resolveName, t, dark }: { data: AtlasData | null; stats: AtlasStats; countries: AtlasCountry[]; resolveName: (code: string) => string; t: TranslationFn; dark: boolean }): React.ReactElement {
const tp = dark ? '#f1f5f9' : '#0f172a' const tp = dark ? '#f1f5f9' : '#0f172a'
@@ -80,20 +83,21 @@ export default function AtlasPage(): React.ReactElement {
bucketPoiMonth, setBucketPoiMonth, bucketPoiYear, setBucketPoiYear, bucketPoiMonth, setBucketPoiMonth, bucketPoiYear, setBucketPoiYear,
bucketSearching, bucketSearch, setBucketSearch, bucketSearching, bucketSearch, setBucketSearch,
} = useAtlas() } = useAtlas()
const toast = useToast()
if (loading) { if (loading) {
return ( return (
<div className="min-h-screen" style={{ background: 'var(--bg-primary)' }}> <div className="min-h-screen bg-surface">
<Navbar /> <Navbar />
<div className="flex items-center justify-center" style={{ paddingTop: 'var(--nav-h)', minHeight: 'calc(100vh - var(--nav-h))' }}> <div className="flex items-center justify-center" style={{ paddingTop: 'var(--nav-h)', minHeight: 'calc(100vh - var(--nav-h))' }}>
<div className="w-8 h-8 border-2 rounded-full animate-spin" style={{ borderColor: 'var(--border-primary)', borderTopColor: 'var(--text-primary)' }} /> <div className="w-8 h-8 border-2 rounded-full animate-spin border-edge border-t-content" />
</div> </div>
</div> </div>
) )
} }
return ( return (
<div className="h-screen overflow-hidden" style={{ background: 'var(--bg-primary)' }}> <div className="h-screen overflow-hidden bg-surface">
<Navbar /> <Navbar />
<div style={{ position: 'fixed', top: 'var(--nav-h)', left: 0, right: 0, bottom: 'env(safe-area-inset-bottom, 0px)' }}> <div style={{ position: 'fixed', top: 'var(--nav-h)', left: 0, right: 0, bottom: 'env(safe-area-inset-bottom, 0px)' }}>
{/* Map */} {/* Map */}
@@ -110,129 +114,18 @@ export default function AtlasPage(): React.ReactElement {
border: `1px solid ${dark ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.08)'}`, border: `1px solid ${dark ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.08)'}`,
fontSize: 12, minWidth: 120, fontSize: 12, minWidth: 120,
}} /> }} />
<div <AtlasCountrySearch
className="absolute z-20 flex justify-center" dark={dark}
style={{ top: 'calc(env(safe-area-inset-top, 0px) + 14px)', left: 0, right: 0, pointerEvents: 'none' }} t={t}
> search={atlas_country_search}
<div style={{ width: 'min(520px, calc(100vw - 28px))', pointerEvents: 'auto' }}> setSearch={set_atlas_country_search}
<div style={{ results={atlas_country_results}
display: 'flex', setResults={set_atlas_country_results}
alignItems: 'center', open={atlas_country_open}
gap: 10, setOpen={set_atlas_country_open}
padding: '10px 12px', options={atlas_country_options}
borderRadius: 16, onSelect={select_country_from_search}
border: '1px solid ' + (dark ? 'rgba(255,255,255,0.10)' : 'rgba(0,0,0,0.08)'), />
background: dark ? 'rgba(10,10,15,0.55)' : 'rgba(255,255,255,0.55)',
backdropFilter: 'blur(18px) saturate(180%)',
WebkitBackdropFilter: 'blur(18px) saturate(180%)',
boxShadow: dark ? '0 8px 26px rgba(0,0,0,0.25)' : '0 8px 26px rgba(0,0,0,0.10)',
}}>
<Search size={16} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
<input
value={atlas_country_search}
onChange={(e) => {
const raw = e.target.value
set_atlas_country_search(raw)
const q = raw.trim().toLowerCase()
if (!q) {
set_atlas_country_results([])
set_atlas_country_open(false)
return
}
const results = atlas_country_options
.filter(o => o.label.toLowerCase().includes(q) || o.code.toLowerCase() === q)
.slice(0, 8)
set_atlas_country_results(results)
set_atlas_country_open(true)
}}
onFocus={() => {
if (atlas_country_results.length > 0) set_atlas_country_open(true)
}}
onKeyDown={(e) => {
if (e.key === 'Escape') {
set_atlas_country_open(false)
return
}
if (e.key === 'Enter') {
const first = atlas_country_results[0]
if (first) select_country_from_search(first.code)
}
}}
placeholder={t('atlas.searchCountry')}
autoComplete="off"
spellCheck={false}
style={{
flex: 1,
border: 'none',
outline: 'none',
background: 'transparent',
fontSize: 13,
fontFamily: 'inherit',
color: 'var(--text-primary)',
}}
/>
{atlas_country_search.trim() && (
<button
onClick={() => {
set_atlas_country_search('')
set_atlas_country_results([])
set_atlas_country_open(false)
}}
style={{ border: 'none', background: 'none', cursor: 'pointer', color: 'var(--text-faint)', padding: 2, display: 'flex' }}
aria-label="Clear"
>
<X size={14} />
</button>
)}
</div>
{atlas_country_open && atlas_country_results.length > 0 && (
<div
style={{
marginTop: 8,
borderRadius: 14,
overflow: 'hidden',
border: '1px solid ' + (dark ? 'rgba(255,255,255,0.10)' : 'rgba(0,0,0,0.08)'),
background: dark ? 'rgba(10,10,15,0.75)' : 'rgba(255,255,255,0.75)',
backdropFilter: 'blur(18px) saturate(180%)',
WebkitBackdropFilter: 'blur(18px) saturate(180%)',
boxShadow: dark ? '0 12px 30px rgba(0,0,0,0.35)' : '0 12px 30px rgba(0,0,0,0.12)',
}}
onMouseLeave={() => set_atlas_country_open(false)}
>
{atlas_country_results.map((r) => (
<button
key={r.code}
onClick={() => select_country_from_search(r.code)}
style={{
width: '100%',
border: 'none',
background: 'transparent',
cursor: 'pointer',
padding: '10px 12px',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
fontFamily: 'inherit',
textAlign: 'left',
borderBottom: '1px solid ' + (dark ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.06)'),
}}
onMouseEnter={(e) => { (e.currentTarget as HTMLButtonElement).style.background = dark ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.05)' }}
onMouseLeave={(e) => { (e.currentTarget as HTMLButtonElement).style.background = 'transparent' }}
>
<span style={{ display: 'flex', alignItems: 'center', gap: 10, minWidth: 0 }}>
<img src={`https://flagcdn.com/w40/${r.code.toLowerCase()}.png`} alt={r.code} style={{ width: 28, height: 20, borderRadius: 4, objectFit: 'cover' }} />
<span style={{ fontSize: 13, fontWeight: 650, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{r.label}
</span>
</span>
<ChevronRight size={16} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
</button>
))}
</div>
)}
</div>
</div>
{/* Mobile: Bottom bar */} {/* Mobile: Bottom bar */}
<div className="md:hidden absolute left-0 right-0 z-10 flex justify-center" style={{ bottom: 'calc(84px + env(safe-area-inset-bottom, 0px) + 8px)', touchAction: 'manipulation' }}> <div className="md:hidden absolute left-0 right-0 z-10 flex justify-center" style={{ bottom: 'calc(84px + env(safe-area-inset-bottom, 0px) + 8px)', touchAction: 'manipulation' }}>
@@ -240,13 +133,13 @@ export default function AtlasPage(): React.ReactElement {
style={{ background: dark ? 'rgba(0,0,0,0.45)' : 'rgba(255,255,255,0.5)', backdropFilter: 'blur(16px)' }}> style={{ background: dark ? 'rgba(0,0,0,0.45)' : 'rgba(255,255,255,0.5)', backdropFilter: 'blur(16px)' }}>
{/* Countries highlighted */} {/* Countries highlighted */}
<div className="text-center px-3 py-1.5 rounded-xl" style={{ background: dark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.05)' }}> <div className="text-center px-3 py-1.5 rounded-xl" style={{ background: dark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.05)' }}>
<p className="text-3xl font-black tabular-nums leading-none" style={{ color: 'var(--text-primary)' }}>{stats.totalCountries}</p> <p className="text-3xl font-black tabular-nums leading-none text-content">{stats.totalCountries}</p>
<p className="text-[9px] font-semibold uppercase tracking-wide mt-1" style={{ color: 'var(--text-faint)' }}>{t('atlas.countries')}</p> <p className="text-[9px] font-semibold uppercase tracking-wide mt-1 text-content-faint">{t('atlas.countries')}</p>
</div> </div>
{[[stats.totalTrips, t('atlas.trips')], [stats.totalPlaces, t('atlas.places')], [stats.totalCities || 0, t('atlas.cities')], [stats.totalDays, t('atlas.days')]].map(([v, l], i) => ( {[[stats.totalTrips, t('atlas.trips')], [stats.totalPlaces, t('atlas.places')], [stats.totalCities || 0, t('atlas.cities')], [stats.totalDays, t('atlas.days')]].map(([v, l], i) => (
<div key={i} className="text-center px-1"> <div key={i} className="text-center px-1">
<p className="text-xl font-black tabular-nums leading-none" style={{ color: 'var(--text-primary)' }}>{v}</p> <p className="text-xl font-black tabular-nums leading-none text-content">{v}</p>
<p className="text-[9px] font-semibold uppercase tracking-wide mt-1" style={{ color: 'var(--text-faint)' }}>{l}</p> <p className="text-[9px] font-semibold uppercase tracking-wide mt-1 text-content-faint">{l}</p>
</div> </div>
))} ))}
</div> </div>
@@ -303,14 +196,14 @@ export default function AtlasPage(): React.ReactElement {
{confirmAction && ( {confirmAction && (
<div style={{ position: 'fixed', inset: 0, zIndex: 1000, background: 'rgba(0,0,0,0.4)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20 }} <div style={{ position: 'fixed', inset: 0, zIndex: 1000, background: 'rgba(0,0,0,0.4)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20 }}
onClick={() => setConfirmAction(null)}> onClick={() => setConfirmAction(null)}>
<div style={{ background: 'var(--bg-card)', borderRadius: 16, padding: 24, maxWidth: 340, width: '100%', boxShadow: '0 16px 48px rgba(0,0,0,0.2)', textAlign: 'center' }} <div className="bg-surface-card" style={{ borderRadius: 16, padding: 24, maxWidth: 340, width: '100%', boxShadow: '0 16px 48px rgba(0,0,0,0.2)', textAlign: 'center' }}
onClick={e => e.stopPropagation()}> onClick={e => e.stopPropagation()}>
{confirmAction.code.length === 2 ? ( {confirmAction.code.length === 2 ? (
<img src={`https://flagcdn.com/w80/${confirmAction.code.toLowerCase()}.png`} alt={confirmAction.code} style={{ width: 48, height: 34, borderRadius: 6, objectFit: 'cover', marginBottom: 12, display: 'inline-block' }} /> <img src={`https://flagcdn.com/w80/${confirmAction.code.toLowerCase()}.png`} alt={confirmAction.code} style={{ width: 48, height: 34, borderRadius: 6, objectFit: 'cover', marginBottom: 12, display: 'inline-block' }} />
) : ( ) : (
<div style={{ fontSize: 36, marginBottom: 12 }}>{countryCodeToFlag(confirmAction.code)}</div> <div style={{ fontSize: 36, marginBottom: 12 }}>{countryCodeToFlag(confirmAction.code)}</div>
)} )}
<h3 style={{ margin: '0 0 16px', fontSize: 16, fontWeight: 700, color: 'var(--text-primary)' }}>{confirmAction.name}</h3> <h3 className="text-content" style={{ margin: '0 0 16px', fontSize: 16, fontWeight: 700 }}>{confirmAction.name}</h3>
{confirmAction.type === 'choose' && ( {confirmAction.type === 'choose' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
@@ -321,26 +214,30 @@ export default function AtlasPage(): React.ReactElement {
if (!prev || prev.countries.find(c => c.code === confirmAction.code)) return prev if (!prev || prev.countries.find(c => c.code === confirmAction.code)) return prev
return { ...prev, countries: [...prev.countries, { code: confirmAction.code, placeCount: 0, tripCount: 0, firstVisit: null, lastVisit: null }], stats: { ...prev.stats, totalCountries: prev.stats.totalCountries + 1 } } return { ...prev, countries: [...prev.countries, { code: confirmAction.code, placeCount: 0, tripCount: 0, firstVisit: null, lastVisit: null }], stats: { ...prev.stats, totalCountries: prev.stats.totalCountries + 1 } }
}) })
} catch {} } catch (err) {
toast.error(getApiErrorMessage(err, t('common.error')))
}
setConfirmAction(null) setConfirmAction(null)
}} }}
style={{ display: 'flex', alignItems: 'center', gap: 10, width: '100%', padding: '12px 16px', borderRadius: 12, border: '1px solid var(--border-primary)', background: 'none', cursor: 'pointer', fontFamily: 'inherit', textAlign: 'left', transition: 'background 0.12s' }} className="border border-edge"
style={{ display: 'flex', alignItems: 'center', gap: 10, width: '100%', padding: '12px 16px', borderRadius: 12, background: 'none', cursor: 'pointer', fontFamily: 'inherit', textAlign: 'left', transition: 'background 0.12s' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-secondary)'} onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-secondary)'}
onMouseLeave={e => e.currentTarget.style.background = 'none'}> onMouseLeave={e => e.currentTarget.style.background = 'none'}>
<MapPin size={18} style={{ color: 'var(--text-primary)', flexShrink: 0 }} /> <MapPin size={18} className="text-content" style={{ flexShrink: 0 }} />
<div> <div>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>{t('atlas.markVisited')}</div> <div className="text-content" style={{ fontSize: 13, fontWeight: 600 }}>{t('atlas.markVisited')}</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 1 }}>{t('atlas.markVisitedHint')}</div> <div className="text-content-muted" style={{ fontSize: 11, marginTop: 1 }}>{t('atlas.markVisitedHint')}</div>
</div> </div>
</button> </button>
<button onClick={() => setConfirmAction({ ...confirmAction, type: 'bucket' as any })} <button onClick={() => setConfirmAction({ ...confirmAction, type: 'bucket' as any })}
style={{ display: 'flex', alignItems: 'center', gap: 10, width: '100%', padding: '12px 16px', borderRadius: 12, border: '1px solid var(--border-primary)', background: 'none', cursor: 'pointer', fontFamily: 'inherit', textAlign: 'left', transition: 'background 0.12s' }} className="border border-edge"
style={{ display: 'flex', alignItems: 'center', gap: 10, width: '100%', padding: '12px 16px', borderRadius: 12, background: 'none', cursor: 'pointer', fontFamily: 'inherit', textAlign: 'left', transition: 'background 0.12s' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-secondary)'} onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-secondary)'}
onMouseLeave={e => e.currentTarget.style.background = 'none'}> onMouseLeave={e => e.currentTarget.style.background = 'none'}>
<Star size={18} style={{ color: '#fbbf24', flexShrink: 0 }} /> <Star size={18} style={{ color: '#fbbf24', flexShrink: 0 }} />
<div> <div>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>{t('atlas.addToBucket')}</div> <div className="text-content" style={{ fontSize: 13, fontWeight: 600 }}>{t('atlas.addToBucket')}</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 1 }}>{t('atlas.addToBucketHint')}</div> <div className="text-content-muted" style={{ fontSize: 11, marginTop: 1 }}>{t('atlas.addToBucketHint')}</div>
</div> </div>
</button> </button>
</div> </div>
@@ -349,7 +246,7 @@ export default function AtlasPage(): React.ReactElement {
{confirmAction.type === 'choose-region' && ( {confirmAction.type === 'choose-region' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{confirmAction.countryName && ( {confirmAction.countryName && (
<p style={{ margin: '-8px 0 8px', fontSize: 12, color: 'var(--text-muted)' }}>{confirmAction.countryName}</p> <p className="text-content-muted" style={{ margin: '-8px 0 8px', fontSize: 12 }}>{confirmAction.countryName}</p>
)} )}
<button onClick={async () => { <button onClick={async () => {
const { code: countryCode, name: rName, regionCode: rCode } = confirmAction const { code: countryCode, name: rName, regionCode: rCode } = confirmAction
@@ -365,26 +262,30 @@ export default function AtlasPage(): React.ReactElement {
if (!prev || prev.countries.find(c => c.code === countryCode)) return prev if (!prev || prev.countries.find(c => c.code === countryCode)) return prev
return { ...prev, countries: [...prev.countries, { code: countryCode, placeCount: 0, tripCount: 0, firstVisit: null, lastVisit: null }], stats: { ...prev.stats, totalCountries: prev.stats.totalCountries + 1 } } return { ...prev, countries: [...prev.countries, { code: countryCode, placeCount: 0, tripCount: 0, firstVisit: null, lastVisit: null }], stats: { ...prev.stats, totalCountries: prev.stats.totalCountries + 1 } }
}) })
} catch {} } catch (err) {
toast.error(getApiErrorMessage(err, t('common.error')))
}
setConfirmAction(null) setConfirmAction(null)
}} }}
style={{ display: 'flex', alignItems: 'center', gap: 10, width: '100%', padding: '12px 16px', borderRadius: 12, border: '1px solid var(--border-primary)', background: 'none', cursor: 'pointer', fontFamily: 'inherit', textAlign: 'left', transition: 'background 0.12s' }} className="border border-edge"
style={{ display: 'flex', alignItems: 'center', gap: 10, width: '100%', padding: '12px 16px', borderRadius: 12, background: 'none', cursor: 'pointer', fontFamily: 'inherit', textAlign: 'left', transition: 'background 0.12s' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-secondary)'} onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-secondary)'}
onMouseLeave={e => e.currentTarget.style.background = 'none'}> onMouseLeave={e => e.currentTarget.style.background = 'none'}>
<MapPin size={18} style={{ color: 'var(--text-primary)', flexShrink: 0 }} /> <MapPin size={18} className="text-content" style={{ flexShrink: 0 }} />
<div> <div>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>{t('atlas.markVisited')}</div> <div className="text-content" style={{ fontSize: 13, fontWeight: 600 }}>{t('atlas.markVisited')}</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 1 }}>{t('atlas.markRegionVisitedHint')}</div> <div className="text-content-muted" style={{ fontSize: 11, marginTop: 1 }}>{t('atlas.markRegionVisitedHint')}</div>
</div> </div>
</button> </button>
<button onClick={() => setConfirmAction({ ...confirmAction, type: 'bucket' })} <button onClick={() => setConfirmAction({ ...confirmAction, type: 'bucket' })}
style={{ display: 'flex', alignItems: 'center', gap: 10, width: '100%', padding: '12px 16px', borderRadius: 12, border: '1px solid var(--border-primary)', background: 'none', cursor: 'pointer', fontFamily: 'inherit', textAlign: 'left', transition: 'background 0.12s' }} className="border border-edge"
style={{ display: 'flex', alignItems: 'center', gap: 10, width: '100%', padding: '12px 16px', borderRadius: 12, background: 'none', cursor: 'pointer', fontFamily: 'inherit', textAlign: 'left', transition: 'background 0.12s' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-secondary)'} onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-secondary)'}
onMouseLeave={e => e.currentTarget.style.background = 'none'}> onMouseLeave={e => e.currentTarget.style.background = 'none'}>
<Star size={18} style={{ color: '#fbbf24', flexShrink: 0 }} /> <Star size={18} style={{ color: '#fbbf24', flexShrink: 0 }} />
<div> <div>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>{t('atlas.addToBucket')}</div> <div className="text-content" style={{ fontSize: 13, fontWeight: 600 }}>{t('atlas.addToBucket')}</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 1 }}>{t('atlas.addToBucketHint')}</div> <div className="text-content-muted" style={{ fontSize: 11, marginTop: 1 }}>{t('atlas.addToBucketHint')}</div>
</div> </div>
</button> </button>
</div> </div>
@@ -392,10 +293,11 @@ export default function AtlasPage(): React.ReactElement {
{confirmAction.type === 'unmark' && ( {confirmAction.type === 'unmark' && (
<> <>
<p style={{ margin: '0 0 20px', fontSize: 13, color: 'var(--text-muted)' }}>{t('atlas.confirmUnmark')}</p> <p className="text-content-muted" style={{ margin: '0 0 20px', fontSize: 13 }}>{t('atlas.confirmUnmark')}</p>
<div style={{ display: 'flex', gap: 8, justifyContent: 'center' }}> <div style={{ display: 'flex', gap: 8, justifyContent: 'center' }}>
<button onClick={() => setConfirmAction(null)} <button onClick={() => setConfirmAction(null)}
style={{ padding: '8px 20px', borderRadius: 10, border: '1px solid var(--border-primary)', background: 'none', fontSize: 13, cursor: 'pointer', fontFamily: 'inherit', color: 'var(--text-muted)' }}> className="border border-edge text-content-muted"
style={{ padding: '8px 20px', borderRadius: 10, background: 'none', fontSize: 13, cursor: 'pointer', fontFamily: 'inherit' }}>
{t('common.cancel')} {t('common.cancel')}
</button> </button>
<button onClick={executeConfirmAction} <button onClick={executeConfirmAction}
@@ -409,12 +311,13 @@ export default function AtlasPage(): React.ReactElement {
{confirmAction.type === 'unmark-region' && ( {confirmAction.type === 'unmark-region' && (
<> <>
{confirmAction.countryName && ( {confirmAction.countryName && (
<p style={{ margin: '-8px 0 8px', fontSize: 12, color: 'var(--text-muted)' }}>{confirmAction.countryName}</p> <p className="text-content-muted" style={{ margin: '-8px 0 8px', fontSize: 12 }}>{confirmAction.countryName}</p>
)} )}
<p style={{ margin: '0 0 20px', fontSize: 13, color: 'var(--text-muted)' }}>{t('atlas.confirmUnmarkRegion')}</p> <p className="text-content-muted" style={{ margin: '0 0 20px', fontSize: 13 }}>{t('atlas.confirmUnmarkRegion')}</p>
<div style={{ display: 'flex', gap: 8, justifyContent: 'center' }}> <div style={{ display: 'flex', gap: 8, justifyContent: 'center' }}>
<button onClick={() => setConfirmAction(null)} <button onClick={() => setConfirmAction(null)}
style={{ padding: '8px 20px', borderRadius: 10, border: '1px solid var(--border-primary)', background: 'none', fontSize: 13, cursor: 'pointer', fontFamily: 'inherit', color: 'var(--text-muted)' }}> className="border border-edge text-content-muted"
style={{ padding: '8px 20px', borderRadius: 10, background: 'none', fontSize: 13, cursor: 'pointer', fontFamily: 'inherit' }}>
{t('common.cancel')} {t('common.cancel')}
</button> </button>
<button onClick={async () => { <button onClick={async () => {
@@ -441,7 +344,9 @@ export default function AtlasPage(): React.ReactElement {
stats: { ...prev.stats, totalCountries: Math.max(0, prev.stats.totalCountries - 1) }, stats: { ...prev.stats, totalCountries: Math.max(0, prev.stats.totalCountries - 1) },
} }
}) })
} catch {} } catch (err) {
toast.error(getApiErrorMessage(err, t('common.error')))
}
setConfirmAction(null) setConfirmAction(null)
}} }}
style={{ padding: '8px 20px', borderRadius: 10, border: 'none', fontSize: 13, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', background: '#ef4444', color: 'white' }}> style={{ padding: '8px 20px', borderRadius: 10, border: 'none', fontSize: 13, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', background: '#ef4444', color: 'white' }}>
@@ -453,7 +358,7 @@ export default function AtlasPage(): React.ReactElement {
{confirmAction.type === 'bucket' && ( {confirmAction.type === 'bucket' && (
<> <>
<p style={{ margin: '0 0 14px', fontSize: 13, color: 'var(--text-muted)' }}>{t('atlas.bucketWhen')}</p> <p className="text-content-muted" style={{ margin: '0 0 14px', fontSize: 13 }}>{t('atlas.bucketWhen')}</p>
<div style={{ display: 'flex', gap: 8, justifyContent: 'center', marginBottom: 16 }}> <div style={{ display: 'flex', gap: 8, justifyContent: 'center', marginBottom: 16 }}>
<div style={{ flex: 1 }}> <div style={{ flex: 1 }}>
<CustomSelect <CustomSelect
@@ -482,7 +387,8 @@ export default function AtlasPage(): React.ReactElement {
</div> </div>
<div style={{ display: 'flex', gap: 8, justifyContent: 'center', flexWrap: 'wrap' }}> <div style={{ display: 'flex', gap: 8, justifyContent: 'center', flexWrap: 'wrap' }}>
<button onClick={() => setConfirmAction({ ...confirmAction, type: confirmAction.regionCode ? 'choose-region' : 'choose' })} <button onClick={() => setConfirmAction({ ...confirmAction, type: confirmAction.regionCode ? 'choose-region' : 'choose' })}
style={{ padding: '8px 20px', borderRadius: 10, border: '1px solid var(--border-primary)', background: 'none', fontSize: 13, cursor: 'pointer', fontFamily: 'inherit', color: 'var(--text-muted)' }}> className="border border-edge text-content-muted"
style={{ padding: '8px 20px', borderRadius: 10, background: 'none', fontSize: 13, cursor: 'pointer', fontFamily: 'inherit' }}>
{t('common.back')} {t('common.back')}
</button> </button>
<button onClick={async () => { <button onClick={async () => {
@@ -490,7 +396,9 @@ export default function AtlasPage(): React.ReactElement {
try { try {
const r = await apiClient.post('/addons/atlas/bucket-list', { name: confirmAction.name, country_code: confirmAction.code, target_date: targetDate }) const r = await apiClient.post('/addons/atlas/bucket-list', { name: confirmAction.name, country_code: confirmAction.code, target_date: targetDate })
setBucketList(prev => [r.data.item, ...prev]) setBucketList(prev => [r.data.item, ...prev])
} catch {} } catch (err) {
toast.error(getApiErrorMessage(err, t('common.error')))
}
setBucketMonth(0); setBucketYear(0) setBucketMonth(0); setBucketYear(0)
setConfirmAction(null) setConfirmAction(null)
}} }}
@@ -503,14 +411,16 @@ export default function AtlasPage(): React.ReactElement {
{confirmAction.type === 'mark' && ( {confirmAction.type === 'mark' && (
<> <>
<p style={{ margin: '0 0 20px', fontSize: 13, color: 'var(--text-muted)' }}>{t('atlas.confirmMark')}</p> <p className="text-content-muted" style={{ margin: '0 0 20px', fontSize: 13 }}>{t('atlas.confirmMark')}</p>
<div style={{ display: 'flex', gap: 8, justifyContent: 'center' }}> <div style={{ display: 'flex', gap: 8, justifyContent: 'center' }}>
<button onClick={() => setConfirmAction(null)} <button onClick={() => setConfirmAction(null)}
style={{ padding: '8px 20px', borderRadius: 10, border: '1px solid var(--border-primary)', background: 'none', fontSize: 13, cursor: 'pointer', fontFamily: 'inherit', color: 'var(--text-muted)' }}> className="border border-edge text-content-muted"
style={{ padding: '8px 20px', borderRadius: 10, background: 'none', fontSize: 13, cursor: 'pointer', fontFamily: 'inherit' }}>
{t('common.cancel')} {t('common.cancel')}
</button> </button>
<button onClick={executeConfirmAction} <button onClick={executeConfirmAction}
style={{ padding: '8px 20px', borderRadius: 10, border: 'none', fontSize: 13, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', background: 'var(--text-primary)', color: 'white' }}> className="bg-content"
style={{ padding: '8px 20px', borderRadius: 10, border: 'none', fontSize: 13, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', color: 'white' }}>
{t('atlas.markVisited')} {t('atlas.markVisited')}
</button> </button>
</div> </div>
@@ -657,27 +567,30 @@ function SidebarContent({ data, stats, countries, selectedCountry, countryDetail
onKeyDown={e => { if (e.key === 'Enter' && !bucketForm.name) onSearchBucket(); else if (e.key === 'Enter') onAddBucket(); if (e.key === 'Escape') setShowBucketAdd(false) }} onKeyDown={e => { if (e.key === 'Enter' && !bucketForm.name) onSearchBucket(); else if (e.key === 'Enter') onAddBucket(); if (e.key === 'Escape') setShowBucketAdd(false) }}
placeholder={t('atlas.bucketNamePlaceholder')} placeholder={t('atlas.bucketNamePlaceholder')}
autoFocus autoFocus
style={{ flex: 1, padding: '6px 10px', borderRadius: 8, border: '1px solid var(--border-primary)', fontSize: 12, fontFamily: 'inherit', outline: 'none', boxSizing: 'border-box', color: 'var(--text-primary)', background: 'var(--bg-input)' }} className="border border-edge text-content bg-surface-input"
style={{ flex: 1, padding: '6px 10px', borderRadius: 8, fontSize: 12, fontFamily: 'inherit', outline: 'none', boxSizing: 'border-box' }}
/> />
{!bucketForm.name && ( {!bucketForm.name && (
<button onClick={onSearchBucket} disabled={bucketSearching} <button onClick={onSearchBucket} disabled={bucketSearching}
style={{ padding: '6px 10px', borderRadius: 8, border: 'none', background: 'var(--accent)', color: 'var(--accent-text)', cursor: 'pointer', display: 'flex', alignItems: 'center' }}> className="bg-accent text-accent-text"
style={{ padding: '6px 10px', borderRadius: 8, border: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center' }}>
<Search size={12} /> <Search size={12} />
</button> </button>
)} )}
{bucketForm.name && ( {bucketForm.name && (
<button onClick={() => { setBucketForm({ ...bucketForm, name: '', lat: '', lng: '' }); setBucketSearch('') }} <button onClick={() => { setBucketForm({ ...bucketForm, name: '', lat: '', lng: '' }); setBucketSearch('') }}
style={{ padding: '6px 8px', borderRadius: 8, border: '1px solid var(--border-primary)', background: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', color: 'var(--text-faint)' }}> className="border border-edge text-content-faint"
style={{ padding: '6px 8px', borderRadius: 8, background: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center' }}>
<X size={12} /> <X size={12} />
</button> </button>
)} )}
</div> </div>
{bucketSearchResults.length > 0 && ( {bucketSearchResults.length > 0 && (
<div style={{ position: 'absolute', bottom: '100%', left: 0, right: 0, zIndex: 50, marginBottom: 4, background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 8, boxShadow: '0 4px 12px rgba(0,0,0,0.12)', maxHeight: 160, overflowY: 'auto' }}> <div className="bg-surface-card border border-edge" style={{ position: 'absolute', bottom: '100%', left: 0, right: 0, zIndex: 50, marginBottom: 4, borderRadius: 8, boxShadow: '0 4px 12px rgba(0,0,0,0.12)', maxHeight: 160, overflowY: 'auto' }}>
{bucketSearchResults.slice(0, 6).map((r, i) => ( {bucketSearchResults.slice(0, 6).map((r, i) => (
<button key={i} onClick={() => onSelectBucketPoi(r)} style={{ display: 'flex', flexDirection: 'column', gap: 1, width: '100%', padding: '6px 10px', border: 'none', background: 'none', cursor: 'pointer', textAlign: 'left', fontFamily: 'inherit', borderBottom: '1px solid var(--border-faint)' }}> <button key={i} onClick={() => onSelectBucketPoi(r)} className="border-b border-edge-faint" style={{ display: 'flex', flexDirection: 'column', gap: 1, width: '100%', padding: '6px 10px', borderTop: 'none', borderLeft: 'none', borderRight: 'none', background: 'none', cursor: 'pointer', textAlign: 'left', fontFamily: 'inherit' }}>
<span style={{ fontSize: 12, fontWeight: 500, color: 'var(--text-primary)' }}>{r.name}</span> <span className="text-content" style={{ fontSize: 12, fontWeight: 500 }}>{r.name}</span>
{r.address && <span style={{ fontSize: 10, color: 'var(--text-faint)' }}>{r.address}</span>} {r.address && <span className="text-content-faint" style={{ fontSize: 10 }}>{r.address}</span>}
</button> </button>
))} ))}
</div> </div>
@@ -685,7 +598,7 @@ function SidebarContent({ data, stats, countries, selectedCountry, countryDetail
</div> </div>
{/* Selected place indicator */} {/* Selected place indicator */}
{bucketForm.lat && bucketForm.lng && ( {bucketForm.lat && bucketForm.lng && (
<div style={{ fontSize: 10, color: 'var(--text-faint)', display: 'flex', alignItems: 'center', gap: 4 }}> <div className="text-content-faint" style={{ fontSize: 10, display: 'flex', alignItems: 'center', gap: 4 }}>
<MapPin size={10} /> {Number(bucketForm.lat).toFixed(4)}, {Number(bucketForm.lng).toFixed(4)} <MapPin size={10} /> {Number(bucketForm.lat).toFixed(4)}, {Number(bucketForm.lng).toFixed(4)}
</div> </div>
)} )}
@@ -702,7 +615,8 @@ function SidebarContent({ data, stats, countries, selectedCountry, countryDetail
</div> </div>
<div style={{ display: 'flex', gap: 6, justifyContent: 'flex-end' }}> <div style={{ display: 'flex', gap: 6, justifyContent: 'flex-end' }}>
<button onClick={() => { setShowBucketAdd(false); setBucketForm({ name: '', notes: '', lat: '', lng: '', target_date: '' }); setBucketSearch(''); setBucketSearchResults([]); setBucketPoiMonth(0); setBucketPoiYear(0) }} <button onClick={() => { setShowBucketAdd(false); setBucketForm({ name: '', notes: '', lat: '', lng: '', target_date: '' }); setBucketSearch(''); setBucketSearchResults([]); setBucketPoiMonth(0); setBucketPoiYear(0) }}
style={{ fontSize: 11, padding: '4px 10px', borderRadius: 6, border: '1px solid var(--border-primary)', background: 'none', cursor: 'pointer', fontFamily: 'inherit', color: 'var(--text-muted)' }}> className="border border-edge text-content-muted"
style={{ fontSize: 11, padding: '4px 10px', borderRadius: 6, background: 'none', cursor: 'pointer', fontFamily: 'inherit' }}>
{t('common.cancel')} {t('common.cancel')}
</button> </button>
<button onClick={onAddBucket} disabled={!bucketForm.name.trim()} <button onClick={onAddBucket} disabled={!bucketForm.name.trim()}
@@ -714,7 +628,8 @@ function SidebarContent({ data, stats, countries, selectedCountry, countryDetail
) : ( ) : (
<div style={{ padding: '4px 16px 8px' }}> <div style={{ padding: '4px 16px 8px' }}>
<button onClick={() => setShowBucketAdd(true)} <button onClick={() => setShowBucketAdd(true)}
style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 4, width: '100%', padding: '5px 0', borderRadius: 8, border: '1px dashed var(--border-primary)', background: 'none', fontSize: 11, color: tf, cursor: 'pointer', fontFamily: 'inherit' }}> className="border border-dashed border-edge"
style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 4, width: '100%', padding: '5px 0', borderRadius: 8, background: 'none', fontSize: 11, color: tf, cursor: 'pointer', fontFamily: 'inherit' }}>
<Plus size={11} /> {t('atlas.addPoi')} <Plus size={11} /> {t('atlas.addPoi')}
</button> </button>
</div> </div>
+9 -13
View File
@@ -21,16 +21,15 @@ export default function InAppNotificationsPage(): React.ReactElement {
{/* Header */} {/* Header */}
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
<div> <div>
<h1 className="text-xl font-semibold" style={{ color: 'var(--text-primary)' }}> <h1 className="text-xl font-semibold text-content">
{t('notifications.title')} {t('notifications.title')}
{unreadCount > 0 && ( {unreadCount > 0 && (
<span className="ml-2 px-2 py-0.5 rounded-full text-xs font-medium align-middle inline-flex items-center justify-center" <span className="ml-2 px-2 py-0.5 rounded-full text-xs font-medium align-middle inline-flex items-center justify-center bg-content text-surface">
style={{ background: 'var(--text-primary)', color: 'var(--bg-primary)' }}>
{unreadCount} {unreadCount}
</span> </span>
)} )}
</h1> </h1>
<p className="text-sm mt-0.5" style={{ color: 'var(--text-muted)' }}> <p className="text-sm mt-0.5 text-content-muted">
{total} {total === 1 ? 'notification' : 'notifications'} {total} {total === 1 ? 'notification' : 'notifications'}
</p> </p>
</div> </div>
@@ -40,8 +39,8 @@ export default function InAppNotificationsPage(): React.ReactElement {
{unreadCount > 0 && ( {unreadCount > 0 && (
<button <button
onClick={markAllRead} onClick={markAllRead}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm transition-colors" className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm transition-colors text-content-secondary"
style={{ background: 'var(--bg-hover)', color: 'var(--text-secondary)' }} style={{ background: 'var(--bg-hover)' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-tertiary)'} onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-tertiary)'}
onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-hover)'} onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-hover)'}
> >
@@ -86,19 +85,16 @@ export default function InAppNotificationsPage(): React.ReactElement {
</div> </div>
{/* Notification list */} {/* Notification list */}
<div <div className="rounded-xl border overflow-hidden border-edge bg-surface-card">
className="rounded-xl border overflow-hidden"
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)' }}
>
{isLoading && displayed.length === 0 ? ( {isLoading && displayed.length === 0 ? (
<div className="flex items-center justify-center py-16"> <div className="flex items-center justify-center py-16">
<Spinner className="w-6 h-6 border-2 border-slate-200 border-t-current" /> <Spinner className="w-6 h-6 border-2 border-slate-200 border-t-current" />
</div> </div>
) : displayed.length === 0 ? ( ) : displayed.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 px-4 text-center gap-3"> <div className="flex flex-col items-center justify-center py-16 px-4 text-center gap-3">
<Bell className="w-12 h-12" style={{ color: 'var(--text-faint)' }} /> <Bell className="w-12 h-12 text-content-faint" />
<p className="text-base font-medium" style={{ color: 'var(--text-muted)' }}>{t('notifications.empty')}</p> <p className="text-base font-medium text-content-muted">{t('notifications.empty')}</p>
<p className="text-sm" style={{ color: 'var(--text-faint)' }}>{t('notifications.emptyDescription')}</p> <p className="text-sm text-content-faint">{t('notifications.emptyDescription')}</p>
</div> </div>
) : ( ) : (
displayed.map(n => ( displayed.map(n => (
+10 -245
View File
@@ -7,7 +7,7 @@ import { useParams, useNavigate, useSearchParams } from 'react-router-dom'
import { useJourneyStore } from '../store/journeyStore' import { useJourneyStore } from '../store/journeyStore'
import { useAuthStore } from '../store/authStore' import { useAuthStore } from '../store/authStore'
import { useTranslation } from '../i18n' import { useTranslation } from '../i18n'
import { journeyApi, authApi, addonsApi, mapsApi } from '../api/client' import { journeyApi, addonsApi, mapsApi } from '../api/client'
import { addListener, removeListener } from '../api/websocket' import { addListener, removeListener } from '../api/websocket'
import Navbar from '../components/Layout/Navbar' import Navbar from '../components/Layout/Navbar'
import JourneyMap from '../components/Journey/JourneyMapAuto' import JourneyMap from '../components/Journey/JourneyMapAuto'
@@ -16,10 +16,12 @@ import type { JourneyMapAutoHandle as JourneyMapHandle } from '../components/Jou
import JournalBody from '../components/Journey/JournalBody' import JournalBody from '../components/Journey/JournalBody'
import MarkdownToolbar from '../components/Journey/MarkdownToolbar' import MarkdownToolbar from '../components/Journey/MarkdownToolbar'
import PhotoLightbox from '../components/Journey/PhotoLightbox' import PhotoLightbox from '../components/Journey/PhotoLightbox'
import ContributorInviteDialog from '../components/Journey/ContributorInviteDialog'
import JourneyShareSection from '../components/Journey/JourneyShareSection'
import { useToast } from '../components/shared/Toast' import { useToast } from '../components/shared/Toast'
import ConfirmDialog from '../components/shared/ConfirmDialog' import ConfirmDialog from '../components/shared/ConfirmDialog'
import { import {
ArrowLeft, RefreshCw, MoreHorizontal, Share2, Download, List, Grid, MapPin, Link, Copy, ArrowLeft, RefreshCw, MoreHorizontal, Share2, Download, List, Grid, MapPin, Copy,
Clock, Package, Image, ChevronRight, Clock, Package, Image, ChevronRight,
UserPlus, Plus, Minus, Calendar, Camera, BookOpen, X, Check, ImagePlus, Trash2, Pencil, UserPlus, Plus, Minus, Calendar, Camera, BookOpen, X, Check, ImagePlus, Trash2, Pencil,
Laugh, Smile, Meh, Annoyed, Frown, Laugh, Smile, Meh, Annoyed, Frown,
@@ -939,6 +941,7 @@ function GalleryView({ entries, gallery, journeyId, userId, trips, onPhotoClick,
onClose={() => setShowPicker(false)} onClose={() => setShowPicker(false)}
onAdd={async (groups, entryId) => { onAdd={async (groups, entryId) => {
let added = 0 let added = 0
let anyFailed = false
for (const group of groups) { for (const group of groups) {
try { try {
if (entryId) { if (entryId) {
@@ -948,11 +951,15 @@ function GalleryView({ entries, gallery, journeyId, userId, trips, onPhotoClick,
const result = await journeyApi.addProviderPhotosToGallery(journeyId, pickerProvider!, group.assetIds, group.passphrase) const result = await journeyApi.addProviderPhotosToGallery(journeyId, pickerProvider!, group.assetIds, group.passphrase)
added += result.added || 0 added += result.added || 0
} }
} catch {} } catch {
anyFailed = true
}
} }
if (added > 0) { if (added > 0) {
toast.success(t('journey.photosAdded', { count: added })) toast.success(t('journey.photosAdded', { count: added }))
onRefresh() onRefresh()
} else if (anyFailed) {
toast.error(t('common.error'))
} }
setShowPicker(false) setShowPicker(false)
}} }}
@@ -2524,250 +2531,8 @@ function AddTripDialog({ journeyId, existingTripIds, onClose, onAdded }: {
) )
} }
// ── Contributor Invite Dialog ─────────────────────────────────────────────
function ContributorInviteDialog({ journeyId, existingUserIds, onClose, onInvited }: {
journeyId: number
existingUserIds: number[]
onClose: () => void
onInvited: () => void
}) {
const { t } = useTranslation()
const [users, setUsers] = useState<{ id: number; username: string; email: string; avatar?: string | null }[]>([])
const [search, setSearch] = useState('')
const [selectedUserId, setSelectedUserId] = useState<number | null>(null)
const [role, setRole] = useState<'editor' | 'viewer'>('viewer')
const [sending, setSending] = useState(false)
const toast = useToast()
useEffect(() => {
authApi.listUsers().then(d => setUsers(d.users || [])).catch(() => {})
}, [])
const filtered = users.filter(u => {
if (existingUserIds.includes(u.id)) return false
if (!search) return true
const q = search.toLowerCase()
return u.username.toLowerCase().includes(q) || u.email.toLowerCase().includes(q)
})
const handleInvite = async () => {
if (!selectedUserId) return
setSending(true)
try {
await journeyApi.addContributor(journeyId, selectedUserId, role)
toast.success(t('journey.contributors.added'))
onInvited()
} catch {
toast.error(t('journey.contributors.addFailed'))
} finally {
setSending(false)
}
}
return (
<div className="fixed inset-0 z-[200] flex items-center justify-center p-5" style={{ background: 'rgba(9,9,11,0.75)' }}>
<div className="bg-white dark:bg-zinc-900 rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] max-w-[420px] w-full flex flex-col overflow-hidden">
<div className="flex items-center justify-between px-6 py-4 border-b border-zinc-200 dark:border-zinc-700">
<h2 className="text-[16px] font-bold text-zinc-900 dark:text-white">{t('journey.contributors.invite')}</h2>
<button onClick={onClose} className="w-8 h-8 rounded-lg flex items-center justify-center text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800">
<X size={16} />
</button>
</div>
<div className="px-6 py-5 flex flex-col gap-4">
{/* Search */}
<div>
<label className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500 block mb-1.5">{t('journey.contributors.searchUser')}</label>
<input
value={search}
onChange={e => setSearch(e.target.value)}
placeholder={t('journey.contributors.searchPlaceholder')}
className="w-full px-3 py-2 border border-zinc-200 dark:border-zinc-700 rounded-lg text-[13px] bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white outline-none focus:border-zinc-400 dark:focus:border-zinc-500"
/>
</div>
{/* User list */}
<div className="max-h-[200px] overflow-y-auto flex flex-col gap-1">
{filtered.length === 0 && (
<p className="text-[12px] text-zinc-400 text-center py-4">{t('journey.contributors.noUsers')}</p>
)}
{filtered.map(u => (
<div
key={u.id}
onClick={() => setSelectedUserId(u.id)}
className={`flex items-center gap-2.5 p-2.5 rounded-lg cursor-pointer transition-all ${
selectedUserId === u.id
? 'bg-zinc-100 dark:bg-zinc-800 border border-zinc-900 dark:border-white'
: 'hover:bg-zinc-50 dark:hover:bg-zinc-800 border border-transparent'
}`}
>
<div className="w-8 h-8 rounded-full bg-zinc-200 dark:bg-zinc-700 text-zinc-600 dark:text-zinc-300 flex items-center justify-center text-[12px] font-semibold">
{u.username[0].toUpperCase()}
</div>
<div className="flex-1 min-w-0">
<div className="text-[13px] font-medium text-zinc-900 dark:text-white">{u.username}</div>
<div className="text-[11px] text-zinc-500 truncate">{u.email}</div>
</div>
{selectedUserId === u.id && (
<div className="w-5 h-5 rounded-full bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 flex items-center justify-center">
<Check size={12} />
</div>
)}
</div>
))}
</div>
{/* Role selector */}
<div>
<label className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500 block mb-2">{t('journey.invite.role')}</label>
<div className="flex gap-2">
{(['viewer', 'editor'] as const).map(r => (
<button
key={r}
onClick={() => setRole(r)}
className={`flex-1 py-2 rounded-lg text-[12px] font-medium border transition-all ${
role === r
? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 border-zinc-900 dark:border-white'
: 'border-zinc-200 dark:border-zinc-700 text-zinc-500 hover:border-zinc-400'
}`}
>
{t(`journey.invite.${r}`)}
</button>
))}
</div>
</div>
</div>
<div className="flex items-center justify-end gap-2 px-6 py-4 border-t border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/50">
<button onClick={onClose} className="px-3.5 py-2 rounded-lg border border-zinc-200 dark:border-zinc-600 text-[13px] font-medium text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-700">
{t('common.cancel')}
</button>
<button
onClick={handleInvite}
disabled={!selectedUserId || sending}
className="px-3.5 py-2 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[13px] font-medium hover:bg-zinc-800 dark:hover:bg-zinc-100 disabled:opacity-40 disabled:cursor-not-allowed"
>
{sending ? t('journey.invite.inviting') : t('journey.invite.invite')}
</button>
</div>
</div>
</div>
)
}
// ── Journey Settings Dialog ─────────────────────────────────────────────── // ── Journey Settings Dialog ───────────────────────────────────────────────
// ── Journey Share Section ─────────────────────────────────────────────────
function JourneyShareSection({ journeyId }: { journeyId: number }) {
const { t } = useTranslation()
const [link, setLink] = useState<{ token: string; share_timeline: boolean; share_gallery: boolean; share_map: boolean } | null>(null)
const [loading, setLoading] = useState(true)
const [copied, setCopied] = useState(false)
const toast = useToast()
useEffect(() => {
journeyApi.getShareLink(journeyId).then(d => setLink(d.link || null)).catch(() => {}).finally(() => setLoading(false))
}, [journeyId])
const createLink = async () => {
try {
const res = await journeyApi.createShareLink(journeyId, { share_timeline: true, share_gallery: true, share_map: true })
setLink({ token: res.token, share_timeline: true, share_gallery: true, share_map: true })
toast.success(t('journey.share.linkCreated'))
} catch { toast.error(t('journey.share.createFailed')) }
}
const togglePerm = async (key: 'share_timeline' | 'share_gallery' | 'share_map') => {
if (!link) return
const updated = { ...link, [key]: !link[key] }
setLink(updated)
try {
await journeyApi.createShareLink(journeyId, { share_timeline: updated.share_timeline, share_gallery: updated.share_gallery, share_map: updated.share_map })
} catch { setLink(link); toast.error(t('journey.share.updateFailed')) }
}
const deleteLink = async () => {
try {
await journeyApi.deleteShareLink(journeyId)
setLink(null)
toast.success(t('journey.share.linkDeleted'))
} catch { toast.error(t('journey.share.deleteFailed')) }
}
const shareUrl = link ? `${window.location.origin}/public/journey/${link.token}` : ''
const copyLink = () => {
navigator.clipboard.writeText(shareUrl)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
if (loading) return null
return (
<div>
<label className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500 block mb-2">{t('journey.share.publicShare')}</label>
{!link ? (
<button
onClick={createLink}
className="w-full flex items-center justify-center gap-1.5 py-2.5 rounded-lg border border-dashed border-zinc-300 dark:border-zinc-600 text-[12px] font-medium text-zinc-500 hover:border-zinc-400 hover:text-zinc-700 dark:hover:border-zinc-500 dark:hover:text-zinc-300 transition-colors"
>
<Link size={14} /> {t('journey.share.createLink')}
</button>
) : (
<div className="flex flex-col gap-3">
{/* URL + Copy */}
<div className="flex items-center gap-2 p-2.5 rounded-lg bg-zinc-50 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700">
<Link size={13} className="text-zinc-400 flex-shrink-0" />
<span className="flex-1 text-[11px] text-zinc-600 dark:text-zinc-400 truncate">{shareUrl}</span>
<button
onClick={copyLink}
className="flex-shrink-0 px-2.5 py-1 rounded-md bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[11px] font-medium hover:bg-zinc-700 dark:hover:bg-zinc-200"
>
{copied ? t('journey.share.copied') : t('journey.share.copy')}
</button>
</div>
{/* Permission toggles */}
<div className="flex flex-col gap-1.5">
{[
{ key: 'share_timeline' as const, label: t('journey.share.timeline'), icon: List },
{ key: 'share_gallery' as const, label: t('journey.share.gallery'), icon: Grid },
{ key: 'share_map' as const, label: t('journey.share.map'), icon: MapPin },
].map(({ key, label, icon: Icon }) => (
<button
key={key}
onClick={() => togglePerm(key)}
className={`flex items-center gap-2.5 px-3 py-2 rounded-lg border text-[12px] font-medium transition-all ${
link[key]
? 'border-zinc-900 dark:border-white bg-zinc-900 dark:bg-white text-white dark:text-zinc-900'
: 'border-zinc-200 dark:border-zinc-700 text-zinc-500 hover:border-zinc-400'
}`}
>
<Icon size={13} />
{label}
{link[key] && <Check size={12} className="ml-auto" />}
</button>
))}
</div>
{/* Delete link */}
<button
onClick={deleteLink}
className="text-[11px] font-medium text-red-500 hover:text-red-600 self-start"
>
{t('share.deleteLink')}
</button>
</div>
)}
</div>
)
}
function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite, onRefresh }: { function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite, onRefresh }: {
journey: JourneyDetail journey: JourneyDetail
onClose: () => void onClose: () => void
+6 -6
View File
@@ -86,29 +86,29 @@ export default function JourneyPage() {
{/* Header — desktop (unified toolbar) */} {/* Header — desktop (unified toolbar) */}
<div className="hidden md:block px-8 pt-10 pb-7"> <div className="hidden md:block px-8 pt-10 pb-7">
<div style={{ <div className="bg-surface-tertiary border border-edge" style={{
background: 'var(--bg-tertiary)', borderRadius: 18, borderRadius: 18,
border: '1px solid var(--border-primary)',
boxShadow: '0 1px 3px rgba(0,0,0,0.06), 0 1px 2px rgba(0,0,0,0.04)', boxShadow: '0 1px 3px rgba(0,0,0,0.06), 0 1px 2px rgba(0,0,0,0.04)',
padding: '14px 16px 14px 22px', padding: '14px 16px 14px 22px',
display: 'flex', alignItems: 'center', gap: 16, flexWrap: 'wrap', display: 'flex', alignItems: 'center', gap: 16, flexWrap: 'wrap',
}}> }}>
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 600, color: 'var(--text-primary)', letterSpacing: '-0.01em', flexShrink: 0 }}> <h2 className="text-content" style={{ margin: 0, fontSize: 18, fontWeight: 600, letterSpacing: '-0.01em', flexShrink: 0 }}>
{t('journey.title')} {t('journey.title')}
</h2> </h2>
<div style={{ width: 1, height: 22, background: 'var(--border-faint)', flexShrink: 0 }} /> <div style={{ width: 1, height: 22, background: 'var(--border-faint)', flexShrink: 0 }} />
<span style={{ fontSize: 13, color: 'var(--text-muted)' }}> <span className="text-content-muted" style={{ fontSize: 13 }}>
{t('journey.frontpage.subtitle')} {t('journey.frontpage.subtitle')}
</span> </span>
<div style={{ display: 'inline-flex', gap: 6, alignItems: 'center', marginLeft: 'auto', flexShrink: 0 }}> <div style={{ display: 'inline-flex', gap: 6, alignItems: 'center', marginLeft: 'auto', flexShrink: 0 }}>
<button <button
onClick={() => openCreateModal()} onClick={() => openCreateModal()}
className="bg-accent text-accent-text"
style={{ 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, flexShrink: 0,
marginLeft: 2, marginLeft: 2,
transition: 'opacity 0.15s ease', transition: 'opacity 0.15s ease',
}} }}
+4 -4
View File
@@ -36,12 +36,12 @@ export default function SettingsPage(): React.ReactElement {
<div className="w-full px-4 sm:px-6 lg:px-8 py-8"> <div className="w-full px-4 sm:px-6 lg:px-8 py-8">
{/* Header */} {/* Header */}
<div className="flex items-center gap-3 mb-6"> <div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 rounded-xl flex items-center justify-center" style={{ background: 'var(--bg-tertiary)' }}> <div className="w-10 h-10 rounded-xl flex items-center justify-center bg-surface-tertiary">
<Settings className="w-5 h-5" style={{ color: 'var(--text-secondary)' }} /> <Settings className="w-5 h-5 text-content-secondary" />
</div> </div>
<div> <div>
<h1 className="text-2xl font-bold" style={{ color: 'var(--text-primary)' }}>{t('settings.title')}</h1> <h1 className="text-2xl font-bold text-content">{t('settings.title')}</h1>
<p className="text-sm" style={{ color: 'var(--text-muted)' }}>{t('settings.subtitle')}</p> <p className="text-sm text-content-muted">{t('settings.subtitle')}</p>
</div> </div>
</div> </div>
+7 -7
View File
@@ -71,7 +71,7 @@ export default function SharedTripPage() {
const center = mapPlaces.length > 0 ? [mapPlaces[0].lat, mapPlaces[0].lng] : [48.85, 2.35] const center = mapPlaces.length > 0 ? [mapPlaces[0].lat, mapPlaces[0].lng] : [48.85, 2.35]
return ( return (
<div style={{ minHeight: '100vh', background: 'var(--bg-secondary, #f3f4f6)', fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }}> <div className="bg-surface-secondary" style={{ minHeight: '100vh', fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }}>
{/* Header */} {/* Header */}
<div style={{ background: 'linear-gradient(135deg, #000 0%, #0f172a 50%, #1e293b 100%)', color: 'white', padding: '32px 20px 28px', textAlign: 'center', position: 'relative' }}> <div style={{ background: 'linear-gradient(135deg, #000 0%, #0f172a 50%, #1e293b 100%)', color: 'white', padding: '32px 20px 28px', textAlign: 'center', position: 'relative' }}>
{/* Cover image background */} {/* Cover image background */}
@@ -187,7 +187,7 @@ export default function SharedTripPage() {
}) })
return ( return (
<div key={day.id} style={{ background: 'var(--bg-card, white)', borderRadius: 14, overflow: 'hidden', border: '1px solid var(--border-faint, #e5e7eb)' }}> <div key={day.id} className="bg-surface-card border border-edge-faint" style={{ borderRadius: 14, overflow: 'hidden' }}>
<div onClick={() => setSelectedDay(selectedDay === day.id ? null : day.id)} <div onClick={() => setSelectedDay(selectedDay === day.id ? null : day.id)}
style={{ padding: '12px 16px', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 10 }}> style={{ padding: '12px 16px', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 10 }}>
<div style={{ width: 28, height: 28, borderRadius: '50%', background: selectedDay === day.id ? '#111827' : '#f3f4f6', color: selectedDay === day.id ? 'white' : '#6b7280', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 12, fontWeight: 700, flexShrink: 0 }}>{di + 1}</div> <div style={{ width: 28, height: 28, borderRadius: '50%', background: selectedDay === day.id ? '#111827' : '#f3f4f6', color: selectedDay === day.id ? 'white' : '#6b7280', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 12, fontWeight: 700, flexShrink: 0 }}>{di + 1}</div>
@@ -271,7 +271,7 @@ export default function SharedTripPage() {
const time = rTime ?? '' const time = rTime ?? ''
const date = rDate ? new Date(rDate + 'T00:00:00Z').toLocaleDateString(locale, { day: 'numeric', month: 'short', timeZone: 'UTC' }) : '' const date = rDate ? new Date(rDate + 'T00:00:00Z').toLocaleDateString(locale, { day: 'numeric', month: 'short', timeZone: 'UTC' }) : ''
return ( return (
<div key={r.id} style={{ background: 'var(--bg-card, white)', borderRadius: 10, padding: '12px 16px', border: '1px solid var(--border-faint, #e5e7eb)', display: 'flex', alignItems: 'center', gap: 12 }}> <div key={r.id} className="bg-surface-card border border-edge-faint" style={{ borderRadius: 10, padding: '12px 16px', display: 'flex', alignItems: 'center', gap: 12 }}>
<div style={{ width: 32, height: 32, borderRadius: '50%', background: '#f3f4f6', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}> <div style={{ width: 32, height: 32, borderRadius: '50%', background: '#f3f4f6', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<TIcon size={15} color="#6b7280" /> <TIcon size={15} color="#6b7280" />
</div> </div>
@@ -296,7 +296,7 @@ export default function SharedTripPage() {
{/* Packing */} {/* Packing */}
{activeTab === 'packing' && (packing || []).length > 0 && ( {activeTab === 'packing' && (packing || []).length > 0 && (
<div style={{ background: 'var(--bg-card, white)', borderRadius: 14, border: '1px solid var(--border-faint, #e5e7eb)', overflow: 'hidden' }}> <div className="bg-surface-card border border-edge-faint" style={{ borderRadius: 14, overflow: 'hidden' }}>
{Object.entries((packing || []).reduce((g: any, i: any) => { const c = i.category || t('shared.other'); (g[c] = g[c] || []).push(i); return g }, {})).map(([cat, items]: [string, any]) => ( {Object.entries((packing || []).reduce((g: any, i: any) => { const c = i.category || t('shared.other'); (g[c] = g[c] || []).push(i); return g }, {})).map(([cat, items]: [string, any]) => (
<div key={cat}> <div key={cat}>
<div style={{ padding: '8px 16px', background: '#f9fafb', fontSize: 11, fontWeight: 700, color: '#6b7280', textTransform: 'uppercase', letterSpacing: '0.05em', borderBottom: '1px solid #f3f4f6' }}>{cat}</div> <div style={{ padding: '8px 16px', background: '#f9fafb', fontSize: 11, fontWeight: 700, color: '#6b7280', textTransform: 'uppercase', letterSpacing: '0.05em', borderBottom: '1px solid #f3f4f6' }}>{cat}</div>
@@ -323,7 +323,7 @@ export default function SharedTripPage() {
</div> </div>
{/* By category */} {/* By category */}
{Object.entries(grouped).map(([cat, items]: [string, any]) => ( {Object.entries(grouped).map(([cat, items]: [string, any]) => (
<div key={cat} style={{ background: 'var(--bg-card, white)', borderRadius: 12, border: '1px solid var(--border-faint, #e5e7eb)', overflow: 'hidden' }}> <div key={cat} className="bg-surface-card border border-edge-faint" style={{ borderRadius: 12, overflow: 'hidden' }}>
<div style={{ padding: '10px 16px', background: '#f9fafb', display: 'flex', justifyContent: 'space-between', alignItems: 'center', borderBottom: '1px solid #f3f4f6' }}> <div style={{ padding: '10px 16px', background: '#f9fafb', display: 'flex', justifyContent: 'space-between', alignItems: 'center', borderBottom: '1px solid #f3f4f6' }}>
<span style={{ fontSize: 12, fontWeight: 700, color: '#374151' }}>{cat}</span> <span style={{ fontSize: 12, fontWeight: 700, color: '#374151' }}>{cat}</span>
<span style={{ fontSize: 12, fontWeight: 600, color: '#6b7280' }}>{items.reduce((s: number, i: any) => s + (parseFloat(i.total_price) || 0), 0).toLocaleString(locale, { minimumFractionDigits: 2 })} {trip.currency || ''}</span> <span style={{ fontSize: 12, fontWeight: 600, color: '#6b7280' }}>{items.reduce((s: number, i: any) => s + (parseFloat(i.total_price) || 0), 0).toLocaleString(locale, { minimumFractionDigits: 2 })} {trip.currency || ''}</span>
@@ -342,7 +342,7 @@ export default function SharedTripPage() {
{/* Collab Chat */} {/* Collab Chat */}
{activeTab === 'collab' && (collab || []).length > 0 && ( {activeTab === 'collab' && (collab || []).length > 0 && (
<div style={{ background: 'var(--bg-card, white)', borderRadius: 14, border: '1px solid var(--border-faint, #e5e7eb)', overflow: 'hidden' }}> <div className="bg-surface-card border border-edge-faint" style={{ borderRadius: 14, overflow: 'hidden' }}>
<div style={{ padding: '12px 16px', background: '#f9fafb', borderBottom: '1px solid #f3f4f6', display: 'flex', alignItems: 'center', gap: 8 }}> <div style={{ padding: '12px 16px', background: '#f9fafb', borderBottom: '1px solid #f3f4f6', display: 'flex', alignItems: 'center', gap: 8 }}>
<MessageCircle size={14} color="#6b7280" /> <MessageCircle size={14} color="#6b7280" />
<span style={{ fontSize: 12, fontWeight: 700, color: '#374151' }}>{t('shared.tabChat')} · {(collab || []).length} {t('shared.messages')}</span> <span style={{ fontSize: 12, fontWeight: 700, color: '#374151' }}>{t('shared.tabChat')} · {(collab || []).length} {t('shared.messages')}</span>
@@ -379,7 +379,7 @@ export default function SharedTripPage() {
{/* Footer */} {/* Footer */}
<div style={{ textAlign: 'center', padding: '40px 0 20px' }}> <div style={{ textAlign: 'center', padding: '40px 0 20px' }}>
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 8, padding: '8px 16px', borderRadius: 20, background: 'var(--bg-card, white)', border: '1px solid var(--border-faint, #e5e7eb)', boxShadow: '0 1px 3px rgba(0,0,0,0.04)' }}> <div className="bg-surface-card border border-edge-faint" style={{ display: 'inline-flex', alignItems: 'center', gap: 8, padding: '8px 16px', borderRadius: 20, boxShadow: '0 1px 3px rgba(0,0,0,0.04)' }}>
<img src="/icons/icon.svg" alt="TREK" width="18" height="18" style={{ borderRadius: 4 }} /> <img src="/icons/icon.svg" alt="TREK" width="18" height="18" style={{ borderRadius: 4 }} />
<span style={{ fontSize: 11, color: '#9ca3af' }}>{t('shared.sharedVia')} <strong style={{ color: '#6b7280' }}>TREK</strong></span> <span style={{ fontSize: 11, color: '#9ca3af' }}>{t('shared.sharedVia')} <strong style={{ color: '#6b7280' }}>TREK</strong></span>
</div> </div>
+21 -21
View File
@@ -61,15 +61,15 @@ function ListsContainer({ tripId, packingItems, todoItems }: { tripId: number; p
return ( return (
<div> <div>
<div style={{ padding: '24px 28px 0' }} className="max-md:!px-4 max-md:!pt-4"> <div style={{ padding: '24px 28px 0' }} className="max-md:!px-4 max-md:!pt-4">
<div style={{ <div className="bg-surface-tertiary" style={{
background: 'var(--bg-tertiary)', borderRadius: 18, borderRadius: 18,
padding: '14px 16px 14px 22px', padding: '14px 16px 14px 22px',
display: 'flex', alignItems: 'center', gap: 16, flexWrap: 'wrap', display: 'flex', alignItems: 'center', gap: 16, flexWrap: 'wrap',
}}> }}>
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 600, color: 'var(--text-primary)', letterSpacing: '-0.01em', flexShrink: 0 }}> <h2 className="text-content" style={{ margin: 0, fontSize: 18, fontWeight: 600, letterSpacing: '-0.01em', flexShrink: 0 }}>
{t('trip.tabs.lists')} {t('trip.tabs.lists')}
</h2> </h2>
<div className="hidden md:block" style={{ width: 1, height: 22, background: 'var(--border-faint)', flexShrink: 0 }} /> <div className="hidden md:block bg-edge-faint" style={{ width: 1, height: 22, flexShrink: 0 }} />
<div style={{ display: 'inline-flex', gap: 4, flexWrap: 'wrap', flex: 1, minWidth: 0 }}> <div style={{ display: 'inline-flex', gap: 4, flexWrap: 'wrap', flex: 1, minWidth: 0 }}>
{tabs.map(tab => { {tabs.map(tab => {
const active = subTab === tab.id const active = subTab === tab.id
@@ -205,9 +205,9 @@ export default function TripPlannerPage(): React.ReactElement | null {
if (isLoading || !splashDone) { if (isLoading || !splashDone) {
return ( return (
<div style={{ <div className="bg-surface" style={{
minHeight: '100vh', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', minHeight: '100vh', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
background: 'var(--bg-primary)', ...fontStyle, ...fontStyle,
}}> }}>
<style>{` <style>{`
@keyframes dotPulse { @keyframes dotPulse {
@@ -227,16 +227,16 @@ export default function TripPlannerPage(): React.ReactElement | null {
height={64} height={64}
/> />
</div> </div>
<div style={{ fontSize: 20, fontWeight: 700, color: 'var(--text-primary)', letterSpacing: '-0.3px', marginBottom: 6, animation: 'fadeInUp 0.5s ease-out' }}> <div className="text-content" style={{ fontSize: 20, fontWeight: 700, letterSpacing: '-0.3px', marginBottom: 6, animation: 'fadeInUp 0.5s ease-out' }}>
{trip?.title || 'TREK'} {trip?.title || 'TREK'}
</div> </div>
<div style={{ fontSize: 12, color: 'var(--text-faint)', fontWeight: 500, letterSpacing: '2px', textTransform: 'uppercase', marginBottom: 32, animation: 'fadeInUp 0.5s ease-out 0.1s both' }}> <div className="text-content-faint" style={{ fontSize: 12, fontWeight: 500, letterSpacing: '2px', textTransform: 'uppercase', marginBottom: 32, animation: 'fadeInUp 0.5s ease-out 0.1s both' }}>
{t('trip.loadingPhotos')} {t('trip.loadingPhotos')}
</div> </div>
<div style={{ display: 'flex', gap: 6 }}> <div style={{ display: 'flex', gap: 6 }}>
{[0, 1, 2].map(i => ( {[0, 1, 2].map(i => (
<div key={i} style={{ <div key={i} className="bg-content-muted" style={{
width: 8, height: 8, borderRadius: '50%', background: 'var(--text-muted)', width: 8, height: 8, borderRadius: '50%',
animation: `dotPulse 1.4s ease-in-out ${i * 0.2}s infinite`, animation: `dotPulse 1.4s ease-in-out ${i * 0.2}s infinite`,
}} /> }} />
))} ))}
@@ -250,14 +250,12 @@ export default function TripPlannerPage(): React.ReactElement | null {
<div style={{ position: 'fixed', inset: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden', ...fontStyle }}> <div style={{ position: 'fixed', inset: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden', ...fontStyle }}>
<Navbar tripTitle={trip.title} tripId={tripId} showBack onBack={() => navigate('/dashboard')} onShare={() => setShowMembersModal(true)} /> <Navbar tripTitle={trip.title} tripId={tripId} showBack onBack={() => navigate('/dashboard')} onShare={() => setShowMembersModal(true)} />
<div style={{ <div className="bg-surface-elevated border-b border-edge-faint" style={{
position: 'fixed', top: 'var(--nav-h)', left: 0, right: 0, zIndex: 40, position: 'fixed', top: 'var(--nav-h)', left: 0, right: 0, zIndex: 40,
display: 'flex', alignItems: 'center', justifyContent: 'center', display: 'flex', alignItems: 'center', justifyContent: 'center',
padding: '0 12px', padding: '0 12px',
background: 'var(--bg-elevated)',
backdropFilter: 'blur(16px)', backdropFilter: 'blur(16px)',
WebkitBackdropFilter: 'blur(16px)', WebkitBackdropFilter: 'blur(16px)',
borderBottom: '1px solid var(--border-faint)',
height: 44, height: 44,
}}> }}>
<SlidingTabs <SlidingTabs
@@ -445,11 +443,13 @@ export default function TripPlannerPage(): React.ReactElement | null {
{activeTab === 'plan' && !mobileSidebarOpen && !showPlaceForm && !showMembersModal && !showReservationModal && ReactDOM.createPortal( {activeTab === 'plan' && !mobileSidebarOpen && !showPlaceForm && !showMembersModal && !showReservationModal && ReactDOM.createPortal(
<div className="flex md:hidden" style={{ position: 'fixed', top: 'calc(var(--nav-h) + 44px + 12px)', left: 12, right: 12, justifyContent: 'space-between', zIndex: 100, pointerEvents: 'none' }}> <div className="flex md:hidden" style={{ position: 'fixed', top: 'calc(var(--nav-h) + 44px + 12px)', left: 12, right: 12, justifyContent: 'space-between', zIndex: 100, pointerEvents: 'none' }}>
<button onClick={() => setMobileSidebarOpen('left')} <button onClick={() => setMobileSidebarOpen('left')}
style={{ pointerEvents: 'auto', background: 'var(--bg-card)', color: 'var(--text-primary)', backdropFilter: 'blur(12px)', border: '1px solid var(--border-primary)', borderRadius: 24, padding: '11px 24px', fontSize: 15, fontWeight: 600, cursor: 'pointer', boxShadow: '0 2px 12px rgba(0,0,0,0.15)', minHeight: 44, fontFamily: 'inherit', touchAction: 'manipulation' }}> className="bg-surface-card text-content border border-edge"
style={{ pointerEvents: 'auto', backdropFilter: 'blur(12px)', borderRadius: 24, padding: '11px 24px', fontSize: 15, fontWeight: 600, cursor: 'pointer', boxShadow: '0 2px 12px rgba(0,0,0,0.15)', minHeight: 44, fontFamily: 'inherit', touchAction: 'manipulation' }}>
{t('trip.mobilePlan')} {t('trip.mobilePlan')}
</button> </button>
<button onClick={() => setMobileSidebarOpen('right')} <button onClick={() => setMobileSidebarOpen('right')}
style={{ pointerEvents: 'auto', background: 'var(--bg-card)', color: 'var(--text-primary)', backdropFilter: 'blur(12px)', border: '1px solid var(--border-primary)', borderRadius: 24, padding: '11px 24px', fontSize: 15, fontWeight: 600, cursor: 'pointer', boxShadow: '0 2px 12px rgba(0,0,0,0.15)', minHeight: 44, fontFamily: 'inherit', touchAction: 'manipulation' }}> className="bg-surface-card text-content border border-edge"
style={{ pointerEvents: 'auto', backdropFilter: 'blur(12px)', borderRadius: 24, padding: '11px 24px', fontSize: 15, fontWeight: 600, cursor: 'pointer', boxShadow: '0 2px 12px rgba(0,0,0,0.15)', minHeight: 44, fontFamily: 'inherit', touchAction: 'manipulation' }}>
{t('trip.mobilePlaces')} {t('trip.mobilePlaces')}
</button> </button>
</div>, </div>,
@@ -520,7 +520,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
), ),
} }
})) }))
} catch {} } catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) }
}} }}
onUpdatePlace={async (placeId, data) => { try { await tripActions.updatePlace(tripId, placeId, data) } catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) } }} onUpdatePlace={async (placeId, data) => { try { await tripActions.updatePlace(tripId, placeId, data) } catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) } }}
leftWidth={(isMobile || window.innerWidth < 900) ? 0 : (leftCollapsed ? 0 : leftWidth)} leftWidth={(isMobile || window.innerWidth < 900) ? 0 : (leftCollapsed ? 0 : leftWidth)}
@@ -569,7 +569,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
), ),
} }
})) }))
} catch {} } catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) }
}} }}
onUpdatePlace={async (placeId, data) => { try { await tripActions.updatePlace(tripId, placeId, data) } catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) } }} onUpdatePlace={async (placeId, data) => { try { await tripActions.updatePlace(tripId, placeId, data) } catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) } }}
leftWidth={0} leftWidth={0}
@@ -582,10 +582,10 @@ export default function TripPlannerPage(): React.ReactElement | null {
{mobileSidebarOpen && ReactDOM.createPortal( {mobileSidebarOpen && ReactDOM.createPortal(
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.3)', zIndex: 9999 }} onClick={() => setMobileSidebarOpen(null)}> <div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.3)', zIndex: 9999 }} onClick={() => setMobileSidebarOpen(null)}>
<div style={{ position: 'absolute', top: 'var(--nav-h)', left: 0, right: 0, bottom: 0, background: 'var(--bg-card)', display: 'flex', flexDirection: 'column', overflow: 'hidden' }} onClick={e => e.stopPropagation()}> <div className="bg-surface-card" style={{ position: 'absolute', top: 'var(--nav-h)', left: 0, right: 0, bottom: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }} onClick={e => e.stopPropagation()}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '14px 16px', borderBottom: '1px solid var(--border-secondary)' }}> <div className="border-b border-edge-secondary" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '14px 16px' }}>
<span style={{ fontWeight: 600, fontSize: 14, color: 'var(--text-primary)' }}>{mobileSidebarOpen === 'left' ? t('trip.mobilePlan') : t('trip.mobilePlaces')}</span> <span className="text-content" style={{ fontWeight: 600, fontSize: 14 }}>{mobileSidebarOpen === 'left' ? t('trip.mobilePlan') : t('trip.mobilePlaces')}</span>
<button onClick={() => setMobileSidebarOpen(null)} style={{ background: 'var(--bg-tertiary)', border: 'none', borderRadius: '50%', width: 28, height: 28, cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--text-primary)' }}> <button onClick={() => setMobileSidebarOpen(null)} className="bg-surface-tertiary text-content" style={{ border: 'none', borderRadius: '50%', width: 28, height: 28, cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<X size={14} /> <X size={14} />
</button> </button>
</div> </div>
+40 -46
View File
@@ -24,7 +24,7 @@ export default function VacayPage(): React.ReactElement {
if (loading) { if (loading) {
return ( return (
<PageShell background="var(--bg-primary)" contentClassName="flex items-center justify-center" contentStyle={{ minHeight: 'calc(100vh - var(--nav-h))' }}> <PageShell background="var(--bg-primary)" contentClassName="flex items-center justify-center" contentStyle={{ minHeight: 'calc(100vh - var(--nav-h))' }}>
<div className="w-8 h-8 border-2 rounded-full animate-spin" style={{ borderColor: 'var(--border-primary)', borderTopColor: 'var(--text-primary)' }} /> <div className="w-8 h-8 border-2 rounded-full animate-spin border-edge border-t-content" />
</PageShell> </PageShell>
) )
} }
@@ -33,25 +33,25 @@ export default function VacayPage(): React.ReactElement {
const sidebarContent = ( const sidebarContent = (
<> <>
{/* Year Selector */} {/* Year Selector */}
<div className="rounded-xl border p-3" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}> <div className="rounded-xl border p-3 bg-surface-card border-edge">
<div className="flex items-center mb-2"> <div className="flex items-center mb-2">
<span className="text-[11px] font-medium uppercase tracking-wider" style={{ color: 'var(--text-faint)' }}>{t('vacay.year')}</span> <span className="text-[11px] font-medium uppercase tracking-wider text-content-faint">{t('vacay.year')}</span>
</div> </div>
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<button onClick={handleAddPrevYear} className="p-0.5 rounded transition-colors" style={{ color: 'var(--text-faint)' }} title={t('vacay.addPrevYear')}> <button onClick={handleAddPrevYear} className="p-0.5 rounded transition-colors text-content-faint" title={t('vacay.addPrevYear')}>
<Plus size={14} /> <Plus size={14} />
</button> </button>
<button onClick={() => { const idx = years.indexOf(selectedYear); if (idx > 0) setSelectedYear(years[idx - 1]) }} disabled={years.indexOf(selectedYear) <= 0} className="p-1 rounded-lg disabled:opacity-20 transition-colors" style={{ background: 'var(--bg-secondary)' }}> <button onClick={() => { const idx = years.indexOf(selectedYear); if (idx > 0) setSelectedYear(years[idx - 1]) }} disabled={years.indexOf(selectedYear) <= 0} className="p-1 rounded-lg disabled:opacity-20 transition-colors bg-surface-secondary">
<ChevronLeft size={16} style={{ color: 'var(--text-muted)' }} /> <ChevronLeft size={16} className="text-content-muted" />
</button> </button>
</div> </div>
<span className="text-xl font-bold tabular-nums" style={{ color: 'var(--text-primary)' }}>{selectedYear}</span> <span className="text-xl font-bold tabular-nums text-content">{selectedYear}</span>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<button onClick={() => { const idx = years.indexOf(selectedYear); if (idx < years.length - 1) setSelectedYear(years[idx + 1]) }} disabled={years.indexOf(selectedYear) >= years.length - 1} className="p-1 rounded-lg disabled:opacity-20 transition-colors" style={{ background: 'var(--bg-secondary)' }}> <button onClick={() => { const idx = years.indexOf(selectedYear); if (idx < years.length - 1) setSelectedYear(years[idx + 1]) }} disabled={years.indexOf(selectedYear) >= years.length - 1} className="p-1 rounded-lg disabled:opacity-20 transition-colors bg-surface-secondary">
<ChevronRight size={16} style={{ color: 'var(--text-muted)' }} /> <ChevronRight size={16} className="text-content-muted" />
</button> </button>
<button onClick={handleAddNextYear} className="p-0.5 rounded transition-colors" style={{ color: 'var(--text-faint)' }} title={t('vacay.addYear')}> <button onClick={handleAddNextYear} className="p-0.5 rounded transition-colors text-content-faint" title={t('vacay.addYear')}>
<Plus size={14} /> <Plus size={14} />
</button> </button>
</div> </div>
@@ -80,8 +80,8 @@ export default function VacayPage(): React.ReactElement {
{/* Legend */} {/* Legend */}
{(plan?.holidays_enabled || plan?.company_holidays_enabled || plan?.block_weekends) && ( {(plan?.holidays_enabled || plan?.company_holidays_enabled || plan?.block_weekends) && (
<div className="rounded-xl border p-3" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}> <div className="rounded-xl border p-3 bg-surface-card border-edge">
<span className="text-[11px] font-medium uppercase tracking-wider" style={{ color: 'var(--text-faint)' }}>{t('vacay.legend')}</span> <span className="text-[11px] font-medium uppercase tracking-wider text-content-faint">{t('vacay.legend')}</span>
<div className="mt-2 flex flex-wrap gap-x-3 gap-y-1.5"> <div className="mt-2 flex flex-wrap gap-x-3 gap-y-1.5">
{plan?.holidays_enabled && (plan?.holiday_calendars ?? []).length === 0 && ( {plan?.holidays_enabled && (plan?.holiday_calendars ?? []).length === 0 && (
<LegendItem color="#fecaca" label={t('vacay.publicHoliday')} /> <LegendItem color="#fecaca" label={t('vacay.publicHoliday')} />
@@ -105,23 +105,21 @@ export default function VacayPage(): React.ReactElement {
{/* Mobile + tablet header (filter toggle lives here) */} {/* Mobile + tablet header (filter toggle lives here) */}
<div className="lg:hidden flex items-center justify-between mb-4"> <div className="lg:hidden flex items-center justify-between mb-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-xl flex items-center justify-center" style={{ background: 'var(--bg-secondary)' }}> <div className="w-9 h-9 rounded-xl flex items-center justify-center bg-surface-secondary">
<CalendarDays size={18} style={{ color: 'var(--text-primary)' }} /> <CalendarDays size={18} className="text-content" />
</div> </div>
<h1 className="text-lg font-bold" style={{ color: 'var(--text-primary)' }}>{t('admin.addons.catalog.vacay.name')}</h1> <h1 className="text-lg font-bold text-content">{t('admin.addons.catalog.vacay.name')}</h1>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button <button
onClick={() => setShowMobileSidebar(true)} onClick={() => setShowMobileSidebar(true)}
className="lg:hidden flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm transition-colors" className="lg:hidden flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm transition-colors bg-surface-secondary text-content-muted"
style={{ background: 'var(--bg-secondary)', color: 'var(--text-muted)' }}
> >
<SlidersHorizontal size={14} /> <SlidersHorizontal size={14} />
</button> </button>
<button <button
onClick={() => setShowSettings(true)} onClick={() => setShowSettings(true)}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm transition-colors" className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm transition-colors bg-surface-secondary text-content-muted"
style={{ background: 'var(--bg-secondary)', color: 'var(--text-muted)' }}
> >
<Settings size={14} /> <Settings size={14} />
</button> </button>
@@ -130,28 +128,28 @@ export default function VacayPage(): React.ReactElement {
{/* Desktop header — unified toolbar (sidebar is always visible at this width) */} {/* Desktop header — unified toolbar (sidebar is always visible at this width) */}
<div className="hidden lg:block" style={{ marginBottom: 20 }}> <div className="hidden lg:block" style={{ marginBottom: 20 }}>
<div style={{ <div className="bg-surface-tertiary border border-edge" style={{
background: 'var(--bg-tertiary)', borderRadius: 18, borderRadius: 18,
border: '1px solid var(--border-primary)',
boxShadow: '0 1px 3px rgba(0,0,0,0.06), 0 1px 2px rgba(0,0,0,0.04)', boxShadow: '0 1px 3px rgba(0,0,0,0.06), 0 1px 2px rgba(0,0,0,0.04)',
padding: '14px 16px 14px 22px', padding: '14px 16px 14px 22px',
display: 'flex', alignItems: 'center', gap: 16, flexWrap: 'wrap', display: 'flex', alignItems: 'center', gap: 16, flexWrap: 'wrap',
}}> }}>
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 600, color: 'var(--text-primary)', letterSpacing: '-0.01em', flexShrink: 0 }}> <h2 className="text-content" style={{ margin: 0, fontSize: 18, fontWeight: 600, letterSpacing: '-0.01em', flexShrink: 0 }}>
{t('admin.addons.catalog.vacay.name')} {t('admin.addons.catalog.vacay.name')}
</h2> </h2>
<div style={{ width: 1, height: 22, background: 'var(--border-faint)', flexShrink: 0 }} /> <div className="bg-edge-faint" style={{ width: 1, height: 22, flexShrink: 0 }} />
<span style={{ fontSize: 13, color: 'var(--text-muted)' }}> <span className="text-content-muted" style={{ fontSize: 13 }}>
{t('vacay.subtitle')} {t('vacay.subtitle')}
</span> </span>
<div style={{ display: 'inline-flex', gap: 6, alignItems: 'center', marginLeft: 'auto', flexShrink: 0 }}> <div style={{ display: 'inline-flex', gap: 6, alignItems: 'center', marginLeft: 'auto', flexShrink: 0 }}>
<button <button
onClick={() => setShowSettings(true)} onClick={() => setShowSettings(true)}
className="bg-accent text-accent-text"
style={{ 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, flexShrink: 0,
marginLeft: 2, marginLeft: 2,
transition: 'opacity 0.15s ease', transition: 'opacity 0.15s ease',
}} }}
@@ -182,8 +180,8 @@ export default function VacayPage(): React.ReactElement {
{showMobileSidebar && ReactDOM.createPortal( {showMobileSidebar && ReactDOM.createPortal(
<div className="fixed inset-0 lg:hidden" style={{ zIndex: 99980 }}> <div className="fixed inset-0 lg:hidden" style={{ zIndex: 99980 }}>
<div className="absolute inset-0" style={{ background: 'rgba(0,0,0,0.4)' }} onClick={() => setShowMobileSidebar(false)} /> <div className="absolute inset-0" style={{ background: 'rgba(0,0,0,0.4)' }} onClick={() => setShowMobileSidebar(false)} />
<div className="absolute left-0 top-0 bottom-0 w-[280px] overflow-y-auto p-3 flex flex-col gap-3" <div className="absolute left-0 top-0 bottom-0 w-[280px] overflow-y-auto p-3 flex flex-col gap-3 bg-surface"
style={{ background: 'var(--bg-primary)', boxShadow: '4px 0 24px rgba(0,0,0,0.15)', animation: 'slideInLeft 0.2s ease-out' }}> style={{ boxShadow: '4px 0 24px rgba(0,0,0,0.15)', animation: 'slideInLeft 0.2s ease-out' }}>
{sidebarContent} {sidebarContent}
</div> </div>
</div>, </div>,
@@ -201,16 +199,16 @@ export default function VacayPage(): React.ReactElement {
<div className="flex gap-3 p-3 rounded-lg" style={{ background: 'rgba(239,68,68,0.08)', border: '1px solid rgba(239,68,68,0.15)' }}> <div className="flex gap-3 p-3 rounded-lg" style={{ background: 'rgba(239,68,68,0.08)', border: '1px solid rgba(239,68,68,0.15)' }}>
<AlertTriangle size={18} className="text-red-500 shrink-0 mt-0.5" /> <AlertTriangle size={18} className="text-red-500 shrink-0 mt-0.5" />
<div> <div>
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}> <p className="text-sm font-medium text-content">
{t('vacay.removeYearConfirm', { year: deleteYear })} {t('vacay.removeYearConfirm', { year: deleteYear })}
</p> </p>
<p className="text-xs mt-1" style={{ color: 'var(--text-muted)' }}> <p className="text-xs mt-1 text-content-muted">
{t('vacay.removeYearHint')} {t('vacay.removeYearHint')}
</p> </p>
</div> </div>
</div> </div>
<div className="flex gap-3 justify-end"> <div className="flex gap-3 justify-end">
<button onClick={() => setDeleteYear(null)} className="px-4 py-2 text-sm rounded-lg transition-colors" style={{ color: 'var(--text-muted)', border: '1px solid var(--border-primary)' }}> <button onClick={() => setDeleteYear(null)} className="px-4 py-2 text-sm rounded-lg transition-colors border text-content-muted border-edge">
{t('common.cancel')} {t('common.cancel')}
</button> </button>
<button onClick={async () => { await removeYear(deleteYear); setDeleteYear(null) }} className="px-4 py-2 text-sm bg-red-500 hover:bg-red-600 text-white rounded-lg transition-colors"> <button onClick={async () => { await removeYear(deleteYear); setDeleteYear(null) }} className="px-4 py-2 text-sm bg-red-500 hover:bg-red-600 text-white rounded-lg transition-colors">
@@ -225,18 +223,16 @@ 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="trek-modal-enter 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 bg-surface-card">
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 bg-surface-secondary text-content">
style={{ background: 'var(--bg-secondary)', color: 'var(--text-primary)' }}>
{inv.username?.[0]?.toUpperCase()} {inv.username?.[0]?.toUpperCase()}
</div> </div>
<h2 className="text-lg font-bold mb-1" style={{ color: 'var(--text-primary)' }}> <h2 className="text-lg font-bold mb-1 text-content">
{t('vacay.inviteTitle')} {t('vacay.inviteTitle')}
</h2> </h2>
<p className="text-sm" style={{ color: 'var(--text-muted)' }}> <p className="text-sm text-content-muted">
<span className="font-semibold" style={{ color: 'var(--text-primary)' }}>{inv.username}</span> {t('vacay.inviteWantsToFuse')} <span className="font-semibold text-content">{inv.username}</span> {t('vacay.inviteWantsToFuse')}
</p> </p>
</div> </div>
<div className="px-6 pb-4 space-y-2"> <div className="px-6 pb-4 space-y-2">
@@ -248,13 +244,11 @@ export default function VacayPage(): React.ReactElement {
</div> </div>
<div className="px-6 pb-6 flex gap-3"> <div className="px-6 pb-6 flex gap-3">
<button onClick={() => declineInvite(inv.plan_id)} <button onClick={() => declineInvite(inv.plan_id)}
className="flex-1 px-4 py-2.5 text-sm font-medium rounded-xl transition-colors" className="flex-1 px-4 py-2.5 text-sm font-medium rounded-xl transition-colors border text-content-muted border-edge">
style={{ color: 'var(--text-muted)', border: '1px solid var(--border-primary)' }}>
{t('vacay.decline')} {t('vacay.decline')}
</button> </button>
<button onClick={() => acceptInvite(inv.plan_id)} <button onClick={() => acceptInvite(inv.plan_id)}
className="flex-1 px-4 py-2.5 text-sm font-medium rounded-xl transition-colors" className="flex-1 px-4 py-2.5 text-sm font-medium rounded-xl transition-colors bg-content text-surface-card">
style={{ background: 'var(--text-primary)', color: 'var(--bg-card)' }}>
{t('vacay.acceptFusion')} {t('vacay.acceptFusion')}
</button> </button>
</div> </div>
@@ -276,9 +270,9 @@ export default function VacayPage(): React.ReactElement {
function InfoItem({ icon: Icon, text }: { icon: React.ComponentType<{ size?: number; className?: string; style?: React.CSSProperties }>; text: string }): React.ReactElement { function InfoItem({ icon: Icon, text }: { icon: React.ComponentType<{ size?: number; className?: string; style?: React.CSSProperties }>; text: string }): React.ReactElement {
return ( return (
<div className="flex items-start gap-3 px-3 py-2 rounded-lg" style={{ background: 'var(--bg-secondary)' }}> <div className="flex items-start gap-3 px-3 py-2 rounded-lg bg-surface-secondary">
<Icon size={15} className="shrink-0 mt-0.5" style={{ color: 'var(--text-muted)' }} /> <Icon size={15} className="shrink-0 mt-0.5 text-content-muted" />
<span className="text-xs" style={{ color: 'var(--text-primary)' }}>{text}</span> <span className="text-xs text-content">{text}</span>
</div> </div>
) )
} }
@@ -287,7 +281,7 @@ function LegendItem({ color, label }: { color: string; label: string }): React.R
return ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="w-4 h-3 rounded" style={{ background: color, border: `1px solid ${color}` }} /> <span className="w-4 h-3 rounded" style={{ background: color, border: `1px solid ${color}` }} />
<span className="text-[11px]" style={{ color: 'var(--text-muted)' }}>{label}</span> <span className="text-[11px] text-content-muted">{label}</span>
</div> </div>
) )
} }
@@ -0,0 +1,50 @@
import React from 'react'
import { ArrowUpCircle, ExternalLink, Download } from 'lucide-react'
import type { TranslationFn } from '../../types'
import type { UpdateInfo } from './adminModel'
interface AdminUpdateBannerProps {
updateInfo: UpdateInfo
t: TranslationFn
onHowTo: () => void
}
// The "new version available" banner shown at the top of the admin page.
// Purely presentational — extracted from AdminPage with identical markup.
export default function AdminUpdateBanner({ updateInfo, t, onHowTo }: AdminUpdateBannerProps): React.ReactElement {
return (
<div className="mb-6 p-4 rounded-xl border flex flex-col sm:flex-row items-start sm:items-center gap-4 bg-amber-50 dark:bg-amber-950/40 border-amber-300 dark:border-amber-700">
<div className="flex items-center gap-4 flex-1 min-w-0">
<div className="flex-shrink-0 w-10 h-10 rounded-full flex items-center justify-center bg-amber-500 dark:bg-amber-600">
<ArrowUpCircle className="w-5 h-5 text-white" />
</div>
<div>
<p className="text-sm font-semibold text-amber-900 dark:text-amber-200">{t('admin.update.available')}</p>
<p className="text-xs text-amber-700 dark:text-amber-400 mt-0.5">
{t('admin.update.text').replace('{version}', `v${updateInfo.latest}`).replace('{current}', `v${updateInfo.current}`)}
</p>
</div>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
{updateInfo.release_url && (
<a
href={updateInfo.release_url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 px-3 py-2 rounded-lg text-xs font-medium transition-colors text-amber-800 dark:text-amber-300 border border-amber-300 dark:border-amber-600 hover:bg-amber-100 dark:hover:bg-amber-900/50"
>
<ExternalLink className="w-3.5 h-3.5" />
{t('admin.update.button')}
</a>
)}
<button
onClick={onHowTo}
className="flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-semibold transition-colors bg-slate-900 dark:bg-white text-white dark:text-slate-900 hover:bg-slate-700 dark:hover:bg-gray-200"
>
<Download className="w-4 h-4" />
{t('admin.update.howTo')}
</button>
</div>
</div>
)
}
@@ -0,0 +1,152 @@
import React from 'react'
import { Search, X, ChevronRight } from 'lucide-react'
import type { TranslationFn } from '../../types'
type CountryOption = { code: string; label: string }
interface AtlasCountrySearchProps {
dark: boolean
t: TranslationFn
search: string
setSearch: (v: string) => void
results: CountryOption[]
setResults: (v: CountryOption[]) => void
open: boolean
setOpen: (v: boolean) => void
options: CountryOption[]
onSelect: (code: string) => void
}
// The floating country search box that overlays the globe (search input + results
// dropdown). Extracted from AtlasPage as a presentational sibling — behaviour and
// markup are byte-identical to the inline version it replaced.
export default function AtlasCountrySearch({
dark, t, search, setSearch, results, setResults, open, setOpen, options, onSelect,
}: AtlasCountrySearchProps): React.ReactElement {
return (
<div
className="absolute z-20 flex justify-center"
style={{ top: 'calc(env(safe-area-inset-top, 0px) + 14px)', left: 0, right: 0, pointerEvents: 'none' }}
>
<div style={{ width: 'min(520px, calc(100vw - 28px))', pointerEvents: 'auto' }}>
<div style={{
display: 'flex',
alignItems: 'center',
gap: 10,
padding: '10px 12px',
borderRadius: 16,
border: '1px solid ' + (dark ? 'rgba(255,255,255,0.10)' : 'rgba(0,0,0,0.08)'),
background: dark ? 'rgba(10,10,15,0.55)' : 'rgba(255,255,255,0.55)',
backdropFilter: 'blur(18px) saturate(180%)',
WebkitBackdropFilter: 'blur(18px) saturate(180%)',
boxShadow: dark ? '0 8px 26px rgba(0,0,0,0.25)' : '0 8px 26px rgba(0,0,0,0.10)',
}}>
<Search size={16} className="text-content-faint" style={{ flexShrink: 0 }} />
<input
value={search}
onChange={(e) => {
const raw = e.target.value
setSearch(raw)
const q = raw.trim().toLowerCase()
if (!q) {
setResults([])
setOpen(false)
return
}
const next = options
.filter(o => o.label.toLowerCase().includes(q) || o.code.toLowerCase() === q)
.slice(0, 8)
setResults(next)
setOpen(true)
}}
onFocus={() => {
if (results.length > 0) setOpen(true)
}}
onKeyDown={(e) => {
if (e.key === 'Escape') {
setOpen(false)
return
}
if (e.key === 'Enter') {
const first = results[0]
if (first) onSelect(first.code)
}
}}
placeholder={t('atlas.searchCountry')}
autoComplete="off"
spellCheck={false}
className="text-content"
style={{
flex: 1,
border: 'none',
outline: 'none',
background: 'transparent',
fontSize: 13,
fontFamily: 'inherit',
}}
/>
{search.trim() && (
<button
onClick={() => {
setSearch('')
setResults([])
setOpen(false)
}}
className="text-content-faint"
style={{ border: 'none', background: 'none', cursor: 'pointer', padding: 2, display: 'flex' }}
aria-label="Clear"
>
<X size={14} />
</button>
)}
</div>
{open && results.length > 0 && (
<div
style={{
marginTop: 8,
borderRadius: 14,
overflow: 'hidden',
border: '1px solid ' + (dark ? 'rgba(255,255,255,0.10)' : 'rgba(0,0,0,0.08)'),
background: dark ? 'rgba(10,10,15,0.75)' : 'rgba(255,255,255,0.75)',
backdropFilter: 'blur(18px) saturate(180%)',
WebkitBackdropFilter: 'blur(18px) saturate(180%)',
boxShadow: dark ? '0 12px 30px rgba(0,0,0,0.35)' : '0 12px 30px rgba(0,0,0,0.12)',
}}
onMouseLeave={() => setOpen(false)}
>
{results.map((r) => (
<button
key={r.code}
onClick={() => onSelect(r.code)}
style={{
width: '100%',
border: 'none',
background: 'transparent',
cursor: 'pointer',
padding: '10px 12px',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
fontFamily: 'inherit',
textAlign: 'left',
borderBottom: '1px solid ' + (dark ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.06)'),
}}
onMouseEnter={(e) => { (e.currentTarget as HTMLButtonElement).style.background = dark ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.05)' }}
onMouseLeave={(e) => { (e.currentTarget as HTMLButtonElement).style.background = 'transparent' }}
>
<span style={{ display: 'flex', alignItems: 'center', gap: 10, minWidth: 0 }}>
<img src={`https://flagcdn.com/w40/${r.code.toLowerCase()}.png`} alt={r.code} style={{ width: 28, height: 20, borderRadius: 4, objectFit: 'cover' }} />
<span className="text-content" style={{ fontSize: 13, fontWeight: 650, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{r.label}
</span>
</span>
<ChevronRight size={16} className="text-content-faint" style={{ flexShrink: 0 }} />
</button>
))}
</div>
)}
</div>
</div>
)
}
@@ -364,7 +364,7 @@ export function useTripPlanner() {
const fd = new FormData() const fd = new FormData()
fd.append('file', file) fd.append('file', file)
fd.append('place_id', editingPlace.id) fd.append('place_id', editingPlace.id)
try { await tripActions.addFile(tripId, fd) } catch {} try { await tripActions.addFile(tripId, fd) } catch { toast.error(t('files.uploadError')) }
} }
} }
toast.success(t('trip.toast.placeUpdated')) toast.success(t('trip.toast.placeUpdated'))
@@ -375,7 +375,7 @@ export function useTripPlanner() {
const fd = new FormData() const fd = new FormData()
fd.append('file', file) fd.append('file', file)
fd.append('place_id', place.id) fd.append('place_id', place.id)
try { await tripActions.addFile(tripId, fd) } catch {} try { await tripActions.addFile(tripId, fd) } catch { toast.error(t('files.uploadError')) }
} }
} }
toast.success(t('trip.toast.placeAdded')) toast.success(t('trip.toast.placeAdded'))