mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 21:31:46 +00:00
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:
@@ -158,16 +158,16 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="rounded-xl border overflow-hidden" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
|
||||
<div className="px-6 py-4 border-b" style={{ borderColor: 'var(--border-secondary)' }}>
|
||||
<h2 className="font-semibold" style={{ color: 'var(--text-primary)' }}>{t('admin.addons.title')}</h2>
|
||||
<p className="text-xs mt-1" style={{ color: 'var(--text-muted)', display: 'flex', alignItems: 'center', gap: 4, flexWrap: 'wrap' }}>
|
||||
<div className="rounded-xl border overflow-hidden bg-surface-card border-edge">
|
||||
<div className="px-6 py-4 border-b border-edge-secondary">
|
||||
<h2 className="font-semibold text-content">{t('admin.addons.title')}</h2>
|
||||
<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')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{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')}
|
||||
</div>
|
||||
) : (
|
||||
@@ -175,9 +175,9 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
|
||||
{/* Trip Addons */}
|
||||
{tripAddons.length > 0 && (
|
||||
<div>
|
||||
<div className="px-6 py-2.5 border-b flex items-center gap-2" style={{ background: 'var(--bg-secondary)', borderColor: 'var(--border-secondary)' }}>
|
||||
<Briefcase size={13} style={{ color: 'var(--text-muted)' }} />
|
||||
<span className="text-xs font-medium uppercase tracking-wider" style={{ color: 'var(--text-muted)' }}>
|
||||
<div className="px-6 py-2.5 border-b flex items-center gap-2 bg-surface-secondary border-edge-secondary">
|
||||
<Briefcase size={13} className="text-content-muted" />
|
||||
<span className="text-xs font-medium uppercase tracking-wider text-content-muted">
|
||||
{t('admin.addons.type.trip')} — {t('admin.addons.tripHint')}
|
||||
</span>
|
||||
</div>
|
||||
@@ -185,11 +185,11 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
|
||||
<div key={addon.id}>
|
||||
<AddonRow addon={addon} onToggle={handleToggle} t={t} />
|
||||
{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 }} />
|
||||
<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-xs mt-0.5" style={{ color: 'var(--text-faint)' }}>{t('admin.bagTracking.subtitle')}</div>
|
||||
<div className="text-sm font-medium text-content-secondary">{t('admin.bagTracking.title')}</div>
|
||||
<div className="text-xs mt-0.5 text-content-faint">{t('admin.bagTracking.subtitle')}</div>
|
||||
</div>
|
||||
<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)' }}>
|
||||
@@ -205,7 +205,7 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
|
||||
</div>
|
||||
)}
|
||||
{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">
|
||||
{COLLAB_SUB_FEATURES.map(feat => {
|
||||
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 }}>
|
||||
<Icon size={14} style={{ color: 'var(--text-faint)', flexShrink: 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-xs mt-0.5" style={{ color: 'var(--text-faint)' }}>{t(feat.subtitleKey)}</div>
|
||||
<div className="text-sm font-medium text-content-secondary">{t(feat.titleKey)}</div>
|
||||
<div className="text-xs mt-0.5 text-content-faint">{t(feat.subtitleKey)}</div>
|
||||
</div>
|
||||
<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)' }}>
|
||||
@@ -242,9 +242,9 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
|
||||
{/* Global Addons */}
|
||||
{globalAddons.length > 0 && (
|
||||
<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)' }}>
|
||||
<Globe size={13} style={{ color: 'var(--text-muted)' }} />
|
||||
<span className="text-xs font-medium uppercase tracking-wider" style={{ color: 'var(--text-muted)' }}>
|
||||
<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} className="text-content-muted" />
|
||||
<span className="text-xs font-medium uppercase tracking-wider text-content-muted">
|
||||
{t('admin.addons.type.global')} — {t('admin.addons.globalHint')}
|
||||
</span>
|
||||
</div>
|
||||
@@ -253,7 +253,7 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
|
||||
<AddonRow addon={addon} onToggle={handleToggle} t={t} />
|
||||
{/* Memories providers as sub-items under Journey addon */}
|
||||
{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">
|
||||
{providerOptions.map(provider => {
|
||||
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 }}>
|
||||
{ProviderIcon && <span style={{ color: 'var(--text-faint)' }}><ProviderIcon size={14} /></span>}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>{provider.label}</div>
|
||||
<div className="text-xs mt-0.5" style={{ color: 'var(--text-faint)' }}>{provider.description}</div>
|
||||
<div className="text-sm font-medium text-content-secondary">{provider.label}</div>
|
||||
<div className="text-xs mt-0.5 text-content-faint">{provider.description}</div>
|
||||
</div>
|
||||
<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)' }}>
|
||||
@@ -291,9 +291,9 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
|
||||
{/* Integration Addons */}
|
||||
{integrationAddons.length > 0 && (
|
||||
<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)' }}>
|
||||
<Link2 size={13} style={{ color: 'var(--text-muted)' }} />
|
||||
<span className="text-xs font-medium uppercase tracking-wider" style={{ color: 'var(--text-muted)' }}>
|
||||
<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} className="text-content-muted" />
|
||||
<span className="text-xs font-medium uppercase tracking-wider text-content-muted">
|
||||
{t('admin.addons.type.integration')} — {t('admin.addons.integrationHint')}
|
||||
</span>
|
||||
</div>
|
||||
@@ -336,26 +336,26 @@ function AddonRow({ addon, onToggle, t, nameOverride, descriptionOverride, statu
|
||||
const displayDescription = descriptionOverride || label.description
|
||||
const enabledState = statusOverride ?? addon.enabled
|
||||
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 */}
|
||||
<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} />
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<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 && (
|
||||
<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
|
||||
</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')}
|
||||
</span>
|
||||
</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>
|
||||
|
||||
{/* 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' }}
|
||||
>
|
||||
<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={{
|
||||
background: 'var(--bg-card)',
|
||||
transform: (enabledState && !isComingSoon) ? 'translateX(22px)' : 'translateX(4px)',
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -67,7 +67,7 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
|
||||
elements.push(
|
||||
<ul key={`ul-${elements.length}`} className="space-y-1 my-2">
|
||||
{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 dangerouslySetInnerHTML={{ __html: inlineFormat(item) }} />
|
||||
</li>
|
||||
@@ -96,14 +96,14 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
|
||||
if (trimmed.startsWith('### ')) {
|
||||
flushList()
|
||||
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)}
|
||||
</h4>
|
||||
)
|
||||
} else if (trimmed.startsWith('## ')) {
|
||||
flushList()
|
||||
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)}
|
||||
</h3>
|
||||
)
|
||||
@@ -112,7 +112,7 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
|
||||
} else {
|
||||
flushList()
|
||||
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) }}
|
||||
/>
|
||||
)
|
||||
@@ -130,8 +130,8 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
|
||||
href="https://ko-fi.com/mauriceboe"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
|
||||
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={{ borderColor: 'var(--border-primary)', textDecoration: 'none' }}
|
||||
onMouseEnter={e => { e.currentTarget.style.borderColor = '#ff5e5b'; e.currentTarget.style.boxShadow = '0 0 0 1px #ff5e5b22' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
||||
>
|
||||
@@ -139,17 +139,17 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
|
||||
<Coffee size={20} style={{ color: '#ff5e5b' }} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>Ko-fi</div>
|
||||
<div className="text-xs" style={{ color: 'var(--text-faint)' }}>{t('admin.github.support')}</div>
|
||||
<div className="text-sm font-semibold text-content">Ko-fi</div>
|
||||
<div className="text-xs text-content-faint">{t('admin.github.support')}</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
|
||||
href="https://buymeacoffee.com/mauriceboe"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
|
||||
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={{ borderColor: 'var(--border-primary)', textDecoration: 'none' }}
|
||||
onMouseEnter={e => { e.currentTarget.style.borderColor = '#ffdd00'; e.currentTarget.style.boxShadow = '0 0 0 1px #ffdd0022' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
||||
>
|
||||
@@ -157,17 +157,17 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
|
||||
<Heart size={20} style={{ color: '#ffdd00' }} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>Buy Me a Coffee</div>
|
||||
<div className="text-xs" style={{ color: 'var(--text-faint)' }}>{t('admin.github.support')}</div>
|
||||
<div className="text-sm font-semibold text-content">Buy Me a Coffee</div>
|
||||
<div className="text-xs text-content-faint">{t('admin.github.support')}</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
|
||||
href="https://discord.gg/NhZBDSd4qW"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
|
||||
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={{ borderColor: 'var(--border-primary)', textDecoration: 'none' }}
|
||||
onMouseEnter={e => { e.currentTarget.style.borderColor = '#5865F2'; e.currentTarget.style.boxShadow = '0 0 0 1px #5865F222' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
||||
>
|
||||
@@ -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>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>Discord</div>
|
||||
<div className="text-xs" style={{ color: 'var(--text-faint)' }}>Join the community</div>
|
||||
<div className="text-sm font-semibold text-content">Discord</div>
|
||||
<div className="text-xs text-content-faint">Join the community</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>
|
||||
</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"
|
||||
target="_blank"
|
||||
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)]"
|
||||
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
|
||||
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={{ borderColor: 'var(--border-primary)', textDecoration: 'none' }}
|
||||
onMouseEnter={e => { e.currentTarget.style.borderColor = '#ef4444'; e.currentTarget.style.boxShadow = '0 0 0 1px #ef444422' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
||||
>
|
||||
@@ -196,17 +196,17 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
|
||||
<Bug size={20} style={{ color: '#ef4444' }} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>{t('settings.about.reportBug')}</div>
|
||||
<div className="text-xs" style={{ color: 'var(--text-faint)' }}>{t('settings.about.reportBugHint')}</div>
|
||||
<div className="text-sm font-semibold text-content">{t('settings.about.reportBug')}</div>
|
||||
<div className="text-xs text-content-faint">{t('settings.about.reportBugHint')}</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
|
||||
href="https://github.com/mauriceboe/TREK/discussions/new?category=feature-requests"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
|
||||
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={{ borderColor: 'var(--border-primary)', textDecoration: 'none' }}
|
||||
onMouseEnter={e => { e.currentTarget.style.borderColor = '#f59e0b'; e.currentTarget.style.boxShadow = '0 0 0 1px #f59e0b22' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
||||
>
|
||||
@@ -214,17 +214,17 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
|
||||
<Lightbulb size={20} style={{ color: '#f59e0b' }} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>{t('settings.about.featureRequest')}</div>
|
||||
<div className="text-xs" style={{ color: 'var(--text-faint)' }}>{t('settings.about.featureRequestHint')}</div>
|
||||
<div className="text-sm font-semibold text-content">{t('settings.about.featureRequest')}</div>
|
||||
<div className="text-xs text-content-faint">{t('settings.about.featureRequestHint')}</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
|
||||
href="https://github.com/mauriceboe/TREK/wiki"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
|
||||
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={{ borderColor: 'var(--border-primary)', textDecoration: 'none' }}
|
||||
onMouseEnter={e => { e.currentTarget.style.borderColor = '#6366f1'; e.currentTarget.style.boxShadow = '0 0 0 1px #6366f122' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
||||
>
|
||||
@@ -232,40 +232,39 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
|
||||
<BookOpen size={20} style={{ color: '#6366f1' }} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>Wiki</div>
|
||||
<div className="text-xs" style={{ color: 'var(--text-faint)' }}>{t('settings.about.wikiHint')}</div>
|
||||
<div className="text-sm font-semibold text-content">Wiki</div>
|
||||
<div className="text-xs text-content-faint">{t('settings.about.wikiHint')}</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>
|
||||
</div>
|
||||
|
||||
{/* Loading / Error / Releases */}
|
||||
{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">
|
||||
<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>
|
||||
) : 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">
|
||||
<p className="text-sm" style={{ color: 'var(--text-muted)' }}>{t('admin.github.error')}</p>
|
||||
<p className="text-xs mt-1" style={{ color: 'var(--text-faint)' }}>{error}</p>
|
||||
<p className="text-sm text-content-muted">{t('admin.github.error')}</p>
|
||||
<p className="text-xs mt-1 text-content-faint">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-xl border overflow-hidden" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
|
||||
<div className="px-5 py-4 border-b flex items-center justify-between" style={{ borderColor: 'var(--border-secondary)' }}>
|
||||
<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 border-edge-secondary">
|
||||
<div>
|
||||
<h2 className="font-semibold" style={{ color: 'var(--text-primary)' }}>{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>
|
||||
<h2 className="font-semibold text-content">{t('admin.github.title')}</h2>
|
||||
<p className="text-xs mt-0.5 text-content-faint">{t('admin.github.subtitle').replace('{repo}', REPO)}</p>
|
||||
</div>
|
||||
<a
|
||||
href={`https://github.com/${REPO}/releases`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors"
|
||||
style={{ background: 'var(--bg-secondary)', color: 'var(--text-muted)' }}
|
||||
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"
|
||||
>
|
||||
<ExternalLink size={12} />
|
||||
GitHub
|
||||
@@ -299,7 +298,7 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
|
||||
{/* Release content */}
|
||||
<div>
|
||||
<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}
|
||||
</span>
|
||||
{isLatest && (
|
||||
@@ -317,18 +316,18 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
|
||||
</div>
|
||||
|
||||
{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}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<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} />
|
||||
{formatDate(release.published_at || release.created_at)}
|
||||
</span>
|
||||
{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}
|
||||
</span>
|
||||
)}
|
||||
@@ -339,15 +338,14 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
|
||||
<div className="mt-2">
|
||||
<button
|
||||
onClick={() => toggleExpand(release.id)}
|
||||
className="flex items-center gap-1 text-[11px] font-medium transition-colors"
|
||||
style={{ color: 'var(--text-muted)' }}
|
||||
className="flex items-center gap-1 text-[11px] font-medium transition-colors text-content-muted"
|
||||
>
|
||||
{isExpanded ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
|
||||
{isExpanded ? t('admin.github.hideDetails') : t('admin.github.showDetails')}
|
||||
</button>
|
||||
|
||||
{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)}
|
||||
</div>
|
||||
)}
|
||||
@@ -366,8 +364,7 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
|
||||
<button
|
||||
onClick={handleLoadMore}
|
||||
disabled={loadingMore}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-xs font-medium transition-colors"
|
||||
style={{ background: 'var(--bg-secondary)', color: 'var(--text-muted)' }}
|
||||
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"
|
||||
>
|
||||
{loadingMore ? <Loader2 size={12} className="animate-spin" /> : <ChevronDown size={12} />}
|
||||
{loadingMore ? t('admin.github.loading') : t('admin.github.loadMore')}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useState, useEffect, useRef, useMemo, useCallback } from 'react'
|
||||
import DOM from 'react-dom'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useCanDo } from '../../store/permissionsStore'
|
||||
import { useToast } from '../shared/Toast'
|
||||
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'
|
||||
|
||||
@@ -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)' }
|
||||
|
||||
return (
|
||||
<tr style={{ background: 'var(--bg-secondary)' }}>
|
||||
<tr className="bg-surface-secondary">
|
||||
<td style={{ padding: '4px 6px' }}>
|
||||
<input ref={nameRef} value={name} onChange={e => setName(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()}
|
||||
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()}
|
||||
placeholder="-" inputMode="numeric" style={{ ...inp, textAlign: 'center', maxWidth: 60, margin: '0 auto' }} />
|
||||
</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" style={{ padding: '4px 6px', color: 'var(--text-faint)', 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 md:table-cell text-content-faint" style={{ padding: '4px 6px', 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 text-content-faint" style={{ padding: '4px 6px', fontSize: 12, textAlign: 'center' }}>-</td>
|
||||
<td className="hidden sm:table-cell" style={{ padding: '4px 6px', textAlign: 'center' }}>
|
||||
<div style={{ maxWidth: 90, margin: '0 auto' }}>
|
||||
<CustomDatePicker value={expenseDate} onChange={setExpenseDate} placeholder="-" compact />
|
||||
@@ -561,6 +562,7 @@ interface BudgetPanelProps {
|
||||
export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelProps) {
|
||||
const { trip, budgetItems, addBudgetItem, updateBudgetItem, deleteBudgetItem, loadBudgetItems, updateTrip, setBudgetItemMembers, toggleBudgetMemberPaid, reorderBudgetItems, reorderBudgetCategories } = useTripStore()
|
||||
const can = useCanDo()
|
||||
const toast = useToast()
|
||||
const { t, locale } = useTranslation()
|
||||
const isDark = useIsDark()
|
||||
const theme = useMemo(() => widgetTheme(isDark), [isDark])
|
||||
@@ -625,21 +627,24 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
|
||||
})).filter(s => s.value > 0)
|
||||
, [grouped, categoryNames])
|
||||
|
||||
const handleAddItem = async (category, data) => { try { await addBudgetItem(tripId, { ...data, category }) } catch {} }
|
||||
const handleUpdateField = async (id, field, value) => { try { await updateBudgetItem(tripId, id, { [field]: value }) } catch {} }
|
||||
const handleDeleteItem = async (id) => { try { await deleteBudgetItem(tripId, id) } 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 { toast.error(t('common.error')) } }
|
||||
const handleDeleteItem = async (id) => { try { await deleteBudgetItem(tripId, id) } catch { toast.error(t('common.error')) } }
|
||||
const handleDeleteCategory = async (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) => {
|
||||
if (!newName.trim() || newName.trim() === oldName) return
|
||||
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 = () => {
|
||||
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('')
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useCanDo } from '../../store/permissionsStore'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { addListener, removeListener } from '../../api/websocket'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import type { User } from '../../types'
|
||||
|
||||
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%' }}>
|
||||
<ChatMessages {...S} />
|
||||
{/* 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 */}
|
||||
{replyTo && (
|
||||
<div style={{
|
||||
@@ -447,6 +448,7 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
|
||||
|
||||
function useCollabChat(tripId: any, currentUser: any) {
|
||||
const { t } = useTranslation()
|
||||
const toast = useToast()
|
||||
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
|
||||
const can = useCanDo()
|
||||
const trip = useTripStore((s) => s.trip)
|
||||
@@ -569,8 +571,8 @@ function useCollabChat(tripId: any, currentUser: any) {
|
||||
if (textareaRef.current) textareaRef.current.style.height = 'auto'
|
||||
isAtBottom.current = true
|
||||
setTimeout(() => scrollToBottom('smooth'), 50)
|
||||
} catch {} finally { setSending(false) }
|
||||
}, [text, sending, replyTo, tripId, scrollToBottom])
|
||||
} catch { toast.error(t('common.error')) } finally { setSending(false) }
|
||||
}, [text, sending, replyTo, tripId, scrollToBottom, toast, t])
|
||||
|
||||
const handleKeyDown = useCallback((e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend() }
|
||||
@@ -581,23 +583,23 @@ function useCollabChat(tripId: any, currentUser: any) {
|
||||
requestAnimationFrame(() => {
|
||||
setDeletingIds(prev => new Set(prev).add(msgId))
|
||||
})
|
||||
const t = setTimeout(async () => {
|
||||
const timer = setTimeout(async () => {
|
||||
try {
|
||||
await collabApi.deleteMessage(tripId, msgId)
|
||||
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 })
|
||||
}, 400)
|
||||
deleteTimersRef.current.push(t)
|
||||
}, [tripId])
|
||||
deleteTimersRef.current.push(timer)
|
||||
}, [tripId, toast, t])
|
||||
|
||||
const handleReact = useCallback(async (msgId, emoji) => {
|
||||
setReactMenu(null)
|
||||
try {
|
||||
const data = await collabApi.reactMessage(tripId, msgId, emoji)
|
||||
setMessages(prev => prev.map(m => m.id === msgId ? { ...m, reactions: data.reactions } : m))
|
||||
} catch {}
|
||||
}, [tripId])
|
||||
} catch { toast.error(t('common.error')) }
|
||||
}, [tripId, toast, t])
|
||||
|
||||
const handleEmojiSelect = useCallback((emoji) => {
|
||||
setText(prev => prev + emoji)
|
||||
|
||||
@@ -12,6 +12,7 @@ import { useCanDo } from '../../store/permissionsStore'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { addListener, removeListener } from '../../api/websocket'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import type { User } from '../../types'
|
||||
|
||||
interface NoteFile {
|
||||
@@ -916,6 +917,7 @@ interface CollabNotesProps {
|
||||
*/
|
||||
function useCollabNotes({ tripId, currentUser }: CollabNotesProps) {
|
||||
const { t } = useTranslation()
|
||||
const toast = useToast()
|
||||
const can = useCanDo()
|
||||
const trip = useTripStore((s) => s.trip)
|
||||
const canEdit = can('collab_edit', trip)
|
||||
@@ -996,7 +998,13 @@ function useCollabNotes({ tripId, currentUser }: CollabNotesProps) {
|
||||
const handleCreateNote = useCallback(async (data) => {
|
||||
const pendingFiles = 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) {
|
||||
const note = created.note || created
|
||||
// Upload pending files
|
||||
@@ -1004,7 +1012,7 @@ function useCollabNotes({ tripId, currentUser }: CollabNotesProps) {
|
||||
for (const file of pendingFiles) {
|
||||
const fd = new FormData()
|
||||
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
|
||||
const fresh = await collabApi.getNotes(tripId)
|
||||
@@ -1017,17 +1025,23 @@ function useCollabNotes({ tripId, currentUser }: CollabNotesProps) {
|
||||
return [note, ...prev]
|
||||
})
|
||||
}
|
||||
}, [tripId])
|
||||
}, [tripId, toast, t])
|
||||
|
||||
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
|
||||
if (updated) {
|
||||
setNotes(prev =>
|
||||
prev.map(n => (n.id === noteId ? { ...n, ...updated } : n))
|
||||
)
|
||||
}
|
||||
}, [tripId])
|
||||
}, [tripId, toast, t])
|
||||
|
||||
const saveCategoryColors = useCallback(async (newMap) => {
|
||||
// Update notes with changed colors
|
||||
@@ -1058,24 +1072,29 @@ function useCollabNotes({ tripId, currentUser }: CollabNotesProps) {
|
||||
for (const file of pendingFiles) {
|
||||
const fd = new FormData()
|
||||
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)
|
||||
if (fresh?.notes) setNotes(fresh.notes)
|
||||
window.dispatchEvent(new Event('collab-files-changed'))
|
||||
}
|
||||
}, [editingNote, tripId, handleUpdateNote])
|
||||
}, [editingNote, tripId, handleUpdateNote, toast, t])
|
||||
|
||||
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'))
|
||||
}, [tripId])
|
||||
}, [tripId, toast, t])
|
||||
|
||||
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))
|
||||
window.dispatchEvent(new Event('collab-files-changed'))
|
||||
}, [tripId])
|
||||
}, [tripId, toast, t])
|
||||
|
||||
// ── Derived data ──
|
||||
const categories = [...new Set(notes.map(n => n.category).filter(Boolean))]
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Plus, Trash2, X, Check, BarChart3, Lock, Clock } from 'lucide-react'
|
||||
import { collabApi } from '../../api/client'
|
||||
import { addListener, removeListener } from '../../api/websocket'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { useCanDo } from '../../store/permissionsStore'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import ReactDOM from 'react-dom'
|
||||
@@ -342,6 +343,7 @@ interface CollabPollsProps {
|
||||
|
||||
export default function CollabPolls({ tripId, currentUser }: CollabPollsProps) {
|
||||
const { t } = useTranslation()
|
||||
const toast = useToast()
|
||||
const can = useCanDo()
|
||||
const trip = useTripStore((s) => s.trip)
|
||||
const canEdit = can('collab_edit', trip)
|
||||
@@ -378,33 +380,44 @@ export default function CollabPolls({ tripId, currentUser }: CollabPollsProps) {
|
||||
}, [])
|
||||
|
||||
const handleCreate = useCallback(async (data) => {
|
||||
const result = await collabApi.createPoll(tripId, data)
|
||||
const created = result.poll || result
|
||||
setPolls(prev => prev.some(p => p.id === created.id) ? prev : [created, ...prev])
|
||||
setShowForm(false)
|
||||
}, [tripId])
|
||||
try {
|
||||
const result = await collabApi.createPoll(tripId, data)
|
||||
const created = result.poll || result
|
||||
setPolls(prev => prev.some(p => p.id === created.id) ? prev : [created, ...prev])
|
||||
setShowForm(false)
|
||||
} catch (err) {
|
||||
toast.error(t('common.error'))
|
||||
throw err
|
||||
}
|
||||
}, [tripId, toast, t])
|
||||
|
||||
const handleVote = useCallback(async (pollId, optionIndex) => {
|
||||
try {
|
||||
const result = await collabApi.votePoll(tripId, pollId, optionIndex)
|
||||
const updated = result.poll || result
|
||||
setPolls(prev => prev.map(p => p.id === updated.id ? updated : p))
|
||||
} catch {}
|
||||
}, [tripId])
|
||||
} catch {
|
||||
toast.error(t('common.error'))
|
||||
}
|
||||
}, [tripId, toast, t])
|
||||
|
||||
const handleClose = useCallback(async (pollId) => {
|
||||
try {
|
||||
await collabApi.closePoll(tripId, pollId)
|
||||
setPolls(prev => prev.map(p => p.id === pollId ? { ...p, is_closed: true } : p))
|
||||
} catch {}
|
||||
}, [tripId])
|
||||
} catch {
|
||||
toast.error(t('common.error'))
|
||||
}
|
||||
}, [tripId, toast, t])
|
||||
|
||||
const handleDelete = useCallback(async (pollId) => {
|
||||
try {
|
||||
await collabApi.deletePoll(tripId, pollId)
|
||||
setPolls(prev => prev.filter(p => p.id !== pollId))
|
||||
} catch {}
|
||||
}, [tripId])
|
||||
} catch {
|
||||
toast.error(t('common.error'))
|
||||
}
|
||||
}, [tripId, toast, t])
|
||||
|
||||
const activePolls = 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
|
||||
onClick={handleOpen}
|
||||
title={t('notifications.title')}
|
||||
className="relative p-2 rounded-lg transition-colors"
|
||||
style={{ color: 'var(--text-muted)' }}
|
||||
className="relative p-2 rounded-lg transition-colors text-content-muted"
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
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
|
||||
className="rounded-xl shadow-xl border overflow-hidden"
|
||||
className="rounded-xl shadow-xl border overflow-hidden bg-surface-card border-edge"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 'var(--nav-h)',
|
||||
@@ -81,18 +80,15 @@ export default function InAppNotificationBell(): React.ReactElement {
|
||||
maxWidth: 'calc(100vw - 16px)',
|
||||
maxHeight: 'min(480px, calc(100vh - var(--nav-h) - 16px))',
|
||||
zIndex: 9999,
|
||||
background: 'var(--bg-card)',
|
||||
borderColor: 'var(--border-primary)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
className="flex items-center justify-between px-4 py-3 flex-shrink-0"
|
||||
style={{ borderBottom: '1px solid var(--border-secondary)' }}
|
||||
className="flex items-center justify-between px-4 py-3 flex-shrink-0 border-b border-edge-secondary"
|
||||
>
|
||||
<span className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>
|
||||
<span className="text-sm font-semibold text-content">
|
||||
{t('notifications.title')}
|
||||
{unreadCount > 0 && (
|
||||
<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
|
||||
onClick={markAllRead}
|
||||
title={t('notifications.markAllRead')}
|
||||
className="p-1.5 rounded-lg transition-colors"
|
||||
style={{ color: 'var(--text-muted)' }}
|
||||
className="p-1.5 rounded-lg transition-colors text-content-muted"
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
|
||||
>
|
||||
@@ -118,8 +113,7 @@ export default function InAppNotificationBell(): React.ReactElement {
|
||||
<button
|
||||
onClick={deleteAll}
|
||||
title={t('notifications.deleteAll')}
|
||||
className="p-1.5 rounded-lg transition-colors"
|
||||
style={{ color: 'var(--text-muted)' }}
|
||||
className="p-1.5 rounded-lg transition-colors text-content-muted"
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
|
||||
>
|
||||
@@ -137,9 +131,9 @@ export default function InAppNotificationBell(): React.ReactElement {
|
||||
</div>
|
||||
) : notifications.length === 0 ? (
|
||||
<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)' }} />
|
||||
<p className="text-sm font-medium" style={{ color: 'var(--text-muted)' }}>{t('notifications.empty')}</p>
|
||||
<p className="text-xs" style={{ color: 'var(--text-faint)' }}>{t('notifications.emptyDescription')}</p>
|
||||
<Bell className="w-8 h-8 text-content-faint" />
|
||||
<p className="text-sm font-medium text-content-muted">{t('notifications.empty')}</p>
|
||||
<p className="text-xs text-content-faint">{t('notifications.emptyDescription')}</p>
|
||||
</div>
|
||||
) : (
|
||||
notifications.slice(0, 10).map(n => (
|
||||
@@ -151,10 +145,8 @@ export default function InAppNotificationBell(): React.ReactElement {
|
||||
{/* Footer */}
|
||||
<button
|
||||
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={{
|
||||
borderTop: '1px solid var(--border-secondary)',
|
||||
color: 'var(--text-primary)',
|
||||
background: 'transparent',
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
|
||||
@@ -110,8 +110,7 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
{showBack && (
|
||||
<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"
|
||||
style={{ color: 'var(--text-muted)' }}
|
||||
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"
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
|
||||
<ArrowLeft className="trek-back-icon w-4 h-4" />
|
||||
@@ -126,8 +125,8 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
|
||||
|
||||
{tripTitle && (
|
||||
<>
|
||||
<span className="hidden sm:inline" style={{ color: 'var(--text-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-content-faint">/</span>
|
||||
<span className="hidden sm:inline text-sm font-medium truncate max-w-48 text-content-muted">
|
||||
{tripTitle}
|
||||
</span>
|
||||
</>
|
||||
@@ -176,8 +175,8 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
|
||||
{/* Share button */}
|
||||
{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"
|
||||
style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)', background: 'var(--bg-card)' }}
|
||||
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={{ background: 'var(--bg-card)' }}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-card)'}>
|
||||
<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 */}
|
||||
<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"
|
||||
style={{ color: 'var(--text-muted)' }}
|
||||
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"
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
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)]"
|
||||
@@ -227,21 +225,21 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
|
||||
{user.username?.charAt(0).toUpperCase()}
|
||||
</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}
|
||||
</span>
|
||||
<ChevronDown className="w-4 h-4" style={{ color: 'var(--text-faint)' }} />
|
||||
<ChevronDown className="w-4 h-4 text-content-faint" />
|
||||
</button>
|
||||
|
||||
{userMenuOpen && ReactDOM.createPortal(
|
||||
<>
|
||||
<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="px-4 py-3 border-b" style={{ borderColor: 'var(--border-secondary)' }}>
|
||||
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>{user.username}</p>
|
||||
<p className="text-xs truncate" style={{ color: 'var(--text-muted)' }}>{user.email}</p>
|
||||
<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 border-edge-secondary">
|
||||
<p className="text-sm font-medium text-content">{user.username}</p>
|
||||
<p className="text-xs truncate text-content-muted">{user.email}</p>
|
||||
{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')}
|
||||
</span>
|
||||
)}
|
||||
@@ -249,8 +247,7 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
|
||||
|
||||
<div className="py-1">
|
||||
<Link to="/settings" onClick={() => setUserMenuOpen(false)}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm transition-colors"
|
||||
style={{ color: 'var(--text-secondary)' }}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm transition-colors text-content-secondary"
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
|
||||
<Settings className="w-4 h-4" />
|
||||
@@ -259,8 +256,7 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
|
||||
|
||||
{user.role === 'admin' && (
|
||||
<Link to="/admin" onClick={() => setUserMenuOpen(false)}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm transition-colors"
|
||||
style={{ color: 'var(--text-secondary)' }}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm transition-colors text-content-secondary"
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
|
||||
<Shield className="w-4 h-4" />
|
||||
@@ -269,14 +265,14 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="py-1 border-t" style={{ borderColor: 'var(--border-secondary)' }}>
|
||||
<div className="py-1 border-t border-edge-secondary">
|
||||
<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">
|
||||
<LogOut className="w-4 h-4" />
|
||||
{t('nav.logout')}
|
||||
</button>
|
||||
{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: '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 }} />
|
||||
|
||||
@@ -47,27 +47,23 @@ export default function PageSidebar({
|
||||
|
||||
return (
|
||||
<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={{
|
||||
background: 'var(--bg-card)',
|
||||
border: '1px solid var(--border-primary)',
|
||||
minHeight: 'min(820px, calc(100vh - var(--nav-h) - 120px))',
|
||||
}}
|
||||
>
|
||||
{/* Mobile top bar with hamburger */}
|
||||
<div
|
||||
className="lg:hidden flex items-center justify-between px-4 py-3 border-b"
|
||||
style={{ borderColor: 'var(--border-primary)' }}
|
||||
className="lg:hidden flex items-center justify-between px-4 py-3 border-b border-edge"
|
||||
>
|
||||
<button
|
||||
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"
|
||||
style={{ color: 'var(--text-primary)' }}
|
||||
>
|
||||
<Menu size={18} />
|
||||
</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}
|
||||
</div>
|
||||
<div className="w-9" />
|
||||
@@ -75,11 +71,9 @@ export default function PageSidebar({
|
||||
|
||||
{/* Desktop sidebar (always visible on lg) */}
|
||||
<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={{
|
||||
width: 260,
|
||||
background: 'var(--bg-secondary)',
|
||||
borderRight: '1px solid var(--border-primary)',
|
||||
padding: '24px 14px',
|
||||
}}
|
||||
>
|
||||
@@ -102,25 +96,22 @@ export default function PageSidebar({
|
||||
/>
|
||||
<aside
|
||||
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={{
|
||||
width: 280,
|
||||
background: 'var(--bg-secondary)',
|
||||
padding: '18px 14px',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3 px-2">
|
||||
<span
|
||||
className="text-[11px] font-bold tracking-widest uppercase"
|
||||
style={{ color: 'var(--text-muted)' }}
|
||||
className="text-[11px] font-bold tracking-widest uppercase text-content-muted"
|
||||
>
|
||||
{sidebarLabel}
|
||||
</span>
|
||||
<button
|
||||
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"
|
||||
style={{ color: 'var(--text-primary)' }}
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
@@ -164,8 +155,7 @@ function SidebarInner({
|
||||
<>
|
||||
{sidebarLabel && (
|
||||
<div
|
||||
className="text-[11px] font-bold tracking-widest uppercase mb-3 px-3"
|
||||
style={{ color: 'var(--text-muted)' }}
|
||||
className="text-[11px] font-bold tracking-widest uppercase mb-3 px-3 text-content-muted"
|
||||
>
|
||||
{sidebarLabel}
|
||||
</div>
|
||||
@@ -199,8 +189,7 @@ function SidebarInner({
|
||||
</nav>
|
||||
{footer && (
|
||||
<div
|
||||
className="mt-4 pt-3 px-3 text-[10px] tracking-wide"
|
||||
style={{ color: 'var(--text-faint)', borderTop: '1px solid var(--border-primary)' }}
|
||||
className="mt-4 pt-3 px-3 text-[10px] tracking-wide text-content-faint border-t border-edge"
|
||||
>
|
||||
{footer}
|
||||
</div>
|
||||
|
||||
@@ -421,7 +421,7 @@ export default function ReservationOverlay({ reservations, showConnections, show
|
||||
>
|
||||
<Tooltip direction="top" offset={[0, -8]} opacity={1} className="map-tooltip">
|
||||
<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>
|
||||
</Marker>,
|
||||
<Marker
|
||||
@@ -434,7 +434,7 @@ export default function ReservationOverlay({ reservations, showConnections, show
|
||||
>
|
||||
<Tooltip direction="top" offset={[0, -8]} opacity={1} className="map-tooltip">
|
||||
<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>
|
||||
</Marker>,
|
||||
])}
|
||||
|
||||
@@ -53,10 +53,9 @@ export default function InAppNotificationItem({ notification, onClose }: Notific
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative px-4 py-3 transition-colors"
|
||||
className="relative px-4 py-3 transition-colors border-b border-edge-secondary"
|
||||
style={{
|
||||
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
|
||||
className="w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold"
|
||||
style={{ background: dark ? '#27272a' : '#f1f5f9', color: 'var(--text-muted)' }}
|
||||
className="w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold text-content-muted"
|
||||
style={{ background: dark ? '#27272a' : '#f1f5f9' }}
|
||||
>
|
||||
{notification.sender_username
|
||||
? notification.sender_username.charAt(0).toUpperCase()
|
||||
@@ -85,11 +84,11 @@ export default function InAppNotificationItem({ notification, onClose }: Notific
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<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}
|
||||
</p>
|
||||
<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)}
|
||||
</span>
|
||||
{!notification.is_read && (
|
||||
@@ -117,7 +116,7 @@ export default function InAppNotificationItem({ notification, onClose }: Notific
|
||||
</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}
|
||||
</p>
|
||||
|
||||
|
||||
@@ -22,8 +22,7 @@ export default function ScopeGroupPicker({ selected, onChange }: Props): React.R
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
|
||||
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">
|
||||
{allSelected ? t('settings.oauth.modal.deselectAll') : t('settings.oauth.modal.selectAll')}
|
||||
</button>
|
||||
</div>
|
||||
@@ -33,13 +32,12 @@ export default function ScopeGroupPicker({ selected, onChange }: Props): React.R
|
||||
const allGroupSelected = groupScopeKeys.every(s => selected.includes(s))
|
||||
const someGroupSelected = groupScopeKeys.some(s => selected.includes(s))
|
||||
return (
|
||||
<div key={group} className="rounded-lg border overflow-hidden" style={{ borderColor: 'var(--border-primary)' }}>
|
||||
<div className="flex items-center gap-1 px-3 py-2" style={{ background: 'var(--bg-secondary)' }}>
|
||||
<div key={group} className="rounded-lg border overflow-hidden border-edge">
|
||||
<div className="flex items-center gap-1 px-3 py-2 bg-surface-secondary">
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
style={{ color: 'var(--text-secondary)' }}>
|
||||
className="flex items-center gap-1 flex-1 text-xs font-semibold hover:opacity-70 transition-opacity text-left text-content-secondary">
|
||||
{open[group]
|
||||
? <ChevronDown 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"
|
||||
/>
|
||||
<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>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
@@ -87,7 +87,7 @@ export default function ApplyTemplateButton({ tripId, style, className }: ApplyT
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-tertiary)'}
|
||||
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={{ fontWeight: 600 }}>{tmpl.name}</div>
|
||||
<div style={{ fontSize: 10, color: 'var(--text-faint)' }}>
|
||||
|
||||
@@ -318,7 +318,7 @@ function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingE
|
||||
if (!canEdit) return
|
||||
const raw = e.target.value.replace(/[^0-9]/g, '')
|
||||
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="—"
|
||||
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',
|
||||
}}
|
||||
>
|
||||
{!item.bag_id && <Package size={9} style={{ color: 'var(--text-faint)' }} />}
|
||||
{!item.bag_id && <Package size={9} className="text-content-faint" />}
|
||||
</button>
|
||||
{showBagPicker && (
|
||||
<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,
|
||||
}}>
|
||||
{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 }}>
|
||||
<span style={{ width: 10, height: 10, borderRadius: '50%', border: '2px dashed var(--border-primary)' }} />
|
||||
{t('packing.noBag')}
|
||||
</button>
|
||||
)}
|
||||
{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={{
|
||||
display: 'flex', alignItems: 'center', gap: 7, width: '100%', padding: '6px 10px',
|
||||
background: item.bag_id === b.id ? 'var(--bg-tertiary)' : 'none',
|
||||
@@ -370,7 +370,7 @@ function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingE
|
||||
onKeyDown={async e => {
|
||||
if (e.key === 'Enter' && 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)
|
||||
}
|
||||
if (e.key === 'Escape') { setBagInlineCreate(false); setBagInlineName('') }
|
||||
@@ -380,7 +380,7 @@ function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingE
|
||||
<button onClick={async () => {
|
||||
if (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)
|
||||
}
|
||||
}}
|
||||
@@ -517,14 +517,18 @@ function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, on
|
||||
}
|
||||
|
||||
const handleCheckAll = async () => {
|
||||
for (const item of Array.from(items)) {
|
||||
if (!item.checked) await togglePackingItem(tripId, item.id, true)
|
||||
}
|
||||
try {
|
||||
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 () => {
|
||||
for (const item of Array.from(items)) {
|
||||
if (item.checked) await togglePackingItem(tripId, item.id, false)
|
||||
}
|
||||
try {
|
||||
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 () => {
|
||||
await onDeleteAll(items)
|
||||
@@ -630,7 +634,7 @@ function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, on
|
||||
{m.username[0]}
|
||||
</div>
|
||||
<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>
|
||||
)
|
||||
})}
|
||||
@@ -856,16 +860,20 @@ function usePackingList({ tripId, items, openImportSignal = 0, clearCheckedSigna
|
||||
}
|
||||
|
||||
const handleDeleteCategory = async (catItems) => {
|
||||
let failed = false
|
||||
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 () => {
|
||||
if (!confirm(t('packing.confirm.clearChecked', { count: abgehakt }))) return
|
||||
let failed = false
|
||||
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
|
||||
@@ -1152,7 +1160,7 @@ function PackingHeader(S: PackingState) {
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-tertiary)'}
|
||||
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={{ fontWeight: 600 }}>{tmpl.name}</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 { Upload, X, Image } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import type { Place, Day } from '../../types'
|
||||
|
||||
interface PhotoUploadProps {
|
||||
@@ -14,6 +15,7 @@ interface PhotoUploadProps {
|
||||
|
||||
export function PhotoUpload({ tripId, days, places, onUpload, onClose }: PhotoUploadProps) {
|
||||
const { t } = useTranslation()
|
||||
const toast = useToast()
|
||||
const [files, setFiles] = useState([])
|
||||
const [dayId, setDayId] = useState('')
|
||||
const [placeId, setPlaceId] = useState('')
|
||||
@@ -59,6 +61,7 @@ export function PhotoUpload({ tripId, days, places, onUpload, onClose }: PhotoUp
|
||||
setFiles([])
|
||||
} catch (err: unknown) {
|
||||
console.error('Upload failed:', err)
|
||||
toast.error(t('common.error'))
|
||||
} finally {
|
||||
setUploading(false)
|
||||
setProgress(0)
|
||||
|
||||
@@ -221,7 +221,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
||||
return (
|
||||
<div style={{ marginBottom: 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 }}>
|
||||
{dayReservations.map(r => {
|
||||
const linkedAssignment = dayAssignments.find(a => a.id === r.assignment_id)
|
||||
@@ -257,7 +257,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
||||
|
||||
{/* ── Accommodation ── */}
|
||||
<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}
|
||||
canEditDays={canEditDays} fmtTime={fmtTime} blurCodes={blurCodes} t={t}
|
||||
@@ -286,7 +286,7 @@ interface ChipProps {
|
||||
|
||||
function Chip({ icon: Icon, value }: ChipProps) {
|
||||
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 }} />
|
||||
<span style={{ fontWeight: 500 }}>{value}</span>
|
||||
</div>
|
||||
|
||||
@@ -1211,7 +1211,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', position: 'relative', fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }}>
|
||||
{/* 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={{ position: 'relative', flexShrink: 0 }}>
|
||||
<button
|
||||
@@ -1228,10 +1228,11 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
}}
|
||||
onMouseEnter={() => setPdfHover(true)}
|
||||
onMouseLeave={() => setPdfHover(false)}
|
||||
className="bg-accent text-accent-text"
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 5,
|
||||
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',
|
||||
}}
|
||||
>
|
||||
@@ -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',
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
) : (
|
||||
merged.map((item, idx) => {
|
||||
@@ -2600,8 +2601,8 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
|
||||
{/* Schließen */}
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<button onClick={() => setTransportDetail(null)} style={{
|
||||
fontSize: 12, background: 'var(--accent)', color: 'var(--accent-text)',
|
||||
<button onClick={() => setTransportDetail(null)} className="bg-accent text-accent-text" style={{
|
||||
fontSize: 12,
|
||||
border: 'none', borderRadius: 8, padding: '6px 16px', cursor: 'pointer', fontWeight: 600, fontFamily: 'inherit',
|
||||
}}>
|
||||
{t('common.close')}
|
||||
@@ -2617,9 +2618,9 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
|
||||
{/* Budget-Fußzeile */}
|
||||
{totalCost > 0 && (
|
||||
<div style={{ flexShrink: 0, padding: '10px 16px', borderTop: '1px solid var(--border-faint)', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span style={{ fontSize: 11, color: 'var(--text-faint)' }}>{t('dayplan.totalCost')}</span>
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>{totalCost.toFixed(currencyDecimals(currency))} {currency}</span>
|
||||
<div className="border-t border-edge-faint" style={{ flexShrink: 0, padding: '10px 16px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span className="text-content-faint" style={{ fontSize: 11 }}>{t('dayplan.totalCost')}</span>
|
||||
<span className="text-content" style={{ fontSize: 13, fontWeight: 600 }}>{totalCost.toFixed(currencyDecimals(currency))} {currency}</span>
|
||||
</div>
|
||||
)}
|
||||
<ContextMenu menu={ctxMenu.menu} onClose={ctxMenu.close} />
|
||||
|
||||
@@ -8,9 +8,10 @@ import PlaceAvatar from '../shared/PlaceAvatar'
|
||||
import { mapsApi } from '../../api/client'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { getCategoryIcon } from '../shared/categoryIcons'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { useTranslation } from '../../i18n'
|
||||
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()
|
||||
|
||||
@@ -76,24 +77,6 @@ function convertHoursLine(line, timeFormat) {
|
||||
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) {
|
||||
if (!bytes) return ''
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
@@ -137,6 +120,7 @@ export default function PlaceInspector({
|
||||
leftWidth = 0, rightWidth = 0,
|
||||
}: PlaceInspectorProps) {
|
||||
const { t, locale, language } = useTranslation()
|
||||
const toast = useToast()
|
||||
const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h'
|
||||
const [hoursExpanded, setHoursExpanded] = useState(false)
|
||||
const [filesExpanded, setFilesExpanded] = useState(false)
|
||||
@@ -197,11 +181,12 @@ export default function PlaceInspector({
|
||||
setFilesExpanded(true)
|
||||
} catch (err: unknown) {
|
||||
console.error('Upload failed', err)
|
||||
toast.error(t('files.uploadError'))
|
||||
} finally {
|
||||
setIsUploading(false)
|
||||
if (fileInputRef.current) fileInputRef.current.value = ''
|
||||
}
|
||||
}, [onFileUpload, place.id])
|
||||
}, [onFileUpload, place.id, toast, t])
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -268,14 +253,14 @@ export default function PlaceInspector({
|
||||
|
||||
{/* Description / 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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
</div>
|
||||
)}
|
||||
@@ -294,7 +279,7 @@ export default function PlaceInspector({
|
||||
</div>
|
||||
|
||||
{/* 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 && (
|
||||
assignmentInDay ? (
|
||||
<ActionButton onClick={() => onRemoveAssignment(selectedDayId, assignmentInDay.id)} variant="ghost" icon={<Minus size={13} />}
|
||||
|
||||
@@ -370,13 +370,14 @@ function PlacesHeader(S: SidebarState) {
|
||||
catDropOpen, setCatDropOpen, toggleCategoryFilter, setCategoryFiltersLocal, onCategoryFilterChange,
|
||||
} = S
|
||||
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
|
||||
onClick={onAddPlace}
|
||||
className="bg-accent text-accent-text"
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
|
||||
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,
|
||||
}}
|
||||
>
|
||||
@@ -386,11 +387,11 @@ function PlacesHeader(S: SidebarState) {
|
||||
<div style={{ display: 'flex', gap: 6, marginBottom: 10 }}>
|
||||
<button
|
||||
onClick={() => setFileImportOpen(true)}
|
||||
className="border border-dashed border-edge text-content-faint"
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
|
||||
flex: 1, padding: '5px 12px', borderRadius: 8,
|
||||
border: '1px dashed var(--border-primary)', background: 'none',
|
||||
color: 'var(--text-faint)', fontSize: 11, fontWeight: 500,
|
||||
background: 'none', fontSize: 11, fontWeight: 500,
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
@@ -398,18 +399,18 @@ function PlacesHeader(S: SidebarState) {
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setListImportOpen(true)}
|
||||
className="border border-dashed border-edge text-content-faint"
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
|
||||
flex: 1, padding: '5px 12px', borderRadius: 8,
|
||||
border: '1px dashed var(--border-primary)', background: 'none',
|
||||
color: 'var(--text-faint)', fontSize: 11, fontWeight: 500,
|
||||
background: 'none', fontSize: 11, fontWeight: 500,
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
<MapPin size={11} strokeWidth={2} /> {t(hasMultipleListImportProviders ? 'places.importList' : 'places.importGoogleList')}
|
||||
</button>
|
||||
</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 */}
|
||||
@@ -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)}>
|
||||
{filtered.length === 0 ? (
|
||||
<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')}
|
||||
</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')}
|
||||
</button>}
|
||||
</div>
|
||||
@@ -910,7 +911,7 @@ const PlacesSidebar = React.memo(function PlacesSidebar(props: PlacesSidebarProp
|
||||
<PlacesSelectionBar {...S} />
|
||||
) : (
|
||||
<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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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>
|
||||
<button type="button" onClick={async () => {
|
||||
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 {
|
||||
const linksRes = await apiClient.get(`/trips/${tripId}/files/${f.id}/links`)
|
||||
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}`)
|
||||
} catch {}
|
||||
} catch { toast.error(t('reservations.toast.updateError')) }
|
||||
setLinkedFileIds(prev => prev.filter(id => id !== f.id))
|
||||
if (tripId) loadFiles(tripId)
|
||||
}} 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])
|
||||
setShowFilePicker(false)
|
||||
if (tripId) loadFiles(tripId)
|
||||
} catch {}
|
||||
} catch { toast.error(t('reservations.toast.updateError')) }
|
||||
}}
|
||||
style={{
|
||||
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">
|
||||
{total === 0 && reservations.length === 0 ? (
|
||||
<div style={{ textAlign: 'center', padding: '60px 20px' }}>
|
||||
<BookMarked size={36} style={{ color: 'var(--text-faint)', 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 style={{ fontSize: 12, color: 'var(--text-faint)', margin: 0 }}>{t('reservations.emptyHint')}</p>
|
||||
<BookMarked size={36} className="text-content-faint" style={{ display: 'block', margin: '0 auto 12px' }} />
|
||||
<p className="text-content-secondary" style={{ fontSize: 14, fontWeight: 600, margin: '0 0 4px' }}>{t('reservations.empty')}</p>
|
||||
<p className="text-content-faint" style={{ fontSize: 12, margin: 0 }}>{t('reservations.emptyHint')}</p>
|
||||
</div>
|
||||
) : total === 0 ? (
|
||||
<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>
|
||||
) : (
|
||||
<>
|
||||
|
||||
@@ -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>
|
||||
<button type="button" onClick={async () => {
|
||||
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 {
|
||||
const linksRes = await apiClient.get(`/trips/${tripId}/files/${f.id}/links`)
|
||||
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}`)
|
||||
} catch {}
|
||||
} catch { toast.error(t('reservations.toast.updateError')) }
|
||||
setLinkedFileIds(prev => prev.filter(id => id !== f.id))
|
||||
if (tripId) loadFiles(tripId)
|
||||
}} 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])
|
||||
setShowFilePicker(false)
|
||||
if (tripId) loadFiles(tripId)
|
||||
} catch {}
|
||||
} catch { toast.error(t('reservations.toast.updateError')) }
|
||||
}}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8, width: '100%', padding: '6px 10px',
|
||||
|
||||
@@ -239,14 +239,14 @@ export default function AboutTab({ appVersion }: Props): React.ReactElement {
|
||||
50% { transform: scale(1.15); }
|
||||
}
|
||||
`}</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')}
|
||||
</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')}{' '}
|
||||
<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')}{' '}
|
||||
<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>
|
||||
|
||||
<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"
|
||||
target="_blank"
|
||||
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)]"
|
||||
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
|
||||
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={{ borderColor: 'var(--border-primary)', textDecoration: 'none' }}
|
||||
onMouseEnter={e => { e.currentTarget.style.borderColor = '#ff5e5b'; e.currentTarget.style.boxShadow = '0 0 0 1px #ff5e5b22' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
||||
>
|
||||
@@ -263,17 +263,17 @@ export default function AboutTab({ appVersion }: Props): React.ReactElement {
|
||||
<Coffee size={20} style={{ color: '#ff5e5b' }} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>Ko-fi</div>
|
||||
<div className="text-xs" style={{ color: 'var(--text-faint)' }}>{t('admin.github.support')}</div>
|
||||
<div className="text-sm font-semibold text-content">Ko-fi</div>
|
||||
<div className="text-xs text-content-faint">{t('admin.github.support')}</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
|
||||
href="https://buymeacoffee.com/mauriceboe"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
|
||||
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={{ borderColor: 'var(--border-primary)', textDecoration: 'none' }}
|
||||
onMouseEnter={e => { e.currentTarget.style.borderColor = '#ffdd00'; e.currentTarget.style.boxShadow = '0 0 0 1px #ffdd0022' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
||||
>
|
||||
@@ -281,17 +281,17 @@ export default function AboutTab({ appVersion }: Props): React.ReactElement {
|
||||
<Heart size={20} style={{ color: '#ffdd00' }} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>Buy Me a Coffee</div>
|
||||
<div className="text-xs" style={{ color: 'var(--text-faint)' }}>{t('admin.github.support')}</div>
|
||||
<div className="text-sm font-semibold text-content">Buy Me a Coffee</div>
|
||||
<div className="text-xs text-content-faint">{t('admin.github.support')}</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
|
||||
href="https://discord.gg/NhZBDSd4qW"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
|
||||
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={{ borderColor: 'var(--border-primary)', textDecoration: 'none' }}
|
||||
onMouseEnter={e => { e.currentTarget.style.borderColor = '#5865F2'; e.currentTarget.style.boxShadow = '0 0 0 1px #5865F222' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
||||
>
|
||||
@@ -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>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>Discord</div>
|
||||
<div className="text-xs" style={{ color: 'var(--text-faint)' }}>Join the community</div>
|
||||
<div className="text-sm font-semibold text-content">Discord</div>
|
||||
<div className="text-xs text-content-faint">Join the community</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>
|
||||
</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"
|
||||
target="_blank"
|
||||
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)]"
|
||||
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
|
||||
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={{ borderColor: 'var(--border-primary)', textDecoration: 'none' }}
|
||||
onMouseEnter={e => { e.currentTarget.style.borderColor = '#ef4444'; e.currentTarget.style.boxShadow = '0 0 0 1px #ef444422' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
||||
>
|
||||
@@ -320,17 +320,17 @@ export default function AboutTab({ appVersion }: Props): React.ReactElement {
|
||||
<Bug size={20} style={{ color: '#ef4444' }} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>{t('settings.about.reportBug')}</div>
|
||||
<div className="text-xs" style={{ color: 'var(--text-faint)' }}>{t('settings.about.reportBugHint')}</div>
|
||||
<div className="text-sm font-semibold text-content">{t('settings.about.reportBug')}</div>
|
||||
<div className="text-xs text-content-faint">{t('settings.about.reportBugHint')}</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
|
||||
href="https://github.com/mauriceboe/TREK/discussions/new?category=feature-requests"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
|
||||
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={{ borderColor: 'var(--border-primary)', textDecoration: 'none' }}
|
||||
onMouseEnter={e => { e.currentTarget.style.borderColor = '#f59e0b'; e.currentTarget.style.boxShadow = '0 0 0 1px #f59e0b22' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
||||
>
|
||||
@@ -338,17 +338,17 @@ export default function AboutTab({ appVersion }: Props): React.ReactElement {
|
||||
<Lightbulb size={20} style={{ color: '#f59e0b' }} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>{t('settings.about.featureRequest')}</div>
|
||||
<div className="text-xs" style={{ color: 'var(--text-faint)' }}>{t('settings.about.featureRequestHint')}</div>
|
||||
<div className="text-sm font-semibold text-content">{t('settings.about.featureRequest')}</div>
|
||||
<div className="text-xs text-content-faint">{t('settings.about.featureRequestHint')}</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
|
||||
href="https://github.com/mauriceboe/TREK/wiki"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
|
||||
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={{ borderColor: 'var(--border-primary)', textDecoration: 'none' }}
|
||||
onMouseEnter={e => { e.currentTarget.style.borderColor = '#6366f1'; e.currentTarget.style.boxShadow = '0 0 0 1px #6366f122' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
||||
>
|
||||
@@ -356,10 +356,10 @@ export default function AboutTab({ appVersion }: Props): React.ReactElement {
|
||||
<BookOpen size={20} style={{ color: '#6366f1' }} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>Wiki</div>
|
||||
<div className="text-xs" style={{ color: 'var(--text-faint)' }}>{t('settings.about.wikiHint')}</div>
|
||||
<div className="text-sm font-semibold text-content">Wiki</div>
|
||||
<div className="text-xs text-content-faint">{t('settings.about.wikiHint')}</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>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -172,7 +172,7 @@ export default function AccountTab(): React.ReactElement {
|
||||
|
||||
{/* Change Password */}
|
||||
{!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>
|
||||
<div className="space-y-3">
|
||||
<input
|
||||
@@ -224,25 +224,24 @@ export default function AccountTab(): React.ReactElement {
|
||||
)}
|
||||
|
||||
{/* 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">
|
||||
<KeyRound className="w-5 h-5" style={{ color: 'var(--text-secondary)' }} />
|
||||
<h3 className="font-semibold text-base m-0" style={{ color: 'var(--text-primary)' }}>{t('settings.mfa.title')}</h3>
|
||||
<KeyRound className="w-5 h-5 text-content-secondary" />
|
||||
<h3 className="font-semibold text-base m-0 text-content">{t('settings.mfa.title')}</h3>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{mfaRequiredByPolicy && (
|
||||
<div className="flex gap-3 p-3 rounded-lg border text-sm"
|
||||
style={{ background: 'var(--bg-secondary)', borderColor: 'var(--border-primary)', color: 'var(--text-primary)' }}>
|
||||
<div className="flex gap-3 p-3 rounded-lg border text-sm bg-surface-secondary border-edge text-content">
|
||||
<AlertTriangle className="w-5 h-5 flex-shrink-0 text-amber-600" />
|
||||
<p className="m-0 leading-relaxed">{t('settings.mfa.requiredByPolicy')}</p>
|
||||
</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 ? (
|
||||
<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')}
|
||||
</p>
|
||||
|
||||
@@ -263,8 +262,7 @@ export default function AccountTab(): React.ReactElement {
|
||||
setMfaLoading(false)
|
||||
}
|
||||
}}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors"
|
||||
style={{ border: '1px solid var(--border-primary)', background: 'var(--bg-card)', color: 'var(--text-primary)' }}
|
||||
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"
|
||||
>
|
||||
{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')}
|
||||
@@ -273,11 +271,11 @@ export default function AccountTab(): React.ReactElement {
|
||||
|
||||
{!user?.mfa_enabled && mfaQr && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm" style={{ color: 'var(--text-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! }} />
|
||||
<p className="text-sm text-content-muted">{t('settings.mfa.scanQr')}</p>
|
||||
<div className="rounded-lg border mx-auto block overflow-hidden border-edge" style={{ width: 'fit-content' }} dangerouslySetInnerHTML={{ __html: mfaQr! }} />
|
||||
<div>
|
||||
<label className="block text-xs font-medium mb-1" style={{ color: 'var(--text-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>
|
||||
<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 bg-surface-hover text-content">{mfaSecret}</code>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
@@ -318,8 +316,7 @@ export default function AccountTab(): React.ReactElement {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setMfaQr(null); setMfaSecret(null); setMfaSetupCode('') }}
|
||||
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('settings.mfa.cancelSetup')}
|
||||
</button>
|
||||
@@ -329,8 +326,8 @@ export default function AccountTab(): React.ReactElement {
|
||||
|
||||
{user?.mfa_enabled && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>{t('settings.mfa.disableTitle')}</p>
|
||||
<p className="text-xs" style={{ color: 'var(--text-muted)' }}>{t('settings.mfa.disableHint')}</p>
|
||||
<p className="text-sm font-medium text-content-secondary">{t('settings.mfa.disableTitle')}</p>
|
||||
<p className="text-xs text-content-muted">{t('settings.mfa.disableHint')}</p>
|
||||
<input
|
||||
type="password"
|
||||
value={mfaDisablePwd}
|
||||
@@ -373,22 +370,22 @@ export default function AccountTab(): React.ReactElement {
|
||||
)}
|
||||
|
||||
{backupCodes && backupCodes.length > 0 && (
|
||||
<div className="space-y-3 p-3 rounded-lg border" style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-hover)' }}>
|
||||
<p className="text-sm font-semibold m-0" style={{ color: 'var(--text-primary)' }}>{t('settings.mfa.backupTitle')}</p>
|
||||
<p className="text-xs m-0" style={{ color: 'var(--text-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>
|
||||
<div className="space-y-3 p-3 rounded-lg border border-edge bg-surface-hover">
|
||||
<p className="text-sm font-semibold m-0 text-content">{t('settings.mfa.backupTitle')}</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 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>
|
||||
<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')}
|
||||
</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')}
|
||||
</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')}
|
||||
</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')}
|
||||
</button>
|
||||
</div>
|
||||
@@ -405,11 +402,10 @@ export default function AccountTab(): React.ReactElement {
|
||||
{user?.avatar_url ? (
|
||||
<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%',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 24, fontWeight: 700,
|
||||
background: 'var(--bg-hover)', color: 'var(--text-secondary)',
|
||||
}}>
|
||||
{user?.username?.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
@@ -447,8 +443,8 @@ export default function AccountTab(): React.ReactElement {
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="text-sm" style={{ color: 'var(--text-muted)' }}>
|
||||
<span className="font-medium" style={{ display: 'inline-flex', alignItems: 'center', gap: 4, color: 'var(--text-secondary)' }}>
|
||||
<div className="text-sm text-content-muted">
|
||||
<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')}
|
||||
</span>
|
||||
{(user as UserWithOidc)?.oidc_issuer && (
|
||||
@@ -462,7 +458,7 @@ export default function AccountTab(): React.ReactElement {
|
||||
)}
|
||||
</div>
|
||||
{(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(/\/+$/, '')}
|
||||
</p>
|
||||
)}
|
||||
@@ -510,25 +506,25 @@ export default function AccountTab(): React.ReactElement {
|
||||
background: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(4px)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24,
|
||||
}} onClick={() => setShowDeleteConfirm(false)}>
|
||||
<div style={{
|
||||
background: 'var(--bg-card)', borderRadius: 16, padding: '28px 24px',
|
||||
<div className="bg-surface-card" style={{
|
||||
borderRadius: 16, padding: '28px 24px',
|
||||
maxWidth: 400, width: '100%', boxShadow: '0 20px 60px rgba(0,0,0,0.3)',
|
||||
}} onClick={e => e.stopPropagation()}>
|
||||
<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' }}>
|
||||
<Shield size={18} style={{ color: '#d97706' }} />
|
||||
</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>
|
||||
<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')}
|
||||
</p>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
onClick={() => setShowDeleteConfirm(false)}
|
||||
className="border border-edge bg-surface-card text-content-secondary"
|
||||
style={{
|
||||
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',
|
||||
}}
|
||||
>
|
||||
@@ -546,25 +542,25 @@ export default function AccountTab(): React.ReactElement {
|
||||
background: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(4px)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24,
|
||||
}} onClick={() => setShowDeleteConfirm(false)}>
|
||||
<div style={{
|
||||
background: 'var(--bg-card)', borderRadius: 16, padding: '28px 24px',
|
||||
<div className="bg-surface-card" style={{
|
||||
borderRadius: 16, padding: '28px 24px',
|
||||
maxWidth: 400, width: '100%', boxShadow: '0 20px 60px rgba(0,0,0,0.3)',
|
||||
}} onClick={e => e.stopPropagation()}>
|
||||
<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' }}>
|
||||
<Trash2 size={18} style={{ color: '#ef4444' }} />
|
||||
</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>
|
||||
<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')}
|
||||
</p>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
|
||||
<button
|
||||
onClick={() => setShowDeleteConfirm(false)}
|
||||
className="border border-edge bg-surface-card text-content-secondary"
|
||||
style={{
|
||||
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',
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -301,21 +301,21 @@ function IntegrationsMcpSection(props: any) {
|
||||
<Section title={t('settings.mcp.title')} icon={Terminal}>
|
||||
{/* Endpoint URL */}
|
||||
<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">
|
||||
<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}
|
||||
</code>
|
||||
<button onClick={() => handleCopy(mcpEndpoint, 'endpoint')}
|
||||
className="p-2 rounded-lg border transition-colors hover:bg-slate-100 dark:hover:bg-slate-700"
|
||||
style={{ borderColor: 'var(--border-primary)' }} 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)' }} />}
|
||||
className="p-2 rounded-lg border border-edge transition-colors hover:bg-slate-100 dark:hover:bg-slate-700"
|
||||
title={t('settings.mcp.copy')}>
|
||||
{copiedKey === 'endpoint' ? <Check className="w-4 h-4 text-green-500" /> : <Copy className="w-4 h-4 text-content-secondary" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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
|
||||
onClick={() => setActiveMcpTab('oauth')}
|
||||
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' && (
|
||||
<>
|
||||
{/* 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
|
||||
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"
|
||||
style={{ background: 'var(--bg-secondary)' }}>
|
||||
<span className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>{t('settings.mcp.clientConfig')}</span>
|
||||
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">
|
||||
<span className="text-sm font-medium text-content-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)' }} />}
|
||||
</button>
|
||||
{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">
|
||||
<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"
|
||||
style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
|
||||
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">
|
||||
{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')}
|
||||
</button>
|
||||
</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}
|
||||
</pre>
|
||||
<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>
|
||||
|
||||
{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')}
|
||||
</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) => (
|
||||
<div key={client.id} className="px-4 py-3"
|
||||
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)' }} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<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 && (
|
||||
<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)' }}>
|
||||
@@ -403,13 +401,13 @@ function IntegrationsMcpSection(props: any) {
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1 mt-1.5">
|
||||
{(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 && (
|
||||
<button
|
||||
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"
|
||||
style={{ color: 'var(--text-tertiary)', border: '1px solid var(--border-primary)' }}>
|
||||
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)' }}>
|
||||
{oauthScopesExpanded[client.id] ? '−' : `+${client.allowed_scopes.length - 5}`}
|
||||
</button>
|
||||
)}
|
||||
@@ -435,21 +433,21 @@ function IntegrationsMcpSection(props: any) {
|
||||
{/* Active OAuth Sessions */}
|
||||
{oauthSessions.length > 0 && (
|
||||
<div>
|
||||
<label className="text-sm font-medium block mb-2" style={{ color: 'var(--text-secondary)' }}>{t('settings.oauth.activeSessions')}</label>
|
||||
<div className="rounded-lg border overflow-hidden" style={{ borderColor: 'var(--border-primary)' }}>
|
||||
<label className="text-sm font-medium block mb-2 text-content-secondary">{t('settings.oauth.activeSessions')}</label>
|
||||
<div className="rounded-lg border overflow-hidden border-edge">
|
||||
{oauthSessions.map((session, i) => (
|
||||
<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 }}>
|
||||
<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)' }}>
|
||||
{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>
|
||||
</p>
|
||||
</div>
|
||||
<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"
|
||||
style={{ borderColor: 'var(--border-primary)', color: 'var(--text-tertiary)' }}>
|
||||
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={{ color: 'var(--text-tertiary)' }}>
|
||||
{t('settings.oauth.revoke')}
|
||||
</button>
|
||||
</div>
|
||||
@@ -469,25 +467,23 @@ function IntegrationsMcpSection(props: any) {
|
||||
</div>
|
||||
|
||||
{/* 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
|
||||
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"
|
||||
style={{ background: 'var(--bg-secondary)' }}>
|
||||
<span className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>{t('settings.mcp.clientConfig')}</span>
|
||||
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">
|
||||
<span className="text-sm font-medium text-content-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)' }} />}
|
||||
</button>
|
||||
{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">
|
||||
<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"
|
||||
style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
|
||||
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">
|
||||
{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')}
|
||||
</button>
|
||||
</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}
|
||||
</pre>
|
||||
<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">
|
||||
<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"
|
||||
style={{ background: 'var(--bg-tertiary, #e5e7eb)', color: 'var(--text-secondary)' }}>
|
||||
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)' }}>
|
||||
<Plus className="w-3.5 h-3.5" /> {t('settings.mcp.createToken')}
|
||||
</button>
|
||||
</div>
|
||||
@@ -508,12 +504,12 @@ function IntegrationsMcpSection(props: any) {
|
||||
{t('settings.mcp.noTokens')}
|
||||
</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) => (
|
||||
<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 }}>
|
||||
<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)' }}>
|
||||
{token.token_prefix}...
|
||||
<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 && (
|
||||
<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) }}>
|
||||
<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 ? (
|
||||
<>
|
||||
<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>
|
||||
<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)}
|
||||
onKeyDown={e => e.key === 'Enter' && handleCreateMcpToken()}
|
||||
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"
|
||||
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-secondary)', color: 'var(--text-primary)' }}
|
||||
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"
|
||||
autoFocus />
|
||||
</div>
|
||||
<div className="flex gap-2 justify-end pt-1">
|
||||
<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')}
|
||||
</button>
|
||||
<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)' }}>
|
||||
<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 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}
|
||||
</pre>
|
||||
<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"
|
||||
style={{ color: 'var(--text-secondary)' }} title={t('settings.mcp.copy')}>
|
||||
className="absolute top-2 right-2 p-1.5 rounded transition-colors hover:bg-slate-200 dark:hover:bg-slate-600 text-content-secondary"
|
||||
title={t('settings.mcp.copy')}>
|
||||
{copiedKey === 'new-token' ? <Check className="w-4 h-4 text-green-500" /> : <Copy className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
@@ -604,12 +599,12 @@ function McpTokenModals(props: any) {
|
||||
{mcpDeleteId !== null && (
|
||||
<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) }}>
|
||||
<div className="rounded-xl shadow-xl w-full max-w-sm p-6 space-y-4" style={{ background: 'var(--bg-card)' }}>
|
||||
<h3 className="text-base font-semibold" style={{ color: 'var(--text-primary)' }}>{t('settings.mcp.deleteTokenTitle')}</h3>
|
||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>{t('settings.mcp.deleteTokenMessage')}</p>
|
||||
<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 text-content">{t('settings.mcp.deleteTokenTitle')}</h3>
|
||||
<p className="text-sm text-content-secondary">{t('settings.mcp.deleteTokenMessage')}</p>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<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')}
|
||||
</button>
|
||||
<button onClick={() => handleDeleteMcpToken(mcpDeleteId)}
|
||||
@@ -635,10 +630,10 @@ function OAuthClientModals(props: any) {
|
||||
{oauthCreateOpen && (
|
||||
<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) }}>
|
||||
<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 ? (
|
||||
<>
|
||||
<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>
|
||||
<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)
|
||||
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"
|
||||
style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)', background: 'var(--bg-secondary)' }}>
|
||||
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">
|
||||
{preset.label}
|
||||
</button>
|
||||
))}
|
||||
@@ -661,11 +655,10 @@ function OAuthClientModals(props: any) {
|
||||
</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)}
|
||||
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"
|
||||
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-secondary)', color: 'var(--text-primary)' }}
|
||||
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"
|
||||
autoFocus />
|
||||
</div>
|
||||
|
||||
@@ -673,32 +666,31 @@ function OAuthClientModals(props: any) {
|
||||
<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" />
|
||||
<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>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{!oauthIsMachine && (
|
||||
<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)}
|
||||
placeholder={t('settings.oauth.modal.redirectUrisPlaceholder')}
|
||||
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"
|
||||
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-secondary)', color: 'var(--text-primary)' }} />
|
||||
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" />
|
||||
<p className="mt-1 text-xs" style={{ color: 'var(--text-tertiary)' }}>{t('settings.oauth.modal.redirectUrisHint')}</p>
|
||||
</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>
|
||||
<ScopeGroupPicker selected={oauthNewScopes} onChange={setOauthNewScopes} />
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 justify-end pt-1">
|
||||
<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')}
|
||||
</button>
|
||||
<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)' }}>
|
||||
<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 className="space-y-3">
|
||||
<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">
|
||||
<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}
|
||||
</code>
|
||||
<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"
|
||||
style={{ borderColor: 'var(--border-primary)' }}>
|
||||
{copiedKey === 'new-client-id' ? <Check className="w-4 h-4 text-green-500" /> : <Copy className="w-4 h-4" style={{ color: 'var(--text-secondary)' }} />}
|
||||
className="p-2 rounded-lg border transition-colors hover:bg-slate-100 dark:hover:bg-slate-700 border-edge">
|
||||
{copiedKey === 'new-client-id' ? <Check className="w-4 h-4 text-green-500" /> : <Copy className="w-4 h-4 text-content-secondary" />}
|
||||
</button>
|
||||
</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">
|
||||
<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}
|
||||
</code>
|
||||
<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"
|
||||
style={{ borderColor: 'var(--border-primary)' }}>
|
||||
{copiedKey === 'new-client-secret' ? <Check className="w-4 h-4 text-green-500" /> : <Copy className="w-4 h-4" style={{ color: 'var(--text-secondary)' }} />}
|
||||
className="p-2 rounded-lg border transition-colors hover:bg-slate-100 dark:hover:bg-slate-700 border-edge">
|
||||
{copiedKey === 'new-client-secret' ? <Check className="w-4 h-4 text-green-500" /> : <Copy className="w-4 h-4 text-content-secondary" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{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')}
|
||||
</div>
|
||||
)}
|
||||
@@ -767,12 +757,12 @@ function OAuthClientModals(props: any) {
|
||||
{oauthDeleteId !== null && (
|
||||
<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) }}>
|
||||
<div className="rounded-xl shadow-xl w-full max-w-sm p-6 space-y-4" style={{ background: 'var(--bg-card)' }}>
|
||||
<h3 className="text-base font-semibold" style={{ color: 'var(--text-primary)' }}>{t('settings.oauth.deleteClient')}</h3>
|
||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>{t('settings.oauth.deleteClientMessage')}</p>
|
||||
<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 text-content">{t('settings.oauth.deleteClient')}</h3>
|
||||
<p className="text-sm text-content-secondary">{t('settings.oauth.deleteClientMessage')}</p>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<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')}
|
||||
</button>
|
||||
<button onClick={() => handleDeleteOAuthClient(oauthDeleteId)}
|
||||
@@ -788,12 +778,12 @@ function OAuthClientModals(props: any) {
|
||||
{oauthRotateId !== null && (
|
||||
<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) }}>
|
||||
<div className="rounded-xl shadow-xl w-full max-w-sm p-6 space-y-4" style={{ background: 'var(--bg-card)' }}>
|
||||
<h3 className="text-base font-semibold" style={{ color: 'var(--text-primary)' }}>{t('settings.oauth.rotateSecret')}</h3>
|
||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>{t('settings.oauth.rotateSecretMessage')}</p>
|
||||
<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 text-content">{t('settings.oauth.rotateSecret')}</h3>
|
||||
<p className="text-sm text-content-secondary">{t('settings.oauth.rotateSecretMessage')}</p>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<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')}
|
||||
</button>
|
||||
<button onClick={() => handleRotateSecret(oauthRotateId)} disabled={oauthRotating}
|
||||
@@ -808,22 +798,21 @@ function OAuthClientModals(props: any) {
|
||||
{/* Rotated Secret display */}
|
||||
{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="rounded-xl shadow-xl w-full max-w-md p-6 space-y-4" style={{ background: 'var(--bg-card)' }}>
|
||||
<h3 className="text-lg font-semibold" style={{ color: 'var(--text-primary)' }}>{t('settings.oauth.rotateSecretDoneTitle')}</h3>
|
||||
<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 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)' }}>
|
||||
<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>
|
||||
<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">
|
||||
<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}
|
||||
</code>
|
||||
<button onClick={() => handleCopy(oauthRotatedSecret, 'rotated-secret')}
|
||||
className="p-2 rounded-lg border transition-colors hover:bg-slate-100 dark:hover:bg-slate-700"
|
||||
style={{ borderColor: 'var(--border-primary)' }}>
|
||||
{copiedKey === 'rotated-secret' ? <Check className="w-4 h-4 text-green-500" /> : <Copy className="w-4 h-4" style={{ color: 'var(--text-secondary)' }} />}
|
||||
className="p-2 rounded-lg border transition-colors hover:bg-slate-100 dark:hover:bg-slate-700 border-edge">
|
||||
{copiedKey === 'rotated-secret' ? <Check className="w-4 h-4 text-green-500" /> : <Copy className="w-4 h-4 text-content-secondary" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -841,12 +830,12 @@ function OAuthClientModals(props: any) {
|
||||
{oauthRevokeId !== null && (
|
||||
<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) }}>
|
||||
<div className="rounded-xl shadow-xl w-full max-w-sm p-6 space-y-4" style={{ background: 'var(--bg-card)' }}>
|
||||
<h3 className="text-base font-semibold" style={{ color: 'var(--text-primary)' }}>{t('settings.oauth.revokeSession')}</h3>
|
||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>{t('settings.oauth.revokeSessionMessage')}</p>
|
||||
<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 text-content">{t('settings.oauth.revokeSession')}</h3>
|
||||
<p className="text-sm text-content-secondary">{t('settings.oauth.revokeSessionMessage')}</p>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<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')}
|
||||
</button>
|
||||
<button onClick={() => handleRevokeSession(oauthRevokeId)}
|
||||
|
||||
@@ -409,7 +409,7 @@ function DetailPane({ item, tripId, categories, members, onClose }: {
|
||||
value={String(assignedUserId ?? '')}
|
||||
onChange={v => setAssignedUserId(v ? Number(v) : null)}
|
||||
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 => ({
|
||||
value: String(m.id),
|
||||
label: m.username,
|
||||
@@ -600,7 +600,7 @@ function NewTaskPane({ tripId, categories, members, defaultCategory, onCreated,
|
||||
value={String(assignedUserId ?? '')}
|
||||
onChange={v => setAssignedUserId(v ? Number(v) : null)}
|
||||
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 => ({
|
||||
value: String(m.id), label: m.username,
|
||||
icon: m.avatar ? (
|
||||
|
||||
@@ -111,12 +111,14 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
|
||||
const createdTrip = result ? result.trip : undefined
|
||||
// Add selected members for newly created trips
|
||||
if (selectedMembers.length > 0 && createdTrip?.id) {
|
||||
let memberAddFailed = false
|
||||
for (const userId of selectedMembers) {
|
||||
const user = allUsers.find(u => u.id === userId)
|
||||
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
|
||||
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)
|
||||
onCoverUpdate?.(createdTrip.id, data.cover_image)
|
||||
} 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()
|
||||
@@ -383,6 +386,7 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 8 }}>
|
||||
{existingMembers.map(m => (
|
||||
<span key={m.id}
|
||||
className="bg-surface-secondary text-content border border-edge"
|
||||
onClick={async () => {
|
||||
if (m.id === currentUser?.id) return
|
||||
try {
|
||||
@@ -393,12 +397,11 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
|
||||
}}
|
||||
style={{
|
||||
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',
|
||||
border: '1px solid var(--border-primary)',
|
||||
}}>
|
||||
{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>
|
||||
))}
|
||||
</div>
|
||||
@@ -411,13 +414,13 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
|
||||
if (!user) return null
|
||||
return (
|
||||
<span key={uid} onClick={() => setSelectedMembers(prev => prev.filter(id => id !== uid))}
|
||||
className="bg-surface-secondary text-content border border-edge cursor-pointer"
|
||||
style={{
|
||||
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',
|
||||
border: '1px solid var(--border-primary)',
|
||||
fontSize: 12, fontWeight: 500,
|
||||
}}>
|
||||
{user.username}
|
||||
<X size={11} style={{ color: 'var(--text-faint)' }} />
|
||||
<X size={11} className="text-content-faint" />
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -67,7 +67,7 @@ function ShareLinkSection({ tripId, t }: { tripId: number; t: (key: string, para
|
||||
const newPerms = { ...perms, [key]: val }
|
||||
setPerms(newPerms)
|
||||
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 {
|
||||
await shareApi.deleteLink(tripId)
|
||||
setShareToken(null)
|
||||
} catch {}
|
||||
} catch { toast.error(t('common.error')) }
|
||||
}
|
||||
|
||||
const handleCopy = () => {
|
||||
@@ -92,10 +92,10 @@ function ShareLinkSection({ tripId, t }: { tripId: number; t: (key: string, para
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 10 }}>
|
||||
<Link2 size={14} style={{ color: 'var(--text-muted)' }} />
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>{t('share.linkTitle')}</span>
|
||||
<Link2 size={14} className="text-content-muted" />
|
||||
<span className="text-content" style={{ fontSize: 13, fontWeight: 600 }}>{t('share.linkTitle')}</span>
|
||||
</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 */}
|
||||
<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 ? (
|
||||
<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',
|
||||
background: 'var(--bg-tertiary)', borderRadius: 8, border: '1px solid var(--border-faint)',
|
||||
borderRadius: 8,
|
||||
}}>
|
||||
<input type="text" value={shareUrl} readOnly style={{
|
||||
flex: 1, border: 'none', background: 'none', fontSize: 11, color: 'var(--text-primary)',
|
||||
<input type="text" value={shareUrl} readOnly className="text-content" style={{
|
||||
flex: 1, border: 'none', background: 'none', fontSize: 11,
|
||||
outline: 'none', fontFamily: 'monospace',
|
||||
}} />
|
||||
<button onClick={handleCopy} style={{
|
||||
@@ -150,10 +150,10 @@ function ShareLinkSection({ tripId, t }: { tripId: number; t: (key: string, para
|
||||
</button>
|
||||
</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,
|
||||
width: '100%', padding: '8px 0', borderRadius: 8, border: '1px dashed var(--border-primary)',
|
||||
background: 'none', color: 'var(--text-muted)', fontSize: 12, fontWeight: 500,
|
||||
width: '100%', padding: '8px 0', borderRadius: 8,
|
||||
background: 'none', fontSize: 12, fontWeight: 500,
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}>
|
||||
<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 }}>
|
||||
|
||||
{/* Trip name */}
|
||||
<div style={{ padding: '10px 14px', background: 'var(--bg-secondary)', borderRadius: 10, border: '1px solid var(--border-secondary)' }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', 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="bg-surface-secondary border border-edge-secondary" style={{ padding: '10px 14px', borderRadius: 10 }}>
|
||||
<div className="text-content-faint" style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 2 }}>{t('nav.trip')}</div>
|
||||
<div className="text-content" style={{ fontSize: 14, fontWeight: 600 }}>{tripTitle}</div>
|
||||
</div>
|
||||
|
||||
{/* Add member dropdown */}
|
||||
{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')}
|
||||
</label>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
@@ -306,15 +306,15 @@ export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle }:
|
||||
</button>
|
||||
</div>
|
||||
{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>}
|
||||
|
||||
{/* Members list */}
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 10 }}>
|
||||
<Users size={13} style={{ color: 'var(--text-faint)' }} />
|
||||
<span style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-secondary)' }}>
|
||||
<Users size={13} className="text-content-faint" />
|
||||
<span className="text-content-secondary" style={{ fontSize: 12, fontWeight: 600 }}>
|
||||
{t('members.access')} ({allMembers.length} {allMembers.length === 1 ? t('members.person') : t('members.persons')})
|
||||
</span>
|
||||
</div>
|
||||
@@ -322,7 +322,7 @@ export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle }:
|
||||
{loading ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{[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>
|
||||
) : (
|
||||
@@ -331,16 +331,15 @@ export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle }:
|
||||
const isSelf = member.id === user?.id
|
||||
const canRemove = isSelf || (canManageMembers && member.role !== 'owner')
|
||||
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,
|
||||
padding: '8px 12px', borderRadius: 10, background: 'var(--bg-secondary)',
|
||||
border: '1px solid var(--border-secondary)',
|
||||
padding: '8px 12px', borderRadius: 10,
|
||||
}}>
|
||||
<Avatar username={member.username} avatarUrl={member.avatar_url} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>{member.username}</span>
|
||||
{isSelf && <span style={{ fontSize: 10, color: 'var(--text-faint)' }}>({t('members.you')})</span>}
|
||||
<span className="text-content" style={{ fontSize: 13, fontWeight: 600 }}>{member.username}</span>
|
||||
{isSelf && <span className="text-content-faint" style={{ fontSize: 10 }}>({t('members.you')})</span>}
|
||||
{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 }}>
|
||||
<Crown size={9} /> {t('members.owner')}
|
||||
@@ -370,7 +369,7 @@ export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle }:
|
||||
</div>
|
||||
|
||||
{/* 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} />
|
||||
</div>}
|
||||
|
||||
|
||||
@@ -91,7 +91,7 @@ export default function VacayCalendar() {
|
||||
|
||||
{/* 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="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
|
||||
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)]"
|
||||
|
||||
@@ -60,12 +60,12 @@ export default function VacayMonthCard({
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border overflow-hidden" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
|
||||
<div className="px-3 py-2 border-b" style={{ borderColor: 'var(--border-secondary)' }}>
|
||||
<span className="text-xs font-semibold" style={{ color: 'var(--text-primary)', textTransform: 'capitalize' }}>{monthName}</span>
|
||||
<div className="rounded-xl border overflow-hidden bg-surface-card border-edge">
|
||||
<div className="px-3 py-2 border-b border-edge-secondary">
|
||||
<span className="text-xs font-semibold capitalize text-content">{monthName}</span>
|
||||
</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) => {
|
||||
// Map column index back to JS day (0=Sun..6=Sat) to check if it's a weekend column
|
||||
const jsDay = (i + weekStart) % 7
|
||||
|
||||
@@ -64,11 +64,11 @@ export default function VacayPersons() {
|
||||
const editingUserColor = users.find(u => u.id === colorEditUserId)?.color || '#6366f1'
|
||||
|
||||
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">
|
||||
<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() }}
|
||||
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} />
|
||||
</button>
|
||||
</div>
|
||||
@@ -91,12 +91,12 @@ export default function VacayPersons() {
|
||||
style={{ backgroundColor: u.color, cursor: 'pointer' }}
|
||||
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.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>
|
||||
{isSelected && isFused && (
|
||||
<Check size={12} style={{ color: 'var(--text-primary)' }} />
|
||||
<Check size={12} className="text-content" />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
@@ -104,15 +104,14 @@ export default function VacayPersons() {
|
||||
|
||||
{/* Pending invites */}
|
||||
{pendingInvites.map(inv => (
|
||||
<div key={inv.id} className="flex items-center gap-2 px-2.5 py-1.5 rounded-lg group"
|
||||
style={{ background: 'var(--bg-secondary)', opacity: 0.7 }}>
|
||||
<Clock size={12} style={{ color: 'var(--text-faint)' }} />
|
||||
<span className="text-xs flex-1 truncate" style={{ color: 'var(--text-muted)' }}>
|
||||
<div key={inv.id} className="flex items-center gap-2 px-2.5 py-1.5 rounded-lg group bg-surface-secondary"
|
||||
style={{ opacity: 0.7 }}>
|
||||
<Clock size={12} className="text-content-faint" />
|
||||
<span className="text-xs flex-1 truncate text-content-muted">
|
||||
{inv.username} <span className="text-[10px]">({t('vacay.pending')})</span>
|
||||
</span>
|
||||
<button onClick={() => cancelInvite(inv.user_id)}
|
||||
className="opacity-0 group-hover:opacity-100 text-[10px] px-1.5 py-0.5 rounded transition-all"
|
||||
style={{ color: 'var(--text-faint)' }}>
|
||||
className="opacity-0 group-hover:opacity-100 text-[10px] px-1.5 py-0.5 rounded transition-all text-content-faint">
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
</div>
|
||||
@@ -123,18 +122,18 @@ export default function VacayPersons() {
|
||||
{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 }}
|
||||
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()}>
|
||||
<div className="flex items-center justify-between p-5" style={{ borderBottom: '1px solid var(--border-secondary)' }}>
|
||||
<h2 className="text-base font-semibold" style={{ color: 'var(--text-primary)' }}>{t('vacay.inviteUser')}</h2>
|
||||
<button onClick={() => setShowInvite(false)} className="p-1.5 rounded-lg transition-colors" style={{ color: 'var(--text-faint)' }}>
|
||||
<div className="flex items-center justify-between p-5 border-b border-edge-secondary">
|
||||
<h2 className="text-base font-semibold text-content">{t('vacay.inviteUser')}</h2>
|
||||
<button onClick={() => setShowInvite(false)} className="p-1.5 rounded-lg transition-colors text-content-faint">
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<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 ? (
|
||||
<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
|
||||
value={selectedInviteUser}
|
||||
@@ -145,8 +144,7 @@ export default function VacayPersons() {
|
||||
/>
|
||||
)}
|
||||
<div className="flex gap-3 justify-end pt-2">
|
||||
<button onClick={() => setShowInvite(false)} className="px-4 py-2 text-sm rounded-lg"
|
||||
style={{ color: 'var(--text-muted)', border: '1px solid var(--border-primary)' }}>
|
||||
<button onClick={() => setShowInvite(false)} className="px-4 py-2 text-sm rounded-lg text-content-muted border border-edge">
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button onClick={handleInvite} disabled={!selectedInviteUser || inviting}
|
||||
@@ -166,11 +164,11 @@ export default function VacayPersons() {
|
||||
{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 }}
|
||||
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()}>
|
||||
<div className="flex items-center justify-between p-5" style={{ borderBottom: '1px solid var(--border-secondary)' }}>
|
||||
<h2 className="text-base font-semibold" style={{ color: 'var(--text-primary)' }}>{t('vacay.changeColor')}</h2>
|
||||
<button onClick={() => { setShowColorPicker(false); setColorEditUserId(null) }} className="p-1.5 rounded-lg transition-colors" style={{ color: 'var(--text-faint)' }}>
|
||||
<div className="flex items-center justify-between p-5 border-b border-edge-secondary">
|
||||
<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 text-content-faint">
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -52,7 +52,7 @@ export default function VacaySettings({ onClose }: VacaySettingsProps) {
|
||||
{/* Weekend days selector */}
|
||||
{plan.block_weekends !== false && (
|
||||
<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">
|
||||
{[
|
||||
{ day: 1, label: t('vacay.mon') },
|
||||
@@ -88,10 +88,10 @@ export default function VacaySettings({ onClose }: VacaySettingsProps) {
|
||||
{/* Week start */}
|
||||
<div>
|
||||
<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 }}>
|
||||
<span className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>{t('vacay.weekStart')}</span>
|
||||
<p className="text-xs mt-0.5" style={{ color: 'var(--text-faint)' }}>{t('vacay.weekStartHint')}</p>
|
||||
<span className="text-sm font-medium text-content">{t('vacay.weekStart')}</span>
|
||||
<p className="text-xs mt-0.5 text-content-faint">{t('vacay.weekStartHint')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<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 && (
|
||||
<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)' }}>
|
||||
<AlertCircle size={12} style={{ color: 'var(--text-faint)' }} />
|
||||
<span className="text-[10px]" style={{ color: 'var(--text-faint)' }}>{t('vacay.companyHolidaysNoDeduct')}</span>
|
||||
<div className="flex items-center gap-1.5 px-2 py-1.5 rounded-md bg-surface-secondary">
|
||||
<AlertCircle size={12} className="text-content-faint" />
|
||||
<span className="text-[10px] text-content-faint">{t('vacay.companyHolidaysNoDeduct')}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -156,7 +156,7 @@ export default function VacaySettings({ onClose }: VacaySettingsProps) {
|
||||
{plan.holidays_enabled && (
|
||||
<div className="ml-7 mt-2 space-y-2">
|
||||
{(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 => (
|
||||
<CalendarRow
|
||||
@@ -178,8 +178,7 @@ export default function VacaySettings({ onClose }: VacaySettingsProps) {
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setShowAddForm(true)}
|
||||
className="flex items-center gap-1.5 text-xs px-2 py-1.5 rounded-md transition-colors"
|
||||
style={{ color: 'var(--text-muted)', background: 'var(--bg-secondary)' }}
|
||||
className="flex items-center gap-1.5 text-xs px-2 py-1.5 rounded-md transition-colors text-content-muted bg-surface-secondary"
|
||||
>
|
||||
<Plus size={12} />
|
||||
{t('vacay.addCalendar')}
|
||||
@@ -191,22 +190,22 @@ export default function VacaySettings({ onClose }: VacaySettingsProps) {
|
||||
|
||||
{/* Dissolve fusion */}
|
||||
{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="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)' }}>
|
||||
<Unlink size={16} className="text-red-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>{t('vacay.dissolve')}</p>
|
||||
<p className="text-[11px]" style={{ color: 'var(--text-faint)' }}>{t('vacay.dissolveHint')}</p>
|
||||
<p className="text-sm font-semibold text-content">{t('vacay.dissolve')}</p>
|
||||
<p className="text-[11px] text-content-faint">{t('vacay.dissolveHint')}</p>
|
||||
</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)' }}>
|
||||
{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="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>
|
||||
@@ -241,17 +240,17 @@ function SettingToggle({ icon: Icon, label, hint, value, onChange }: SettingTogg
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<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">
|
||||
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>{label}</p>
|
||||
<p className="text-[11px]" style={{ color: 'var(--text-faint)' }}>{hint}</p>
|
||||
<p className="text-sm font-medium text-content">{label}</p>
|
||||
<p className="text-[11px] text-content-faint">{hint}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={onChange}
|
||||
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)' }}>
|
||||
<span className="absolute left-1 h-4 w-4 rounded-full transition-transform duration-200"
|
||||
style={{ background: 'var(--bg-card)', transform: value ? 'translateX(20px)' : 'translateX(0)' }} />
|
||||
<span className="absolute left-1 h-4 w-4 rounded-full transition-transform duration-200 bg-surface-card"
|
||||
style={{ transform: value ? 'translateX(20px)' : 'translateX(0)' }} />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
@@ -309,7 +308,7 @@ function CalendarRow({ cal, countries, onUpdate, onDelete }: {
|
||||
const [showColorPicker, setShowColorPicker] = useState(false)
|
||||
|
||||
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 }}>
|
||||
<button
|
||||
onClick={() => setShowColorPicker(!showColorPicker)}
|
||||
@@ -394,7 +393,7 @@ function AddCalendarForm({ countries, onAdd, onCancel }: {
|
||||
const [showColorPicker, setShowColorPicker] = useState(false)
|
||||
|
||||
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 }}>
|
||||
<button
|
||||
onClick={() => setShowColorPicker(!showColorPicker)}
|
||||
|
||||
@@ -14,16 +14,16 @@ export default function VacayStats() {
|
||||
useEffect(() => { loadStats(selectedYear) }, [selectedYear])
|
||||
|
||||
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">
|
||||
<Briefcase size={13} style={{ color: 'var(--text-faint)' }} />
|
||||
<span className="text-[11px] font-medium uppercase tracking-wider" style={{ color: 'var(--text-faint)' }}>
|
||||
<Briefcase size={13} className="text-content-faint" />
|
||||
<span className="text-[11px] font-medium uppercase tracking-wider text-content-faint">
|
||||
{t('vacay.entitlement')} {selectedYear}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{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">
|
||||
{stats.map(s => (
|
||||
@@ -73,16 +73,16 @@ function StatCard({ stat: s, isMe, canEdit, selectedYear, onSave, t }: StatCardP
|
||||
}
|
||||
|
||||
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">
|
||||
<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}
|
||||
{isMe && <span style={{ color: 'var(--text-faint)' }}> ({t('vacay.you')})</span>}
|
||||
{isMe && <span className="text-content-faint"> ({t('vacay.you')})</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 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
|
||||
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 }}
|
||||
@@ -99,8 +99,8 @@ function StatCard({ stat: s, isMe, canEdit, selectedYear, onSave, t }: StatCardP
|
||||
}}
|
||||
onClick={() => { if (canEdit && !editing) setEditing(true) }}
|
||||
>
|
||||
<div className="text-[10px] mb-1" style={{ color: 'var(--text-faint)', 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' }} />}
|
||||
<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 text-content-faint" style={{ verticalAlign: 'middle' }} />}
|
||||
</div>
|
||||
{editing ? (
|
||||
<input
|
||||
@@ -110,21 +110,21 @@ function StatCard({ stat: s, isMe, canEdit, selectedYear, onSave, t }: StatCardP
|
||||
onBlur={handleSave}
|
||||
onKeyDown={e => { if (e.key === 'Enter') handleSave(); if (e.key === 'Escape') { setEditing(false); setLocalDays(s.vacation_days) } }}
|
||||
autoFocus
|
||||
className="w-full bg-transparent text-sm font-bold outline-none p-0 m-0 [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none"
|
||||
style={{ color: 'var(--text-primary)', height: 18, lineHeight: '18px' }}
|
||||
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={{ 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>
|
||||
{/* Used */}
|
||||
<div className="rounded-md px-2 py-2" style={{ background: 'var(--bg-secondary)' }}>
|
||||
<div className="text-[10px] mb-1" style={{ color: 'var(--text-faint)', 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="rounded-md px-2 py-2 bg-surface-secondary">
|
||||
<div className="text-[10px] mb-1 text-content-faint" style={{ height: 14, lineHeight: '14px' }}>{t('vacay.used')}</div>
|
||||
<div className="text-sm font-bold text-content" style={{ height: 18, lineHeight: '18px' }}>{s.used}</div>
|
||||
</div>
|
||||
{/* Remaining */}
|
||||
<div className="rounded-md px-2 py-2" style={{ background: 'var(--bg-secondary)' }}>
|
||||
<div className="text-[10px] mb-1" style={{ color: 'var(--text-faint)', height: 14, lineHeight: '14px' }}>{t('vacay.remaining')}</div>
|
||||
<div className="rounded-md px-2 py-2 bg-surface-secondary">
|
||||
<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' }}>
|
||||
{s.remaining}
|
||||
</div>
|
||||
|
||||
@@ -46,8 +46,7 @@ export default function ConfirmDialog({
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="trek-modal-enter rounded-2xl shadow-2xl w-full max-w-sm p-6"
|
||||
style={{ background: 'var(--bg-card)' }}
|
||||
className="trek-modal-enter rounded-2xl shadow-2xl w-full max-w-sm p-6 bg-surface-card"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
@@ -57,10 +56,10 @@ export default function ConfirmDialog({
|
||||
</div>
|
||||
)}
|
||||
<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')}
|
||||
</h3>
|
||||
<p className="mt-1 text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||
<p className="mt-1 text-sm text-content-secondary">
|
||||
{message}
|
||||
</p>
|
||||
</div>
|
||||
@@ -69,11 +68,7 @@ export default function ConfirmDialog({
|
||||
<div className="flex justify-end gap-3 mt-6">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm font-medium rounded-lg transition-colors"
|
||||
style={{
|
||||
color: 'var(--text-secondary)',
|
||||
border: '1px solid var(--border-secondary)',
|
||||
}}
|
||||
className="px-4 py-2 text-sm font-medium rounded-lg transition-colors text-content-secondary border border-edge-secondary"
|
||||
>
|
||||
{cancelLabel || t('common.cancel')}
|
||||
</button>
|
||||
|
||||
@@ -47,25 +47,24 @@ export default function CopyTripDialog({ isOpen, tripTitle, onClose, onConfirm }
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="trek-modal-enter rounded-2xl shadow-2xl w-full max-w-md p-6"
|
||||
style={{ background: 'var(--bg-card)' }}
|
||||
className="trek-modal-enter rounded-2xl shadow-2xl w-full max-w-md p-6 bg-surface-card"
|
||||
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')}
|
||||
</h3>
|
||||
<p className="text-sm mb-4" style={{ color: 'var(--text-secondary)' }}>
|
||||
<p className="text-sm mb-4 text-content-secondary">
|
||||
{tripTitle}
|
||||
</p>
|
||||
|
||||
<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' }}>
|
||||
{t('dashboard.confirm.copy.willCopy')}
|
||||
</p>
|
||||
<ul className="flex flex-col gap-1">
|
||||
{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' }} />
|
||||
{t(key)}
|
||||
</li>
|
||||
@@ -73,14 +72,14 @@ export default function CopyTripDialog({ isOpen, tripTitle, onClose, onConfirm }
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl p-3" style={{ background: 'var(--bg-subtle)', border: '1px solid var(--border-secondary)' }}>
|
||||
<p className="text-xs font-semibold uppercase tracking-wide mb-2" style={{ color: 'var(--text-muted)' }}>
|
||||
<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 text-content-muted">
|
||||
{t('dashboard.confirm.copy.wontCopy')}
|
||||
</p>
|
||||
<ul className="flex flex-col gap-1">
|
||||
{WONT_COPY_KEYS.map(key => (
|
||||
<li key={key} className="flex items-center gap-2 text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||
<X size={13} className="flex-shrink-0" style={{ color: 'var(--text-muted)' }} />
|
||||
<li key={key} className="flex items-center gap-2 text-sm text-content-secondary">
|
||||
<X size={13} className="flex-shrink-0 text-content-muted" />
|
||||
{t(key)}
|
||||
</li>
|
||||
))}
|
||||
@@ -91,8 +90,7 @@ export default function CopyTripDialog({ isOpen, tripTitle, onClose, onConfirm }
|
||||
<div className="flex justify-end gap-3 mt-5">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm font-medium rounded-lg transition-colors"
|
||||
style={{ color: 'var(--text-secondary)', border: '1px solid var(--border-secondary)' }}
|
||||
className="px-4 py-2 text-sm font-medium rounded-lg transition-colors text-content-secondary border border-edge-secondary"
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
|
||||
@@ -65,13 +65,13 @@ export default function Modal({
|
||||
rounded-2xl overflow-hidden shadow-2xl w-full ${sizeClasses[size] || sizeClasses.md}
|
||||
flex flex-col
|
||||
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()}
|
||||
>
|
||||
{/* 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)' }}>
|
||||
<h2 className="text-lg font-semibold" style={{ color: 'var(--text-primary)' }}>{title}</h2>
|
||||
<div className="flex items-center justify-between p-6 flex-shrink-0 border-b border-edge-secondary">
|
||||
<h2 className="text-lg font-semibold text-content">{title}</h2>
|
||||
{!hideCloseButton && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
@@ -89,7 +89,7 @@ export default function Modal({
|
||||
|
||||
{/* Footer — sticky at the bottom of the modal, never compressed */}
|
||||
{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}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -28,8 +28,8 @@ export function Skeleton({
|
||||
export function SpotlightSkeleton(): React.ReactElement {
|
||||
return (
|
||||
<div
|
||||
className="relative rounded-3xl overflow-hidden mb-8"
|
||||
style={{ minHeight: 340, background: 'var(--bg-tertiary)' }}
|
||||
className="relative rounded-3xl overflow-hidden mb-8 bg-surface-tertiary"
|
||||
style={{ minHeight: 340 }}
|
||||
>
|
||||
<div className="trek-skeleton absolute inset-0" style={{ borderRadius: 24 }} />
|
||||
<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 {
|
||||
return (
|
||||
<div
|
||||
className="rounded-2xl border border-zinc-200 dark:border-zinc-700 overflow-hidden"
|
||||
style={{ background: 'var(--bg-card)' }}
|
||||
className="rounded-2xl border border-zinc-200 dark:border-zinc-700 overflow-hidden bg-surface-card"
|
||||
>
|
||||
<Skeleton height={140} radius={0} />
|
||||
<div className="p-4 flex flex-col gap-2">
|
||||
|
||||
@@ -68,7 +68,7 @@ export function Tooltip({ label, placement = 'bottom', delay = 250, disabled, ch
|
||||
<div
|
||||
ref={tooltipRef}
|
||||
role="tooltip"
|
||||
className="trek-popover-enter"
|
||||
className="trek-popover-enter bg-surface-card text-content border border-edge-faint"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: coords?.top ?? -9999,
|
||||
@@ -76,15 +76,12 @@ export function Tooltip({ label, placement = 'bottom', delay = 250, disabled, ch
|
||||
visibility: coords ? 'visible' : 'hidden',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 100000,
|
||||
background: 'var(--bg-card, #ffffff)',
|
||||
color: 'var(--text-primary, #111827)',
|
||||
fontSize: 11,
|
||||
fontWeight: 500,
|
||||
padding: '5px 10px',
|
||||
borderRadius: 8,
|
||||
whiteSpace: 'nowrap',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
|
||||
border: '1px solid var(--border-faint, #e5e7eb)',
|
||||
fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif",
|
||||
transformOrigin: placement === 'top' ? 'bottom center' : placement === 'bottom' ? 'top center' : placement === 'left' ? 'center right' : 'center left',
|
||||
}}
|
||||
|
||||
@@ -20,10 +20,11 @@ import PackingTemplateManager from '../components/Admin/PackingTemplateManager'
|
||||
import AuditLogPanel from '../components/Admin/AuditLogPanel'
|
||||
import AdminMcpTokensPanel from '../components/Admin/AdminMcpTokensPanel'
|
||||
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 PageSidebar, { type PageSidebarTab } from '../components/Layout/PageSidebar'
|
||||
import { useAdmin } from './admin/useAdmin'
|
||||
import AdminUpdateBanner from './admin/AdminUpdateBanner'
|
||||
|
||||
const ADMIN_EVENT_LABEL_KEYS: Record<string, string> = {
|
||||
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(() => {})
|
||||
}, [])
|
||||
|
||||
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 => {
|
||||
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) {
|
||||
return (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</div>
|
||||
<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 */}
|
||||
<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 />
|
||||
{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}
|
||||
</span>
|
||||
))}
|
||||
@@ -96,13 +97,13 @@ function AdminNotificationsPanel({ t, toast }: { t: (k: string) => string; toast
|
||||
{matrix.event_types.map((eventType: string) => {
|
||||
const implementedForEvent = matrix.implemented_combos[eventType] ?? []
|
||||
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)' }}>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-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 className="text-content" style={{ fontSize: 13 }}>
|
||||
{t(ADMIN_EVENT_LABEL_KEYS[eventType]) || eventType}
|
||||
</span>
|
||||
{visibleChannels.map(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
|
||||
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 {
|
||||
const animated = useCountUp(value, 900)
|
||||
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">
|
||||
<Icon className="w-5 h-5" style={{ color: 'var(--text-primary)' }} />
|
||||
<Icon className="w-5 h-5 text-content" />
|
||||
<div>
|
||||
<p className="text-xl font-bold tabular-nums" style={{ color: 'var(--text-primary)' }}>{animated}</p>
|
||||
<p className="text-xs" style={{ color: 'var(--text-muted)' }}>{label}</p>
|
||||
<p className="text-xl font-bold tabular-nums text-content">{animated}</p>
|
||||
<p className="text-xs text-content-muted">{label}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -205,39 +206,7 @@ export default function AdminPage(): React.ReactElement {
|
||||
|
||||
{/* Update Banner */}
|
||||
{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">
|
||||
<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>
|
||||
<AdminUpdateBanner updateInfo={updateInfo} t={t} onHowTo={() => setShowUpdateModal(true)} />
|
||||
)}
|
||||
|
||||
{/* Demo Baseline Button */}
|
||||
@@ -332,7 +301,7 @@ export default function AdminPage(): React.ReactElement {
|
||||
{u.username.charAt(0).toUpperCase()}
|
||||
</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>
|
||||
<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>
|
||||
<p className="text-xs text-slate-400 mt-1">{t('admin.notifications.inappPanel.hint')}</p>
|
||||
</div>
|
||||
<div className="relative inline-flex h-6 w-11 items-center rounded-full flex-shrink-0"
|
||||
style={{ background: 'var(--text-primary)', opacity: 0.5, cursor: 'not-allowed' }}>
|
||||
<div className="relative inline-flex h-6 w-11 items-center rounded-full flex-shrink-0 bg-content"
|
||||
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"
|
||||
style={{ transform: 'translateX(20px)' }} />
|
||||
</div>
|
||||
|
||||
+87
-172
@@ -3,10 +3,13 @@ import { useTranslation } from '../i18n'
|
||||
import Navbar from '../components/Layout/Navbar'
|
||||
import apiClient from '../api/client'
|
||||
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 { A2_TO_A3, countryCodeToFlag, type AtlasCountry, type AtlasStats, type AtlasData, type CountryDetail } from './atlas/atlasModel'
|
||||
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 {
|
||||
const tp = dark ? '#f1f5f9' : '#0f172a'
|
||||
@@ -80,20 +83,21 @@ export default function AtlasPage(): React.ReactElement {
|
||||
bucketPoiMonth, setBucketPoiMonth, bucketPoiYear, setBucketPoiYear,
|
||||
bucketSearching, bucketSearch, setBucketSearch,
|
||||
} = useAtlas()
|
||||
const toast = useToast()
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen" style={{ background: 'var(--bg-primary)' }}>
|
||||
<div className="min-h-screen bg-surface">
|
||||
<Navbar />
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-screen overflow-hidden" style={{ background: 'var(--bg-primary)' }}>
|
||||
<div className="h-screen overflow-hidden bg-surface">
|
||||
<Navbar />
|
||||
<div style={{ position: 'fixed', top: 'var(--nav-h)', left: 0, right: 0, bottom: 'env(safe-area-inset-bottom, 0px)' }}>
|
||||
{/* 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)'}`,
|
||||
fontSize: 12, minWidth: 120,
|
||||
}} />
|
||||
<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} 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>
|
||||
<AtlasCountrySearch
|
||||
dark={dark}
|
||||
t={t}
|
||||
search={atlas_country_search}
|
||||
setSearch={set_atlas_country_search}
|
||||
results={atlas_country_results}
|
||||
setResults={set_atlas_country_results}
|
||||
open={atlas_country_open}
|
||||
setOpen={set_atlas_country_open}
|
||||
options={atlas_country_options}
|
||||
onSelect={select_country_from_search}
|
||||
/>
|
||||
|
||||
{/* 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' }}>
|
||||
@@ -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)' }}>
|
||||
{/* 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)' }}>
|
||||
<p className="text-3xl font-black tabular-nums leading-none" style={{ color: 'var(--text-primary)' }}>{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-3xl font-black tabular-nums leading-none text-content">{stats.totalCountries}</p>
|
||||
<p className="text-[9px] font-semibold uppercase tracking-wide mt-1 text-content-faint">{t('atlas.countries')}</p>
|
||||
</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) => (
|
||||
<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-[9px] font-semibold uppercase tracking-wide mt-1" style={{ color: 'var(--text-faint)' }}>{l}</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 text-content-faint">{l}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -303,14 +196,14 @@ export default function AtlasPage(): React.ReactElement {
|
||||
{confirmAction && (
|
||||
<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)}>
|
||||
<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()}>
|
||||
{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' }} />
|
||||
) : (
|
||||
<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' && (
|
||||
<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
|
||||
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)
|
||||
}}
|
||||
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)'}
|
||||
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 style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>{t('atlas.markVisited')}</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 1 }}>{t('atlas.markVisitedHint')}</div>
|
||||
<div className="text-content" style={{ fontSize: 13, fontWeight: 600 }}>{t('atlas.markVisited')}</div>
|
||||
<div className="text-content-muted" style={{ fontSize: 11, marginTop: 1 }}>{t('atlas.markVisitedHint')}</div>
|
||||
</div>
|
||||
</button>
|
||||
<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)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'none'}>
|
||||
<Star size={18} style={{ color: '#fbbf24', flexShrink: 0 }} />
|
||||
<div>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>{t('atlas.addToBucket')}</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 1 }}>{t('atlas.addToBucketHint')}</div>
|
||||
<div className="text-content" style={{ fontSize: 13, fontWeight: 600 }}>{t('atlas.addToBucket')}</div>
|
||||
<div className="text-content-muted" style={{ fontSize: 11, marginTop: 1 }}>{t('atlas.addToBucketHint')}</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
@@ -349,7 +246,7 @@ export default function AtlasPage(): React.ReactElement {
|
||||
{confirmAction.type === 'choose-region' && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{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 () => {
|
||||
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
|
||||
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)
|
||||
}}
|
||||
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)'}
|
||||
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 style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>{t('atlas.markVisited')}</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 1 }}>{t('atlas.markRegionVisitedHint')}</div>
|
||||
<div className="text-content" style={{ fontSize: 13, fontWeight: 600 }}>{t('atlas.markVisited')}</div>
|
||||
<div className="text-content-muted" style={{ fontSize: 11, marginTop: 1 }}>{t('atlas.markRegionVisitedHint')}</div>
|
||||
</div>
|
||||
</button>
|
||||
<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)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'none'}>
|
||||
<Star size={18} style={{ color: '#fbbf24', flexShrink: 0 }} />
|
||||
<div>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>{t('atlas.addToBucket')}</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 1 }}>{t('atlas.addToBucketHint')}</div>
|
||||
<div className="text-content" style={{ fontSize: 13, fontWeight: 600 }}>{t('atlas.addToBucket')}</div>
|
||||
<div className="text-content-muted" style={{ fontSize: 11, marginTop: 1 }}>{t('atlas.addToBucketHint')}</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
@@ -392,10 +293,11 @@ export default function AtlasPage(): React.ReactElement {
|
||||
|
||||
{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' }}>
|
||||
<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')}
|
||||
</button>
|
||||
<button onClick={executeConfirmAction}
|
||||
@@ -409,12 +311,13 @@ export default function AtlasPage(): React.ReactElement {
|
||||
{confirmAction.type === 'unmark-region' && (
|
||||
<>
|
||||
{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' }}>
|
||||
<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')}
|
||||
</button>
|
||||
<button onClick={async () => {
|
||||
@@ -441,7 +344,9 @@ export default function AtlasPage(): React.ReactElement {
|
||||
stats: { ...prev.stats, totalCountries: Math.max(0, prev.stats.totalCountries - 1) },
|
||||
}
|
||||
})
|
||||
} catch {}
|
||||
} catch (err) {
|
||||
toast.error(getApiErrorMessage(err, t('common.error')))
|
||||
}
|
||||
setConfirmAction(null)
|
||||
}}
|
||||
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' && (
|
||||
<>
|
||||
<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={{ flex: 1 }}>
|
||||
<CustomSelect
|
||||
@@ -482,7 +387,8 @@ export default function AtlasPage(): React.ReactElement {
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'center', flexWrap: 'wrap' }}>
|
||||
<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')}
|
||||
</button>
|
||||
<button onClick={async () => {
|
||||
@@ -490,7 +396,9 @@ export default function AtlasPage(): React.ReactElement {
|
||||
try {
|
||||
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])
|
||||
} catch {}
|
||||
} catch (err) {
|
||||
toast.error(getApiErrorMessage(err, t('common.error')))
|
||||
}
|
||||
setBucketMonth(0); setBucketYear(0)
|
||||
setConfirmAction(null)
|
||||
}}
|
||||
@@ -503,14 +411,16 @@ export default function AtlasPage(): React.ReactElement {
|
||||
|
||||
{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' }}>
|
||||
<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')}
|
||||
</button>
|
||||
<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')}
|
||||
</button>
|
||||
</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) }}
|
||||
placeholder={t('atlas.bucketNamePlaceholder')}
|
||||
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 && (
|
||||
<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} />
|
||||
</button>
|
||||
)}
|
||||
{bucketForm.name && (
|
||||
<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} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{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) => (
|
||||
<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)' }}>
|
||||
<span style={{ fontSize: 12, fontWeight: 500, color: 'var(--text-primary)' }}>{r.name}</span>
|
||||
{r.address && <span style={{ fontSize: 10, color: 'var(--text-faint)' }}>{r.address}</span>}
|
||||
<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 className="text-content" style={{ fontSize: 12, fontWeight: 500 }}>{r.name}</span>
|
||||
{r.address && <span className="text-content-faint" style={{ fontSize: 10 }}>{r.address}</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -685,7 +598,7 @@ function SidebarContent({ data, stats, countries, selectedCountry, countryDetail
|
||||
</div>
|
||||
{/* Selected place indicator */}
|
||||
{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)}
|
||||
</div>
|
||||
)}
|
||||
@@ -702,7 +615,8 @@ function SidebarContent({ data, stats, countries, selectedCountry, countryDetail
|
||||
</div>
|
||||
<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) }}
|
||||
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')}
|
||||
</button>
|
||||
<button onClick={onAddBucket} disabled={!bucketForm.name.trim()}
|
||||
@@ -714,7 +628,8 @@ function SidebarContent({ data, stats, countries, selectedCountry, countryDetail
|
||||
) : (
|
||||
<div style={{ padding: '4px 16px 8px' }}>
|
||||
<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')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -21,16 +21,15 @@ export default function InAppNotificationsPage(): React.ReactElement {
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold" style={{ color: 'var(--text-primary)' }}>
|
||||
<h1 className="text-xl font-semibold text-content">
|
||||
{t('notifications.title')}
|
||||
{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"
|
||||
style={{ background: 'var(--text-primary)', color: 'var(--bg-primary)' }}>
|
||||
<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">
|
||||
{unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</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'}
|
||||
</p>
|
||||
</div>
|
||||
@@ -40,8 +39,8 @@ export default function InAppNotificationsPage(): React.ReactElement {
|
||||
{unreadCount > 0 && (
|
||||
<button
|
||||
onClick={markAllRead}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm transition-colors"
|
||||
style={{ background: 'var(--bg-hover)', color: 'var(--text-secondary)' }}
|
||||
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)' }}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-tertiary)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
>
|
||||
@@ -86,19 +85,16 @@ export default function InAppNotificationsPage(): React.ReactElement {
|
||||
</div>
|
||||
|
||||
{/* Notification list */}
|
||||
<div
|
||||
className="rounded-xl border overflow-hidden"
|
||||
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)' }}
|
||||
>
|
||||
<div className="rounded-xl border overflow-hidden border-edge bg-surface-card">
|
||||
{isLoading && displayed.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<Spinner className="w-6 h-6 border-2 border-slate-200 border-t-current" />
|
||||
</div>
|
||||
) : displayed.length === 0 ? (
|
||||
<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)' }} />
|
||||
<p className="text-base font-medium" style={{ color: 'var(--text-muted)' }}>{t('notifications.empty')}</p>
|
||||
<p className="text-sm" style={{ color: 'var(--text-faint)' }}>{t('notifications.emptyDescription')}</p>
|
||||
<Bell className="w-12 h-12 text-content-faint" />
|
||||
<p className="text-base font-medium text-content-muted">{t('notifications.empty')}</p>
|
||||
<p className="text-sm text-content-faint">{t('notifications.emptyDescription')}</p>
|
||||
</div>
|
||||
) : (
|
||||
displayed.map(n => (
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useParams, useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import { useJourneyStore } from '../store/journeyStore'
|
||||
import { useAuthStore } from '../store/authStore'
|
||||
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 Navbar from '../components/Layout/Navbar'
|
||||
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 MarkdownToolbar from '../components/Journey/MarkdownToolbar'
|
||||
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 ConfirmDialog from '../components/shared/ConfirmDialog'
|
||||
import {
|
||||
ArrowLeft, RefreshCw, MoreHorizontal, Share2, Download, List, Grid, MapPin, Link, Copy,
|
||||
ArrowLeft, RefreshCw, MoreHorizontal, Share2, Download, List, Grid, MapPin, Copy,
|
||||
Clock, Package, Image, ChevronRight,
|
||||
UserPlus, Plus, Minus, Calendar, Camera, BookOpen, X, Check, ImagePlus, Trash2, Pencil,
|
||||
Laugh, Smile, Meh, Annoyed, Frown,
|
||||
@@ -939,6 +941,7 @@ function GalleryView({ entries, gallery, journeyId, userId, trips, onPhotoClick,
|
||||
onClose={() => setShowPicker(false)}
|
||||
onAdd={async (groups, entryId) => {
|
||||
let added = 0
|
||||
let anyFailed = false
|
||||
for (const group of groups) {
|
||||
try {
|
||||
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)
|
||||
added += result.added || 0
|
||||
}
|
||||
} catch {}
|
||||
} catch {
|
||||
anyFailed = true
|
||||
}
|
||||
}
|
||||
if (added > 0) {
|
||||
toast.success(t('journey.photosAdded', { count: added }))
|
||||
onRefresh()
|
||||
} else if (anyFailed) {
|
||||
toast.error(t('common.error'))
|
||||
}
|
||||
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 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 }: {
|
||||
journey: JourneyDetail
|
||||
onClose: () => void
|
||||
|
||||
@@ -86,29 +86,29 @@ export default function JourneyPage() {
|
||||
|
||||
{/* Header — desktop (unified toolbar) */}
|
||||
<div className="hidden md:block px-8 pt-10 pb-7">
|
||||
<div style={{
|
||||
background: 'var(--bg-tertiary)', borderRadius: 18,
|
||||
border: '1px solid var(--border-primary)',
|
||||
<div className="bg-surface-tertiary border border-edge" style={{
|
||||
borderRadius: 18,
|
||||
boxShadow: '0 1px 3px rgba(0,0,0,0.06), 0 1px 2px rgba(0,0,0,0.04)',
|
||||
padding: '14px 16px 14px 22px',
|
||||
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')}
|
||||
</h2>
|
||||
<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')}
|
||||
</span>
|
||||
|
||||
<div style={{ display: 'inline-flex', gap: 6, alignItems: 'center', marginLeft: 'auto', flexShrink: 0 }}>
|
||||
<button
|
||||
onClick={() => openCreateModal()}
|
||||
className="bg-accent text-accent-text"
|
||||
style={{
|
||||
appearance: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit',
|
||||
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||
padding: '9px 14px', borderRadius: 10, fontSize: 13, fontWeight: 500,
|
||||
background: 'var(--accent)', color: 'var(--accent-text)', flexShrink: 0,
|
||||
flexShrink: 0,
|
||||
marginLeft: 2,
|
||||
transition: 'opacity 0.15s ease',
|
||||
}}
|
||||
|
||||
@@ -36,12 +36,12 @@ export default function SettingsPage(): React.ReactElement {
|
||||
<div className="w-full px-4 sm:px-6 lg:px-8 py-8">
|
||||
{/* Header */}
|
||||
<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)' }}>
|
||||
<Settings className="w-5 h-5" style={{ color: 'var(--text-secondary)' }} />
|
||||
<div className="w-10 h-10 rounded-xl flex items-center justify-center bg-surface-tertiary">
|
||||
<Settings className="w-5 h-5 text-content-secondary" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold" style={{ color: 'var(--text-primary)' }}>{t('settings.title')}</h1>
|
||||
<p className="text-sm" style={{ color: 'var(--text-muted)' }}>{t('settings.subtitle')}</p>
|
||||
<h1 className="text-2xl font-bold text-content">{t('settings.title')}</h1>
|
||||
<p className="text-sm text-content-muted">{t('settings.subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -71,7 +71,7 @@ export default function SharedTripPage() {
|
||||
const center = mapPlaces.length > 0 ? [mapPlaces[0].lat, mapPlaces[0].lng] : [48.85, 2.35]
|
||||
|
||||
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 */}
|
||||
<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 */}
|
||||
@@ -187,7 +187,7 @@ export default function SharedTripPage() {
|
||||
})
|
||||
|
||||
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)}
|
||||
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>
|
||||
@@ -271,7 +271,7 @@ export default function SharedTripPage() {
|
||||
const time = rTime ?? ''
|
||||
const date = rDate ? new Date(rDate + 'T00:00:00Z').toLocaleDateString(locale, { day: 'numeric', month: 'short', timeZone: 'UTC' }) : ''
|
||||
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 }}>
|
||||
<TIcon size={15} color="#6b7280" />
|
||||
</div>
|
||||
@@ -296,7 +296,7 @@ export default function SharedTripPage() {
|
||||
|
||||
{/* Packing */}
|
||||
{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]) => (
|
||||
<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>
|
||||
@@ -323,7 +323,7 @@ export default function SharedTripPage() {
|
||||
</div>
|
||||
{/* By category */}
|
||||
{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' }}>
|
||||
<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>
|
||||
@@ -342,7 +342,7 @@ export default function SharedTripPage() {
|
||||
|
||||
{/* Collab Chat */}
|
||||
{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 }}>
|
||||
<MessageCircle size={14} color="#6b7280" />
|
||||
<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 */}
|
||||
<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 }} />
|
||||
<span style={{ fontSize: 11, color: '#9ca3af' }}>{t('shared.sharedVia')} <strong style={{ color: '#6b7280' }}>TREK</strong></span>
|
||||
</div>
|
||||
|
||||
@@ -61,15 +61,15 @@ function ListsContainer({ tripId, packingItems, todoItems }: { tripId: number; p
|
||||
return (
|
||||
<div>
|
||||
<div style={{ padding: '24px 28px 0' }} className="max-md:!px-4 max-md:!pt-4">
|
||||
<div style={{
|
||||
background: 'var(--bg-tertiary)', borderRadius: 18,
|
||||
<div className="bg-surface-tertiary" style={{
|
||||
borderRadius: 18,
|
||||
padding: '14px 16px 14px 22px',
|
||||
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')}
|
||||
</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 }}>
|
||||
{tabs.map(tab => {
|
||||
const active = subTab === tab.id
|
||||
@@ -205,9 +205,9 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
|
||||
if (isLoading || !splashDone) {
|
||||
return (
|
||||
<div style={{
|
||||
<div className="bg-surface" style={{
|
||||
minHeight: '100vh', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
|
||||
background: 'var(--bg-primary)', ...fontStyle,
|
||||
...fontStyle,
|
||||
}}>
|
||||
<style>{`
|
||||
@keyframes dotPulse {
|
||||
@@ -227,16 +227,16 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
height={64}
|
||||
/>
|
||||
</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'}
|
||||
</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')}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
{[0, 1, 2].map(i => (
|
||||
<div key={i} style={{
|
||||
width: 8, height: 8, borderRadius: '50%', background: 'var(--text-muted)',
|
||||
<div key={i} className="bg-content-muted" style={{
|
||||
width: 8, height: 8, borderRadius: '50%',
|
||||
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 }}>
|
||||
<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,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
padding: '0 12px',
|
||||
background: 'var(--bg-elevated)',
|
||||
backdropFilter: 'blur(16px)',
|
||||
WebkitBackdropFilter: 'blur(16px)',
|
||||
borderBottom: '1px solid var(--border-faint)',
|
||||
height: 44,
|
||||
}}>
|
||||
<SlidingTabs
|
||||
@@ -445,11 +443,13 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
{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' }}>
|
||||
<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')}
|
||||
</button>
|
||||
<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')}
|
||||
</button>
|
||||
</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')) } }}
|
||||
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')) } }}
|
||||
leftWidth={0}
|
||||
@@ -582,10 +582,10 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
|
||||
{mobileSidebarOpen && ReactDOM.createPortal(
|
||||
<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 style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '14px 16px', borderBottom: '1px solid var(--border-secondary)' }}>
|
||||
<span style={{ fontWeight: 600, fontSize: 14, color: 'var(--text-primary)' }}>{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)' }}>
|
||||
<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 className="border-b border-edge-secondary" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '14px 16px' }}>
|
||||
<span className="text-content" style={{ fontWeight: 600, fontSize: 14 }}>{mobileSidebarOpen === 'left' ? t('trip.mobilePlan') : t('trip.mobilePlaces')}</span>
|
||||
<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} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -24,7 +24,7 @@ export default function VacayPage(): React.ReactElement {
|
||||
if (loading) {
|
||||
return (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -33,25 +33,25 @@ export default function VacayPage(): React.ReactElement {
|
||||
const sidebarContent = (
|
||||
<>
|
||||
{/* 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">
|
||||
<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 className="flex items-center justify-between mb-2">
|
||||
<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} />
|
||||
</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)' }}>
|
||||
<ChevronLeft size={16} style={{ color: 'var(--text-muted)' }} />
|
||||
<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} className="text-content-muted" />
|
||||
</button>
|
||||
</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">
|
||||
<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)' }}>
|
||||
<ChevronRight size={16} style={{ color: 'var(--text-muted)' }} />
|
||||
<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} className="text-content-muted" />
|
||||
</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} />
|
||||
</button>
|
||||
</div>
|
||||
@@ -80,8 +80,8 @@ export default function VacayPage(): React.ReactElement {
|
||||
|
||||
{/* Legend */}
|
||||
{(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)' }}>
|
||||
<span className="text-[11px] font-medium uppercase tracking-wider" style={{ color: 'var(--text-faint)' }}>{t('vacay.legend')}</span>
|
||||
<div className="rounded-xl border p-3 bg-surface-card border-edge">
|
||||
<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">
|
||||
{plan?.holidays_enabled && (plan?.holiday_calendars ?? []).length === 0 && (
|
||||
<LegendItem color="#fecaca" label={t('vacay.publicHoliday')} />
|
||||
@@ -105,23 +105,21 @@ export default function VacayPage(): React.ReactElement {
|
||||
{/* Mobile + tablet header (filter toggle lives here) */}
|
||||
<div className="lg:hidden flex items-center justify-between mb-4">
|
||||
<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)' }}>
|
||||
<CalendarDays size={18} style={{ color: 'var(--text-primary)' }} />
|
||||
<div className="w-9 h-9 rounded-xl flex items-center justify-center bg-surface-secondary">
|
||||
<CalendarDays size={18} className="text-content" />
|
||||
</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 className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setShowMobileSidebar(true)}
|
||||
className="lg:hidden flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm transition-colors"
|
||||
style={{ background: 'var(--bg-secondary)', color: 'var(--text-muted)' }}
|
||||
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"
|
||||
>
|
||||
<SlidersHorizontal size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowSettings(true)}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm transition-colors"
|
||||
style={{ background: 'var(--bg-secondary)', color: 'var(--text-muted)' }}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm transition-colors bg-surface-secondary text-content-muted"
|
||||
>
|
||||
<Settings size={14} />
|
||||
</button>
|
||||
@@ -130,28 +128,28 @@ export default function VacayPage(): React.ReactElement {
|
||||
|
||||
{/* Desktop header — unified toolbar (sidebar is always visible at this width) */}
|
||||
<div className="hidden lg:block" style={{ marginBottom: 20 }}>
|
||||
<div style={{
|
||||
background: 'var(--bg-tertiary)', borderRadius: 18,
|
||||
border: '1px solid var(--border-primary)',
|
||||
<div className="bg-surface-tertiary border border-edge" style={{
|
||||
borderRadius: 18,
|
||||
boxShadow: '0 1px 3px rgba(0,0,0,0.06), 0 1px 2px rgba(0,0,0,0.04)',
|
||||
padding: '14px 16px 14px 22px',
|
||||
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')}
|
||||
</h2>
|
||||
<div style={{ width: 1, height: 22, background: 'var(--border-faint)', flexShrink: 0 }} />
|
||||
<span style={{ fontSize: 13, color: 'var(--text-muted)' }}>
|
||||
<div className="bg-edge-faint" style={{ width: 1, height: 22, flexShrink: 0 }} />
|
||||
<span className="text-content-muted" style={{ fontSize: 13 }}>
|
||||
{t('vacay.subtitle')}
|
||||
</span>
|
||||
<div style={{ display: 'inline-flex', gap: 6, alignItems: 'center', marginLeft: 'auto', flexShrink: 0 }}>
|
||||
<button
|
||||
onClick={() => setShowSettings(true)}
|
||||
className="bg-accent text-accent-text"
|
||||
style={{
|
||||
appearance: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit',
|
||||
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||
padding: '9px 14px', borderRadius: 10, fontSize: 13, fontWeight: 500,
|
||||
background: 'var(--accent)', color: 'var(--accent-text)', flexShrink: 0,
|
||||
flexShrink: 0,
|
||||
marginLeft: 2,
|
||||
transition: 'opacity 0.15s ease',
|
||||
}}
|
||||
@@ -182,8 +180,8 @@ export default function VacayPage(): React.ReactElement {
|
||||
{showMobileSidebar && ReactDOM.createPortal(
|
||||
<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 left-0 top-0 bottom-0 w-[280px] overflow-y-auto p-3 flex flex-col gap-3"
|
||||
style={{ background: 'var(--bg-primary)', boxShadow: '4px 0 24px rgba(0,0,0,0.15)', animation: 'slideInLeft 0.2s ease-out' }}>
|
||||
<div className="absolute left-0 top-0 bottom-0 w-[280px] overflow-y-auto p-3 flex flex-col gap-3 bg-surface"
|
||||
style={{ boxShadow: '4px 0 24px rgba(0,0,0,0.15)', animation: 'slideInLeft 0.2s ease-out' }}>
|
||||
{sidebarContent}
|
||||
</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)' }}>
|
||||
<AlertTriangle size={18} className="text-red-500 shrink-0 mt-0.5" />
|
||||
<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 })}
|
||||
</p>
|
||||
<p className="text-xs mt-1" style={{ color: 'var(--text-muted)' }}>
|
||||
<p className="text-xs mt-1 text-content-muted">
|
||||
{t('vacay.removeYearHint')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<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')}
|
||||
</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">
|
||||
@@ -225,18 +223,16 @@ export default function VacayPage(): React.ReactElement {
|
||||
<div className="fixed inset-0 flex items-center justify-center px-4"
|
||||
style={{ zIndex: 99995, backgroundColor: 'rgba(0,0,0,0.7)', backdropFilter: 'blur(8px)' }}>
|
||||
{incomingInvites.map(inv => (
|
||||
<div key={inv.id} className="trek-modal-enter w-full max-w-md rounded-2xl shadow-2xl overflow-hidden"
|
||||
style={{ background: 'var(--bg-card)' }}>
|
||||
<div key={inv.id} className="trek-modal-enter w-full max-w-md rounded-2xl shadow-2xl overflow-hidden bg-surface-card">
|
||||
<div className="px-6 pt-6 pb-4 text-center">
|
||||
<div className="w-14 h-14 rounded-full mx-auto mb-4 flex items-center justify-center text-lg font-bold"
|
||||
style={{ background: 'var(--bg-secondary)', color: 'var(--text-primary)' }}>
|
||||
<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">
|
||||
{inv.username?.[0]?.toUpperCase()}
|
||||
</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')}
|
||||
</h2>
|
||||
<p className="text-sm" style={{ color: 'var(--text-muted)' }}>
|
||||
<span className="font-semibold" style={{ color: 'var(--text-primary)' }}>{inv.username}</span> {t('vacay.inviteWantsToFuse')}
|
||||
<p className="text-sm text-content-muted">
|
||||
<span className="font-semibold text-content">{inv.username}</span> {t('vacay.inviteWantsToFuse')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="px-6 pb-4 space-y-2">
|
||||
@@ -248,13 +244,11 @@ export default function VacayPage(): React.ReactElement {
|
||||
</div>
|
||||
<div className="px-6 pb-6 flex gap-3">
|
||||
<button onClick={() => declineInvite(inv.plan_id)}
|
||||
className="flex-1 px-4 py-2.5 text-sm font-medium rounded-xl transition-colors"
|
||||
style={{ color: 'var(--text-muted)', border: '1px solid var(--border-primary)' }}>
|
||||
className="flex-1 px-4 py-2.5 text-sm font-medium rounded-xl transition-colors border text-content-muted border-edge">
|
||||
{t('vacay.decline')}
|
||||
</button>
|
||||
<button onClick={() => acceptInvite(inv.plan_id)}
|
||||
className="flex-1 px-4 py-2.5 text-sm font-medium rounded-xl transition-colors"
|
||||
style={{ background: 'var(--text-primary)', color: 'var(--bg-card)' }}>
|
||||
className="flex-1 px-4 py-2.5 text-sm font-medium rounded-xl transition-colors bg-content text-surface-card">
|
||||
{t('vacay.acceptFusion')}
|
||||
</button>
|
||||
</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 {
|
||||
return (
|
||||
<div className="flex items-start gap-3 px-3 py-2 rounded-lg" style={{ background: 'var(--bg-secondary)' }}>
|
||||
<Icon size={15} className="shrink-0 mt-0.5" style={{ color: 'var(--text-muted)' }} />
|
||||
<span className="text-xs" style={{ color: 'var(--text-primary)' }}>{text}</span>
|
||||
<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 text-content-muted" />
|
||||
<span className="text-xs text-content">{text}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -287,7 +281,7 @@ function LegendItem({ color, label }: { color: string; label: string }): React.R
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
fd.append('file', file)
|
||||
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'))
|
||||
@@ -375,7 +375,7 @@ export function useTripPlanner() {
|
||||
const fd = new FormData()
|
||||
fd.append('file', file)
|
||||
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'))
|
||||
|
||||
Reference in New Issue
Block a user