Initial commit — NOMAD (Navigation Organizer for Maps, Activities & Destinations)
Self-hosted travel planner with Express.js, SQLite, React & Tailwind CSS.
This commit is contained in:
536
client/src/pages/AdminPage.jsx
Normal file
536
client/src/pages/AdminPage.jsx
Normal file
@@ -0,0 +1,536 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { adminApi, authApi } from '../api/client'
|
||||
import { useAuthStore } from '../store/authStore'
|
||||
import { useTranslation } from '../i18n'
|
||||
import Navbar from '../components/Layout/Navbar'
|
||||
import Modal from '../components/shared/Modal'
|
||||
import { useToast } from '../components/shared/Toast'
|
||||
import CategoryManager from '../components/Admin/CategoryManager'
|
||||
import BackupPanel from '../components/Admin/BackupPanel'
|
||||
import { Users, Map, Briefcase, Shield, Trash2, Edit2, Camera, FileText, Eye, EyeOff, Save, CheckCircle, XCircle, Loader2 } from 'lucide-react'
|
||||
|
||||
export default function AdminPage() {
|
||||
const { t } = useTranslation()
|
||||
const TABS = [
|
||||
{ id: 'users', label: t('admin.tabs.users') },
|
||||
{ id: 'categories', label: t('admin.tabs.categories') },
|
||||
{ id: 'settings', label: t('admin.tabs.settings') },
|
||||
{ id: 'backup', label: t('admin.tabs.backup') },
|
||||
]
|
||||
|
||||
const [activeTab, setActiveTab] = useState('users')
|
||||
const [users, setUsers] = useState([])
|
||||
const [stats, setStats] = useState(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [editingUser, setEditingUser] = useState(null)
|
||||
const [editForm, setEditForm] = useState({ username: '', email: '', role: 'user', password: '' })
|
||||
|
||||
// Registration toggle
|
||||
const [allowRegistration, setAllowRegistration] = useState(true)
|
||||
|
||||
// API Keys
|
||||
const [mapsKey, setMapsKey] = useState('')
|
||||
const [weatherKey, setWeatherKey] = useState('')
|
||||
const [showKeys, setShowKeys] = useState({})
|
||||
const [savingKeys, setSavingKeys] = useState(false)
|
||||
const [validating, setValidating] = useState({})
|
||||
const [validation, setValidation] = useState({})
|
||||
|
||||
const { user: currentUser, updateApiKeys } = useAuthStore()
|
||||
const navigate = useNavigate()
|
||||
const toast = useToast()
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
loadAppConfig()
|
||||
loadApiKeys()
|
||||
}, [])
|
||||
|
||||
const loadData = async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const [usersData, statsData] = await Promise.all([
|
||||
adminApi.users(),
|
||||
adminApi.stats(),
|
||||
])
|
||||
setUsers(usersData.users)
|
||||
setStats(statsData)
|
||||
} catch (err) {
|
||||
toast.error(t('admin.toast.loadError'))
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const loadAppConfig = async () => {
|
||||
try {
|
||||
const config = await authApi.getAppConfig()
|
||||
setAllowRegistration(config.allow_registration)
|
||||
} catch (err) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
const loadApiKeys = async () => {
|
||||
try {
|
||||
const data = await authApi.me()
|
||||
setMapsKey(data.user?.maps_api_key || '')
|
||||
setWeatherKey(data.user?.openweather_api_key || '')
|
||||
} catch (err) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggleRegistration = async (value) => {
|
||||
setAllowRegistration(value)
|
||||
try {
|
||||
await authApi.updateAppSettings({ allow_registration: value })
|
||||
} catch (err) {
|
||||
setAllowRegistration(!value)
|
||||
toast.error(err.response?.data?.error || t('common.error'))
|
||||
}
|
||||
}
|
||||
|
||||
const toggleKey = (key) => {
|
||||
setShowKeys(prev => ({ ...prev, [key]: !prev[key] }))
|
||||
}
|
||||
|
||||
const handleSaveApiKeys = async () => {
|
||||
setSavingKeys(true)
|
||||
try {
|
||||
await updateApiKeys({
|
||||
maps_api_key: mapsKey,
|
||||
openweather_api_key: weatherKey,
|
||||
})
|
||||
toast.success(t('admin.keySaved'))
|
||||
} catch (err) {
|
||||
toast.error(err.message)
|
||||
} finally {
|
||||
setSavingKeys(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleValidateKeys = async () => {
|
||||
setValidating({ maps: true, weather: true })
|
||||
try {
|
||||
const result = await authApi.validateKeys()
|
||||
setValidation(result)
|
||||
} catch (err) {
|
||||
toast.error(t('common.error'))
|
||||
} finally {
|
||||
setValidating({})
|
||||
}
|
||||
}
|
||||
|
||||
const handleValidateKey = async (keyType) => {
|
||||
setValidating(prev => ({ ...prev, [keyType]: true }))
|
||||
try {
|
||||
const result = await authApi.validateKeys()
|
||||
setValidation(prev => ({ ...prev, [keyType]: result[keyType] }))
|
||||
} catch (err) {
|
||||
toast.error(t('common.error'))
|
||||
} finally {
|
||||
setValidating(prev => ({ ...prev, [keyType]: false }))
|
||||
}
|
||||
}
|
||||
|
||||
const handleEditUser = (user) => {
|
||||
setEditingUser(user)
|
||||
setEditForm({ username: user.username, email: user.email, role: user.role, password: '' })
|
||||
}
|
||||
|
||||
const handleSaveUser = async () => {
|
||||
try {
|
||||
const payload = {
|
||||
username: editForm.username.trim() || undefined,
|
||||
email: editForm.email.trim() || undefined,
|
||||
role: editForm.role,
|
||||
}
|
||||
if (editForm.password.trim()) payload.password = editForm.password.trim()
|
||||
const data = await adminApi.updateUser(editingUser.id, payload)
|
||||
setUsers(prev => prev.map(u => u.id === editingUser.id ? data.user : u))
|
||||
setEditingUser(null)
|
||||
toast.success(t('admin.toast.userUpdated'))
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.error || t('admin.toast.updateError'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteUser = async (user) => {
|
||||
if (user.id === currentUser?.id) {
|
||||
toast.error(t('admin.toast.cannotDeleteSelf'))
|
||||
return
|
||||
}
|
||||
if (!confirm(t('admin.deleteUser', { name: user.username }))) return
|
||||
try {
|
||||
await adminApi.deleteUser(user.id)
|
||||
setUsers(prev => prev.filter(u => u.id !== user.id))
|
||||
toast.success(t('admin.toast.userDeleted'))
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.error || t('admin.toast.deleteError'))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50">
|
||||
<Navbar />
|
||||
|
||||
<div className="pt-14">
|
||||
<div className="max-w-6xl mx-auto px-4 py-8">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="w-10 h-10 bg-slate-100 rounded-xl flex items-center justify-center">
|
||||
<Shield className="w-5 h-5 text-slate-700" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900">Administration</h1>
|
||||
<p className="text-slate-500 text-sm">{t('admin.subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
{stats && (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-5 gap-4 mb-6">
|
||||
{[
|
||||
{ label: t('admin.stats.users'), value: stats.totalUsers, icon: Users },
|
||||
{ label: t('admin.stats.trips'), value: stats.totalTrips, icon: Briefcase },
|
||||
{ label: t('admin.stats.places'), value: stats.totalPlaces, icon: Map },
|
||||
{ label: t('admin.stats.photos'), value: stats.totalPhotos || 0, icon: Camera },
|
||||
{ label: t('admin.stats.files'), value: stats.totalFiles || 0, icon: FileText },
|
||||
].map(({ label, value, icon: Icon }) => (
|
||||
<div key={label} className="rounded-xl border p-4" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
|
||||
<div className="flex items-center gap-4">
|
||||
<Icon className="w-5 h-5" style={{ color: 'var(--text-primary)' }} />
|
||||
<div>
|
||||
<p className="text-xl font-bold" style={{ color: 'var(--text-primary)' }}>{value}</p>
|
||||
<p className="text-xs" style={{ color: 'var(--text-muted)' }}>{label}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-1 mb-6 bg-white border border-slate-200 rounded-xl p-1 w-fit">
|
||||
{TABS.map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors ${
|
||||
activeTab === tab.id
|
||||
? 'bg-slate-900 text-white'
|
||||
: 'text-slate-600 hover:text-slate-900 hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tab content */}
|
||||
{activeTab === 'users' && (
|
||||
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
||||
<div className="p-5 border-b border-slate-100">
|
||||
<h2 className="font-semibold text-slate-900">{t('admin.tabs.users')} ({users.length})</h2>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="p-8 text-center">
|
||||
<div className="w-8 h-8 border-2 border-slate-200 border-t-slate-900 rounded-full animate-spin mx-auto"></div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="text-left text-xs font-medium text-slate-500 uppercase tracking-wider border-b border-slate-100 bg-slate-50">
|
||||
<th className="px-5 py-3">{t('admin.table.user')}</th>
|
||||
<th className="px-5 py-3">{t('admin.table.email')}</th>
|
||||
<th className="px-5 py-3">{t('admin.table.role')}</th>
|
||||
<th className="px-5 py-3">{t('admin.table.created')}</th>
|
||||
<th className="px-5 py-3 text-right">{t('admin.table.actions')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{users.map(u => (
|
||||
<tr key={u.id} className={`hover:bg-slate-50 transition-colors ${u.id === currentUser?.id ? 'bg-slate-50/60' : ''}`}>
|
||||
<td className="px-5 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 rounded-full bg-slate-100 flex items-center justify-center text-sm font-medium text-slate-700">
|
||||
{u.username.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-900">{u.username}</p>
|
||||
{u.id === currentUser?.id && (
|
||||
<span className="text-xs text-slate-500">{t('admin.you')}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-5 py-3 text-sm text-slate-600">{u.email}</td>
|
||||
<td className="px-5 py-3">
|
||||
<span className={`inline-flex items-center gap-1 text-xs font-medium px-2.5 py-0.5 rounded-full ${
|
||||
u.role === 'admin'
|
||||
? 'bg-slate-900 text-white'
|
||||
: 'bg-slate-100 text-slate-600'
|
||||
}`}>
|
||||
{u.role === 'admin' && <Shield className="w-3 h-3" />}
|
||||
{u.role === 'admin' ? t('settings.roleAdmin') : t('settings.roleUser')}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-3 text-sm text-slate-500">
|
||||
{new Date(u.created_at).toLocaleDateString('de-DE')}
|
||||
</td>
|
||||
<td className="px-5 py-3">
|
||||
<div className="flex items-center gap-2 justify-end">
|
||||
<button
|
||||
onClick={() => handleEditUser(u)}
|
||||
className="p-1.5 text-slate-400 hover:text-slate-900 hover:bg-slate-100 rounded-lg transition-colors"
|
||||
title={t('admin.editUser')}
|
||||
>
|
||||
<Edit2 className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteUser(u)}
|
||||
disabled={u.id === currentUser?.id}
|
||||
className="p-1.5 text-slate-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
title={t('admin.deleteUserTitle')}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'categories' && <CategoryManager />}
|
||||
|
||||
{activeTab === 'settings' && (
|
||||
<div className="space-y-6">
|
||||
{/* Registration Toggle */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-slate-100">
|
||||
<h2 className="font-semibold text-slate-900">{t('admin.allowRegistration')}</h2>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-700">{t('admin.allowRegistration')}</p>
|
||||
<p className="text-xs text-slate-400 mt-0.5">{t('admin.allowRegistrationHint')}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleToggleRegistration(!allowRegistration)}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
||||
allowRegistration ? 'bg-slate-900' : 'bg-slate-300'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||
allowRegistration ? 'translate-x-6' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* API Keys */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-slate-100">
|
||||
<h2 className="font-semibold text-slate-900">{t('admin.apiKeys')}</h2>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
{/* Google Maps Key */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('admin.mapsKey')}</label>
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<input
|
||||
type={showKeys.maps ? 'text' : 'password'}
|
||||
value={mapsKey}
|
||||
onChange={e => setMapsKey(e.target.value)}
|
||||
placeholder={t('settings.keyPlaceholder')}
|
||||
className="w-full pr-10 px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleKey('maps')}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600"
|
||||
>
|
||||
{showKeys.maps ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleValidateKey('maps')}
|
||||
disabled={!mapsKey || validating.maps}
|
||||
className="px-3 py-2 text-sm border border-slate-300 rounded-lg hover:bg-slate-50 disabled:opacity-40 disabled:cursor-not-allowed flex items-center gap-1.5"
|
||||
>
|
||||
{validating.maps ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : validation.maps === true ? (
|
||||
<CheckCircle className="w-4 h-4 text-emerald-500" />
|
||||
) : validation.maps === false ? (
|
||||
<XCircle className="w-4 h-4 text-red-500" />
|
||||
) : null}
|
||||
{t('admin.validateKey')}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-slate-400 mt-1">{t('admin.mapsKeyHint')}</p>
|
||||
{validation.maps === true && (
|
||||
<p className="text-xs text-emerald-600 mt-1 flex items-center gap-1">
|
||||
<span className="w-2 h-2 bg-emerald-500 rounded-full inline-block"></span>
|
||||
{t('admin.keyValid')}
|
||||
</p>
|
||||
)}
|
||||
{validation.maps === false && (
|
||||
<p className="text-xs text-red-500 mt-1 flex items-center gap-1">
|
||||
<span className="w-2 h-2 bg-red-500 rounded-full inline-block"></span>
|
||||
{t('admin.keyInvalid')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* OpenWeatherMap Key */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('admin.weatherKey')}</label>
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<input
|
||||
type={showKeys.weather ? 'text' : 'password'}
|
||||
value={weatherKey}
|
||||
onChange={e => setWeatherKey(e.target.value)}
|
||||
placeholder={t('settings.keyPlaceholder')}
|
||||
className="w-full pr-10 px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleKey('weather')}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600"
|
||||
>
|
||||
{showKeys.weather ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleValidateKey('weather')}
|
||||
disabled={!weatherKey || validating.weather}
|
||||
className="px-3 py-2 text-sm border border-slate-300 rounded-lg hover:bg-slate-50 disabled:opacity-40 disabled:cursor-not-allowed flex items-center gap-1.5"
|
||||
>
|
||||
{validating.weather ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : validation.weather === true ? (
|
||||
<CheckCircle className="w-4 h-4 text-emerald-500" />
|
||||
) : validation.weather === false ? (
|
||||
<XCircle className="w-4 h-4 text-red-500" />
|
||||
) : null}
|
||||
{t('admin.validateKey')}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-slate-400 mt-1">{t('admin.weatherKeyHint')}</p>
|
||||
{validation.weather === true && (
|
||||
<p className="text-xs text-emerald-600 mt-1 flex items-center gap-1">
|
||||
<span className="w-2 h-2 bg-emerald-500 rounded-full inline-block"></span>
|
||||
{t('admin.keyValid')}
|
||||
</p>
|
||||
)}
|
||||
{validation.weather === false && (
|
||||
<p className="text-xs text-red-500 mt-1 flex items-center gap-1">
|
||||
<span className="w-2 h-2 bg-red-500 rounded-full inline-block"></span>
|
||||
{t('admin.keyInvalid')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleSaveApiKeys}
|
||||
disabled={savingKeys}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-slate-900 text-white rounded-lg text-sm hover:bg-slate-700 disabled:bg-slate-400"
|
||||
>
|
||||
{savingKeys ? <div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" /> : <Save className="w-4 h-4" />}
|
||||
{t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'backup' && <BackupPanel />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Edit user modal */}
|
||||
<Modal
|
||||
isOpen={!!editingUser}
|
||||
onClose={() => setEditingUser(null)}
|
||||
title={t('admin.editUser')}
|
||||
size="sm"
|
||||
footer={
|
||||
<div className="flex gap-3 justify-end">
|
||||
<button
|
||||
onClick={() => setEditingUser(null)}
|
||||
className="px-4 py-2 text-sm text-slate-600 border border-slate-200 rounded-lg hover:bg-slate-50"
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSaveUser}
|
||||
className="px-4 py-2 text-sm bg-slate-900 hover:bg-slate-700 text-white rounded-lg"
|
||||
>
|
||||
{t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{editingUser && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.username')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editForm.username}
|
||||
onChange={e => setEditForm(f => ({ ...f, username: e.target.value }))}
|
||||
className="w-full px-3 py-2.5 border border-slate-300 rounded-lg text-slate-900 focus:ring-2 focus:ring-slate-400 focus:border-transparent text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('common.email')}</label>
|
||||
<input
|
||||
type="email"
|
||||
value={editForm.email}
|
||||
onChange={e => setEditForm(f => ({ ...f, email: e.target.value }))}
|
||||
className="w-full px-3 py-2.5 border border-slate-300 rounded-lg text-slate-900 focus:ring-2 focus:ring-slate-400 focus:border-transparent text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('admin.newPassword')} <span className="text-slate-400 font-normal">({t('admin.newPasswordHint')})</span></label>
|
||||
<input
|
||||
type="password"
|
||||
value={editForm.password}
|
||||
onChange={e => setEditForm(f => ({ ...f, password: e.target.value }))}
|
||||
placeholder={t('admin.newPasswordPlaceholder')}
|
||||
className="w-full px-3 py-2.5 border border-slate-300 rounded-lg text-slate-900 focus:ring-2 focus:ring-slate-400 focus:border-transparent text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.role')}</label>
|
||||
<select
|
||||
value={editForm.role}
|
||||
onChange={e => setEditForm(f => ({ ...f, role: e.target.value }))}
|
||||
className="w-full px-3 py-2.5 border border-slate-300 rounded-lg text-slate-900 focus:ring-2 focus:ring-slate-400 focus:border-transparent bg-white text-sm"
|
||||
>
|
||||
<option value="user">{t('settings.roleUser')}</option>
|
||||
<option value="admin">{t('settings.roleAdmin')}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
593
client/src/pages/DashboardPage.jsx
Normal file
593
client/src/pages/DashboardPage.jsx
Normal file
@@ -0,0 +1,593 @@
|
||||
import React, { useEffect, useState, useRef } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { tripsApi } from '../api/client'
|
||||
import { useAuthStore } from '../store/authStore'
|
||||
import { useTranslation } from '../i18n'
|
||||
import Navbar from '../components/Layout/Navbar'
|
||||
import TravelStats from '../components/Dashboard/TravelStats'
|
||||
import TripFormModal from '../components/Trips/TripFormModal'
|
||||
import { useToast } from '../components/shared/Toast'
|
||||
import {
|
||||
Plus, Calendar, Trash2, Edit2, Map, ChevronDown, ChevronUp,
|
||||
Archive, ArchiveRestore, Clock, MapPin,
|
||||
} from 'lucide-react'
|
||||
|
||||
const font = { fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }
|
||||
|
||||
function daysUntil(dateStr) {
|
||||
if (!dateStr) return null
|
||||
const today = new Date(); today.setHours(0, 0, 0, 0)
|
||||
const d = new Date(dateStr + 'T00:00:00'); d.setHours(0, 0, 0, 0)
|
||||
return Math.round((d - today) / 86400000)
|
||||
}
|
||||
|
||||
function getTripStatus(trip) {
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
if (trip.start_date && trip.end_date && trip.start_date <= today && trip.end_date >= today) return 'ongoing'
|
||||
const until = daysUntil(trip.start_date)
|
||||
if (until === null) return null
|
||||
if (until === 0) return 'today'
|
||||
if (until === 1) return 'tomorrow'
|
||||
if (until > 1) return 'future'
|
||||
return 'past'
|
||||
}
|
||||
|
||||
function formatDate(dateStr, locale = 'de-DE') {
|
||||
if (!dateStr) return null
|
||||
return new Date(dateStr + 'T00:00:00').toLocaleDateString(locale, { day: 'numeric', month: 'short', year: 'numeric' })
|
||||
}
|
||||
|
||||
function formatDateShort(dateStr, locale = 'de-DE') {
|
||||
if (!dateStr) return null
|
||||
return new Date(dateStr + 'T00:00:00').toLocaleDateString(locale, { day: 'numeric', month: 'short' })
|
||||
}
|
||||
|
||||
function sortTrips(trips) {
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
function rank(t) {
|
||||
if (t.start_date && t.end_date && t.start_date <= today && t.end_date >= today) return 0 // ongoing
|
||||
if (t.start_date && t.start_date >= today) return 1 // upcoming
|
||||
return 2 // past
|
||||
}
|
||||
return [...trips].sort((a, b) => {
|
||||
const ra = rank(a), rb = rank(b)
|
||||
if (ra !== rb) return ra - rb
|
||||
const ad = a.start_date || '', bd = b.start_date || ''
|
||||
if (ra <= 1) return ad.localeCompare(bd)
|
||||
return bd.localeCompare(ad)
|
||||
})
|
||||
}
|
||||
|
||||
// Gradient backgrounds when no cover image
|
||||
const GRADIENTS = [
|
||||
'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
'linear-gradient(135deg, #f093fb 0%, #f5576c 100%)',
|
||||
'linear-gradient(135deg, #4facfe 0%, #00f2fe 100%)',
|
||||
'linear-gradient(135deg, #43e97b 0%, #38f9d7 100%)',
|
||||
'linear-gradient(135deg, #fa709a 0%, #fee140 100%)',
|
||||
'linear-gradient(135deg, #a18cd1 0%, #fbc2eb 100%)',
|
||||
'linear-gradient(135deg, #ffecd2 0%, #fcb69f 100%)',
|
||||
'linear-gradient(135deg, #96fbc4 0%, #f9f586 100%)',
|
||||
]
|
||||
function tripGradient(id) { return GRADIENTS[id % GRADIENTS.length] }
|
||||
|
||||
// ── Spotlight Card (next upcoming trip) ─────────────────────────────────────
|
||||
function SpotlightCard({ trip, onEdit, onDelete, onArchive, onClick, t, locale }) {
|
||||
const status = getTripStatus(trip)
|
||||
|
||||
const coverBg = trip.cover_image
|
||||
? `url(${trip.cover_image}) center/cover no-repeat`
|
||||
: tripGradient(trip.id)
|
||||
|
||||
return (
|
||||
<div style={{ marginBottom: 32, borderRadius: 20, overflow: 'hidden', boxShadow: '0 8px 40px rgba(0,0,0,0.13)', position: 'relative', cursor: 'pointer' }}
|
||||
onClick={() => onClick(trip)}>
|
||||
{/* Cover / Background */}
|
||||
<div style={{ height: 300, background: coverBg, position: 'relative' }}>
|
||||
<div style={{
|
||||
position: 'absolute', inset: 0,
|
||||
background: 'linear-gradient(to top, rgba(0,0,0,0.78) 0%, rgba(0,0,0,0.25) 50%, rgba(0,0,0,0.1) 100%)',
|
||||
}} />
|
||||
|
||||
{/* Badges top-left */}
|
||||
<div style={{ position: 'absolute', top: 16, left: 16, display: 'flex', gap: 8 }}>
|
||||
{status && (
|
||||
<span style={{
|
||||
background: 'rgba(255,255,255,0.15)', backdropFilter: 'blur(8px)',
|
||||
color: 'white', fontSize: 12, fontWeight: 700,
|
||||
padding: '5px 12px', borderRadius: 99, border: '1px solid rgba(255,255,255,0.25)',
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
}}>
|
||||
{status === 'ongoing' && (
|
||||
<span style={{ width: 7, height: 7, borderRadius: '50%', background: '#ef4444', animation: 'blink 1s ease-in-out infinite', display: 'inline-block', flexShrink: 0 }} />
|
||||
)}
|
||||
{status === 'ongoing' ? t('dashboard.status.ongoing')
|
||||
: status === 'today' ? t('dashboard.status.today')
|
||||
: status === 'tomorrow' ? t('dashboard.status.tomorrow')
|
||||
: status === 'future' ? t('dashboard.status.daysLeft', { count: daysUntil(trip.start_date) })
|
||||
: t('dashboard.status.past')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Top-right actions */}
|
||||
<div style={{ position: 'absolute', top: 16, right: 16, display: 'flex', gap: 6 }}
|
||||
onClick={e => e.stopPropagation()}>
|
||||
<IconBtn onClick={() => onEdit(trip)} title={t('common.edit')}><Edit2 size={14} /></IconBtn>
|
||||
<IconBtn onClick={() => onArchive(trip.id)} title={t('dashboard.archive')}><Archive size={14} /></IconBtn>
|
||||
<IconBtn onClick={() => onDelete(trip)} title={t('common.delete')} danger><Trash2 size={14} /></IconBtn>
|
||||
</div>
|
||||
|
||||
{/* Bottom content */}
|
||||
<div style={{ position: 'absolute', bottom: 0, left: 0, right: 0, padding: '20px 24px' }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'rgba(255,255,255,0.65)', textTransform: 'uppercase', letterSpacing: '0.08em', marginBottom: 6 }}>
|
||||
{trip.is_owner ? t('dashboard.nextTrip') : t('dashboard.sharedBy', { name: trip.owner_username })}
|
||||
</div>
|
||||
<h2 style={{ margin: 0, fontSize: 26, fontWeight: 800, color: 'white', lineHeight: 1.2, textShadow: '0 1px 4px rgba(0,0,0,0.3)' }}>
|
||||
{trip.title}
|
||||
</h2>
|
||||
{trip.description && (
|
||||
<p style={{ margin: '6px 0 0', fontSize: 13.5, color: 'rgba(255,255,255,0.75)', lineHeight: 1.4, overflow: 'hidden', display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical' }}>
|
||||
{trip.description}
|
||||
</p>
|
||||
)}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16, marginTop: 12 }}>
|
||||
{trip.start_date && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 5, color: 'rgba(255,255,255,0.8)', fontSize: 13 }}>
|
||||
<Calendar size={13} />
|
||||
{formatDateShort(trip.start_date, locale)}
|
||||
{trip.end_date && <> — {formatDateShort(trip.end_date, locale)}</>}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 5, color: 'rgba(255,255,255,0.8)', fontSize: 13 }}>
|
||||
<Clock size={13} /> {trip.day_count || 0} {t('dashboard.days')}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 5, color: 'rgba(255,255,255,0.8)', fontSize: 13 }}>
|
||||
<MapPin size={13} /> {trip.place_count || 0} {t('dashboard.places')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Regular Trip Card ────────────────────────────────────────────────────────
|
||||
function TripCard({ trip, onEdit, onDelete, onArchive, onClick, t, locale }) {
|
||||
const status = getTripStatus(trip)
|
||||
const [hovered, setHovered] = useState(false)
|
||||
|
||||
const coverBg = trip.cover_image
|
||||
? `url(${trip.cover_image}) center/cover no-repeat`
|
||||
: tripGradient(trip.id)
|
||||
|
||||
return (
|
||||
<div
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
onClick={() => onClick(trip)}
|
||||
style={{
|
||||
background: 'var(--bg-card)', borderRadius: 16, overflow: 'hidden', cursor: 'pointer',
|
||||
border: '1px solid var(--border-primary)', transition: 'all 0.18s',
|
||||
boxShadow: hovered ? '0 8px 28px rgba(0,0,0,0.10)' : '0 1px 4px rgba(0,0,0,0.04)',
|
||||
transform: hovered ? 'translateY(-2px)' : 'none',
|
||||
}}
|
||||
>
|
||||
{/* Image area */}
|
||||
<div style={{ height: 120, background: coverBg, position: 'relative', overflow: 'hidden' }}>
|
||||
{trip.cover_image && <div style={{ position: 'absolute', inset: 0, background: 'linear-gradient(to top, rgba(0,0,0,0.35) 0%, transparent 60%)' }} />}
|
||||
|
||||
{/* Status badge */}
|
||||
{status && (
|
||||
<div style={{ position: 'absolute', top: 8, left: 8 }}>
|
||||
<span style={{
|
||||
fontSize: 10.5, fontWeight: 700, padding: '2px 8px', borderRadius: 99,
|
||||
background: 'rgba(0,0,0,0.4)', color: 'white', backdropFilter: 'blur(4px)',
|
||||
display: 'flex', alignItems: 'center', gap: 5,
|
||||
}}>
|
||||
{status === 'ongoing' && (
|
||||
<span style={{ width: 6, height: 6, borderRadius: '50%', background: '#ef4444', animation: 'blink 1s ease-in-out infinite', display: 'inline-block', flexShrink: 0 }} />
|
||||
)}
|
||||
{status === 'ongoing' ? t('dashboard.status.ongoing')
|
||||
: status === 'today' ? t('dashboard.status.today')
|
||||
: status === 'tomorrow' ? t('dashboard.status.tomorrow')
|
||||
: status === 'future' ? t('dashboard.status.daysLeft', { count: daysUntil(trip.start_date) })
|
||||
: t('dashboard.status.past')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div style={{ padding: '12px 14px 14px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, overflow: 'hidden', marginBottom: 3 }}>
|
||||
<span style={{ fontWeight: 700, fontSize: 14, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{trip.title}
|
||||
</span>
|
||||
{!trip.is_owner && (
|
||||
<span style={{ fontSize: 10, fontWeight: 600, color: 'var(--text-muted)', background: 'var(--bg-tertiary)', padding: '1px 6px', borderRadius: 99, whiteSpace: 'nowrap', flexShrink: 0 }}>
|
||||
{t('dashboard.shared')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{trip.description && (
|
||||
<p style={{ fontSize: 12, color: 'var(--text-faint)', margin: '0 0 8px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{trip.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{(trip.start_date || trip.end_date) && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 12, color: 'var(--text-muted)', marginBottom: 10 }}>
|
||||
<Calendar size={11} style={{ flexShrink: 0 }} />
|
||||
{trip.start_date && trip.end_date
|
||||
? `${formatDateShort(trip.start_date, locale)} — ${formatDateShort(trip.end_date, locale)}`
|
||||
: formatDate(trip.start_date || trip.end_date, locale)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 10 }}>
|
||||
<Stat label={t('dashboard.days')} value={trip.day_count || 0} />
|
||||
<Stat label={t('dashboard.places')} value={trip.place_count || 0} />
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 6, borderTop: '1px solid #f3f4f6', paddingTop: 10 }}
|
||||
onClick={e => e.stopPropagation()}>
|
||||
<CardAction onClick={() => onEdit(trip)} icon={<Edit2 size={12} />} label={t('common.edit')} />
|
||||
<CardAction onClick={() => onArchive(trip.id)} icon={<Archive size={12} />} label={t('dashboard.archive')} />
|
||||
<CardAction onClick={() => onDelete(trip)} icon={<Trash2 size={12} />} label={t('common.delete')} danger />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Archived Trip Row ────────────────────────────────────────────────────────
|
||||
function ArchivedRow({ trip, onEdit, onUnarchive, onDelete, onClick, t, locale }) {
|
||||
return (
|
||||
<div onClick={() => onClick(trip)} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 12, padding: '10px 16px',
|
||||
borderRadius: 12, border: '1px solid #f3f4f6', background: 'white', cursor: 'pointer',
|
||||
transition: 'border-color 0.12s',
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.borderColor = '#e5e7eb'}
|
||||
onMouseLeave={e => e.currentTarget.style.borderColor = '#f3f4f6'}>
|
||||
{/* Mini cover */}
|
||||
<div style={{
|
||||
width: 40, height: 40, borderRadius: 10, flexShrink: 0,
|
||||
background: trip.cover_image ? `url(${trip.cover_image}) center/cover no-repeat` : tripGradient(trip.id),
|
||||
opacity: 0.7,
|
||||
}} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: '#6b7280', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{trip.title}</span>
|
||||
{!trip.is_owner && <span style={{ fontSize: 10, color: '#9ca3af', background: '#f3f4f6', padding: '1px 6px', borderRadius: 99, flexShrink: 0 }}>{t('dashboard.shared')}</span>}
|
||||
</div>
|
||||
{trip.start_date && (
|
||||
<div style={{ fontSize: 11, color: '#9ca3af', marginTop: 1 }}>
|
||||
{formatDateShort(trip.start_date, locale)}{trip.end_date ? ` — ${formatDateShort(trip.end_date, locale)}` : ''}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 4, flexShrink: 0 }} onClick={e => e.stopPropagation()}>
|
||||
<button onClick={() => onUnarchive(trip.id)} title={t('dashboard.restore')} style={{ padding: '4px 8px', borderRadius: 8, border: '1px solid #e5e7eb', background: 'white', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 4, fontSize: 11, color: '#6b7280' }}
|
||||
onMouseEnter={e => { e.currentTarget.style.borderColor = 'var(--text-faint)'; e.currentTarget.style.color = 'var(--text-primary)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.borderColor = '#e5e7eb'; e.currentTarget.style.color = '#6b7280' }}>
|
||||
<ArchiveRestore size={12} /> {t('dashboard.restore')}
|
||||
</button>
|
||||
<button onClick={() => onDelete(trip)} title={t('common.delete')} style={{ padding: '4px 8px', borderRadius: 8, border: '1px solid #e5e7eb', background: 'white', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 4, fontSize: 11, color: '#9ca3af' }}
|
||||
onMouseEnter={e => { e.currentTarget.style.borderColor = '#fecaca'; e.currentTarget.style.color = '#ef4444' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.borderColor = '#e5e7eb'; e.currentTarget.style.color = '#9ca3af' }}>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
function Stat({ value, label }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<span style={{ fontSize: 12, fontWeight: 700, color: '#374151' }}>{value}</span>
|
||||
<span style={{ fontSize: 11, color: '#9ca3af' }}>{label}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CardAction({ onClick, icon, label, danger }) {
|
||||
return (
|
||||
<button onClick={onClick} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 4, padding: '4px 8px', borderRadius: 8,
|
||||
border: 'none', background: 'none', cursor: 'pointer', fontSize: 11,
|
||||
color: danger ? '#9ca3af' : '#9ca3af', fontFamily: 'inherit',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.background = danger ? '#fef2f2' : '#f3f4f6'; e.currentTarget.style.color = danger ? '#ef4444' : '#374151' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = 'none'; e.currentTarget.style.color = '#9ca3af' }}>
|
||||
{icon}{label}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function IconBtn({ onClick, title, danger, loading, children }) {
|
||||
return (
|
||||
<button onClick={onClick} title={title} disabled={loading} style={{
|
||||
width: 32, height: 32, borderRadius: 99, border: '1px solid rgba(255,255,255,0.25)',
|
||||
background: 'rgba(0,0,0,0.3)', backdropFilter: 'blur(8px)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
cursor: 'pointer', color: danger ? '#fca5a5' : 'white', transition: 'background 0.12s',
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.background = danger ? 'rgba(239,68,68,0.5)' : 'rgba(255,255,255,0.25)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'rgba(0,0,0,0.3)'}>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Skeleton ─────────────────────────────────────────────────────────────────
|
||||
function SkeletonCard() {
|
||||
return (
|
||||
<div style={{ background: 'white', borderRadius: 16, overflow: 'hidden', border: '1px solid #f3f4f6' }}>
|
||||
<div style={{ height: 120, background: '#f3f4f6', animation: 'pulse 1.5s ease-in-out infinite' }} />
|
||||
<div style={{ padding: '12px 14px 14px' }}>
|
||||
<div style={{ height: 14, background: '#f3f4f6', borderRadius: 6, marginBottom: 8, width: '70%' }} />
|
||||
<div style={{ height: 11, background: '#f3f4f6', borderRadius: 6, width: '50%' }} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Main Page ────────────────────────────────────────────────────────────────
|
||||
export default function DashboardPage() {
|
||||
const [trips, setTrips] = useState([])
|
||||
const [archivedTrips, setArchivedTrips] = useState([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [editingTrip, setEditingTrip] = useState(null)
|
||||
const [showArchived, setShowArchived] = useState(false)
|
||||
|
||||
const navigate = useNavigate()
|
||||
const toast = useToast()
|
||||
const { t, locale } = useTranslation()
|
||||
|
||||
useEffect(() => { loadTrips() }, [])
|
||||
|
||||
const loadTrips = async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const [active, archived] = await Promise.all([
|
||||
tripsApi.list(),
|
||||
tripsApi.list({ archived: 1 }),
|
||||
])
|
||||
setTrips(sortTrips(active.trips))
|
||||
setArchivedTrips(sortTrips(archived.trips))
|
||||
} catch {
|
||||
toast.error(t('dashboard.toast.loadError'))
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreate = async (tripData) => {
|
||||
try {
|
||||
const data = await tripsApi.create(tripData)
|
||||
setTrips(prev => sortTrips([data.trip, ...prev]))
|
||||
toast.success(t('dashboard.toast.created'))
|
||||
} catch (err) {
|
||||
throw new Error(err.response?.data?.error || t('dashboard.toast.createError'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpdate = async (tripData) => {
|
||||
try {
|
||||
const data = await tripsApi.update(editingTrip.id, tripData)
|
||||
setTrips(prev => sortTrips(prev.map(t => t.id === editingTrip.id ? data.trip : t)))
|
||||
toast.success(t('dashboard.toast.updated'))
|
||||
} catch (err) {
|
||||
throw new Error(err.response?.data?.error || t('dashboard.toast.updateError'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (trip) => {
|
||||
if (!confirm(t('dashboard.confirm.delete', { title: trip.title }))) return
|
||||
try {
|
||||
await tripsApi.delete(trip.id)
|
||||
setTrips(prev => prev.filter(t => t.id !== trip.id))
|
||||
setArchivedTrips(prev => prev.filter(t => t.id !== trip.id))
|
||||
toast.success(t('dashboard.toast.deleted'))
|
||||
} catch {
|
||||
toast.error(t('dashboard.toast.deleteError'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleArchive = async (id) => {
|
||||
try {
|
||||
const data = await tripsApi.archive(id)
|
||||
setTrips(prev => prev.filter(t => t.id !== id))
|
||||
setArchivedTrips(prev => sortTrips([data.trip, ...prev]))
|
||||
toast.success(t('dashboard.toast.archived'))
|
||||
} catch {
|
||||
toast.error(t('dashboard.toast.archiveError'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleUnarchive = async (id) => {
|
||||
try {
|
||||
const data = await tripsApi.unarchive(id)
|
||||
setArchivedTrips(prev => prev.filter(t => t.id !== id))
|
||||
setTrips(prev => sortTrips([data.trip, ...prev]))
|
||||
toast.success(t('dashboard.toast.restored'))
|
||||
} catch {
|
||||
toast.error(t('dashboard.toast.restoreError'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleCoverUpdate = (tripId, coverImage) => {
|
||||
const update = t => t.id === tripId ? { ...t, cover_image: coverImage } : t
|
||||
setTrips(prev => prev.map(update))
|
||||
setArchivedTrips(prev => prev.map(update))
|
||||
}
|
||||
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
const spotlight = trips.find(t => t.start_date && t.end_date && t.start_date <= today && t.end_date >= today)
|
||||
|| trips.find(t => t.start_date && t.start_date >= today)
|
||||
|| trips[0]
|
||||
|| null
|
||||
const rest = spotlight ? trips.filter(t => t.id !== spotlight.id) : trips
|
||||
|
||||
return (
|
||||
<div style={{ minHeight: '100vh', background: 'var(--bg-secondary)', ...font }}>
|
||||
<Navbar />
|
||||
<div style={{ paddingTop: 56 }}>
|
||||
<div style={{ maxWidth: 1300, margin: '0 auto', padding: '32px 20px 60px' }}>
|
||||
|
||||
{/* Header */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 28 }}>
|
||||
<div>
|
||||
<h1 style={{ margin: 0, fontSize: 24, fontWeight: 800, color: 'var(--text-primary)' }}>{t('dashboard.title')}</h1>
|
||||
<p style={{ margin: '3px 0 0', fontSize: 13, color: '#9ca3af' }}>
|
||||
{isLoading ? t('common.loading')
|
||||
: trips.length > 0 ? `${t(trips.length !== 1 ? 'dashboard.subtitle.activeMany' : 'dashboard.subtitle.activeOne', { count: trips.length })}${archivedTrips.length > 0 ? t('dashboard.subtitle.archivedSuffix', { count: archivedTrips.length }) : ''}`
|
||||
: t('dashboard.subtitle.empty')}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => { setEditingTrip(null); setShowForm(true) }}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 7, padding: '9px 18px',
|
||||
background: 'var(--accent)', color: 'var(--accent-text)', border: 'none', borderRadius: 12,
|
||||
fontSize: 13, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.opacity = '0.85'}
|
||||
onMouseLeave={e => e.currentTarget.style.opacity = '1'}
|
||||
>
|
||||
<Plus size={15} /> {t('dashboard.newTrip')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 24, alignItems: 'flex-start' }}>
|
||||
{/* Main content */}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
|
||||
{/* Loading skeletons */}
|
||||
{isLoading && (
|
||||
<>
|
||||
<div style={{ height: 260, background: '#e5e7eb', borderRadius: 20, marginBottom: 32, animation: 'pulse 1.5s ease-in-out infinite' }} />
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: 16 }}>
|
||||
{[1, 2, 3].map(i => <SkeletonCard key={i} />)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{!isLoading && trips.length === 0 && (
|
||||
<div style={{ textAlign: 'center', padding: '80px 20px' }}>
|
||||
<div style={{ width: 80, height: 80, background: '#f3f4f6', borderRadius: '50%', display: 'flex', alignItems: 'center', justifyContent: 'center', margin: '0 auto 20px' }}>
|
||||
<Map size={36} style={{ color: '#d1d5db' }} />
|
||||
</div>
|
||||
<h3 style={{ margin: '0 0 8px', fontSize: 18, fontWeight: 700, color: 'var(--text-primary)' }}>{t('dashboard.emptyTitle')}</h3>
|
||||
<p style={{ margin: '0 0 24px', fontSize: 14, color: '#9ca3af', maxWidth: 340, marginLeft: 'auto', marginRight: 'auto' }}>
|
||||
{t('dashboard.emptyText')}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => { setEditingTrip(null); setShowForm(true) }}
|
||||
style={{ display: 'inline-flex', alignItems: 'center', gap: 7, padding: '10px 22px', background: 'var(--accent)', color: 'var(--accent-text)', border: 'none', borderRadius: 12, fontSize: 14, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}
|
||||
>
|
||||
<Plus size={16} /> {t('dashboard.emptyButton')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Spotlight */}
|
||||
{!isLoading && spotlight && (
|
||||
<SpotlightCard
|
||||
trip={spotlight}
|
||||
t={t} locale={locale}
|
||||
onEdit={tr => { setEditingTrip(tr); setShowForm(true) }}
|
||||
onDelete={handleDelete}
|
||||
onArchive={handleArchive}
|
||||
onClick={tr => navigate(`/trips/${tr.id}`)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Rest grid */}
|
||||
{!isLoading && rest.length > 0 && (
|
||||
<div className="trip-grid" style={{ display: 'grid', gap: 16, marginBottom: 40 }}>
|
||||
{rest.map(trip => (
|
||||
<TripCard
|
||||
key={trip.id}
|
||||
trip={trip}
|
||||
t={t} locale={locale}
|
||||
onEdit={tr => { setEditingTrip(tr); setShowForm(true) }}
|
||||
onDelete={handleDelete}
|
||||
onArchive={handleArchive}
|
||||
onClick={tr => navigate(`/trips/${tr.id}`)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Archived section */}
|
||||
{!isLoading && archivedTrips.length > 0 && (
|
||||
<div style={{ borderTop: '1px solid #f3f4f6', paddingTop: 24 }}>
|
||||
<button
|
||||
onClick={() => setShowArchived(v => !v)}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 8, background: 'none', border: 'none', cursor: 'pointer', padding: '4px 0', marginBottom: showArchived ? 16 : 0, fontFamily: 'inherit' }}
|
||||
>
|
||||
<Archive size={15} style={{ color: '#9ca3af' }} />
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: '#6b7280' }}>
|
||||
{t('dashboard.archived')} ({archivedTrips.length})
|
||||
</span>
|
||||
{showArchived ? <ChevronUp size={14} style={{ color: '#9ca3af' }} /> : <ChevronDown size={14} style={{ color: '#9ca3af' }} />}
|
||||
</button>
|
||||
{showArchived && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{archivedTrips.map(trip => (
|
||||
<ArchivedRow
|
||||
key={trip.id}
|
||||
trip={trip}
|
||||
t={t} locale={locale}
|
||||
onEdit={tr => { setEditingTrip(tr); setShowForm(true) }}
|
||||
onUnarchive={handleUnarchive}
|
||||
onDelete={handleDelete}
|
||||
onClick={tr => navigate(`/trips/${tr.id}`)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stats sidebar */}
|
||||
<div className="hidden lg:block" style={{ position: 'sticky', top: 80, flexShrink: 0 }}>
|
||||
<TravelStats />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TripFormModal
|
||||
isOpen={showForm}
|
||||
onClose={() => { setShowForm(false); setEditingTrip(null) }}
|
||||
onSave={editingTrip ? handleUpdate : handleCreate}
|
||||
trip={editingTrip}
|
||||
onCoverUpdate={handleCoverUpdate}
|
||||
/>
|
||||
|
||||
<style>{`
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1 }
|
||||
50% { opacity: 0.5 }
|
||||
}
|
||||
@keyframes blink {
|
||||
0%, 100% { opacity: 1 }
|
||||
50% { opacity: 0 }
|
||||
}
|
||||
.trip-grid { grid-template-columns: repeat(3, 1fr); }
|
||||
@media(max-width: 1024px) { .trip-grid { grid-template-columns: repeat(2, 1fr); } }
|
||||
@media(max-width: 640px) { .trip-grid { grid-template-columns: 1fr; } }
|
||||
`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
94
client/src/pages/FilesPage.jsx
Normal file
94
client/src/pages/FilesPage.jsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { useParams, useNavigate, Link } from 'react-router-dom'
|
||||
import { useTripStore } from '../store/tripStore'
|
||||
import { tripsApi, placesApi } from '../api/client'
|
||||
import Navbar from '../components/Layout/Navbar'
|
||||
import FileManager from '../components/Files/FileManager'
|
||||
import { ArrowLeft } from 'lucide-react'
|
||||
|
||||
export default function FilesPage() {
|
||||
const { id: tripId } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const tripStore = useTripStore()
|
||||
|
||||
const [trip, setTrip] = useState(null)
|
||||
const [places, setPlaces] = useState([])
|
||||
const [files, setFiles] = useState([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
}, [tripId])
|
||||
|
||||
const loadData = async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const [tripData, placesData] = await Promise.all([
|
||||
tripsApi.get(tripId),
|
||||
placesApi.list(tripId),
|
||||
])
|
||||
setTrip(tripData.trip)
|
||||
setPlaces(placesData.places)
|
||||
await tripStore.loadFiles(tripId)
|
||||
} catch (err) {
|
||||
navigate('/dashboard')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setFiles(tripStore.files)
|
||||
}, [tripStore.files])
|
||||
|
||||
const handleUpload = async (formData) => {
|
||||
await tripStore.addFile(tripId, formData)
|
||||
}
|
||||
|
||||
const handleDelete = async (fileId) => {
|
||||
await tripStore.deleteFile(tripId, fileId)
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-slate-50">
|
||||
<div className="w-10 h-10 border-4 border-slate-200 border-t-slate-700 rounded-full animate-spin"></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50">
|
||||
<Navbar tripTitle={trip?.title} tripId={tripId} showBack onBack={() => navigate(`/trips/${tripId}`)} />
|
||||
|
||||
<div className="pt-14">
|
||||
<div className="max-w-5xl mx-auto px-4 py-6">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<Link
|
||||
to={`/trips/${tripId}`}
|
||||
className="flex items-center gap-1 text-sm text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
Zurück zur Planung
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Dateien & Dokumente</h1>
|
||||
<p className="text-gray-500 text-sm">{files.length} Dateien für {trip?.title}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FileManager
|
||||
files={files}
|
||||
onUpload={handleUpload}
|
||||
onDelete={handleDelete}
|
||||
places={places}
|
||||
tripId={tripId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
231
client/src/pages/LoginPage.jsx
Normal file
231
client/src/pages/LoginPage.jsx
Normal file
@@ -0,0 +1,231 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useAuthStore } from '../store/authStore'
|
||||
import { useSettingsStore } from '../store/settingsStore'
|
||||
import { useTranslation } from '../i18n'
|
||||
import { authApi } from '../api/client'
|
||||
import { Plane, Eye, EyeOff, Mail, Lock, MapPin, Calendar, Package, User, Globe } from 'lucide-react'
|
||||
|
||||
export default function LoginPage() {
|
||||
const { t, language } = useTranslation()
|
||||
const [mode, setMode] = useState('login') // 'login' | 'register'
|
||||
const [username, setUsername] = useState('')
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [appConfig, setAppConfig] = useState(null)
|
||||
|
||||
const { login, register } = useAuthStore()
|
||||
const { setLanguageLocal } = useSettingsStore()
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
authApi.getAppConfig?.().catch(() => null).then(config => {
|
||||
if (config) {
|
||||
setAppConfig(config)
|
||||
if (!config.has_users) setMode('register')
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setIsLoading(true)
|
||||
try {
|
||||
if (mode === 'register') {
|
||||
if (!username.trim()) { setError('Username is required'); setIsLoading(false); return }
|
||||
if (password.length < 6) { setError('Password must be at least 6 characters'); setIsLoading(false); return }
|
||||
await register(username, email, password)
|
||||
} else {
|
||||
await login(email, password)
|
||||
}
|
||||
navigate('/dashboard')
|
||||
} catch (err) {
|
||||
setError(err.message || t('login.error'))
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const showRegisterOption = appConfig?.allow_registration || !appConfig?.has_users
|
||||
|
||||
const inputBase = {
|
||||
width: '100%', padding: '11px 12px 11px 40px', border: '1px solid #e5e7eb',
|
||||
borderRadius: 12, fontSize: 14, fontFamily: 'inherit', outline: 'none',
|
||||
color: '#111827', background: 'white', boxSizing: 'border-box', transition: 'border-color 0.15s',
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ minHeight: '100vh', display: 'flex', fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif", position: 'relative' }}>
|
||||
|
||||
{/* Sprach-Toggle oben rechts */}
|
||||
<button
|
||||
onClick={() => setLanguageLocal(language === 'en' ? 'de' : 'en')}
|
||||
style={{
|
||||
position: 'absolute', top: 16, right: 16, zIndex: 10,
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
padding: '6px 12px', borderRadius: 99,
|
||||
background: 'rgba(0,0,0,0.06)', border: 'none',
|
||||
fontSize: 13, fontWeight: 500, color: '#374151',
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
transition: 'background 0.15s',
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'rgba(0,0,0,0.1)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'rgba(0,0,0,0.06)'}
|
||||
>
|
||||
<Globe size={14} />
|
||||
{language === 'en' ? 'DE' : 'EN'}
|
||||
</button>
|
||||
|
||||
{/* Left — branding */}
|
||||
<div style={{ display: 'none', width: '45%', background: '#111827', flexDirection: 'column', justifyContent: 'center', alignItems: 'center', padding: '60px 48px' }}
|
||||
className="lg-panel">
|
||||
<style>{`@media(min-width:1024px){.lg-panel{display:flex!important}}`}</style>
|
||||
|
||||
{/* Logo */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 48 }}>
|
||||
<div style={{ width: 44, height: 44, background: 'white', borderRadius: 12, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Plane size={22} style={{ color: '#111827' }} />
|
||||
</div>
|
||||
<span style={{ fontSize: 26, fontWeight: 800, color: 'white', letterSpacing: '-0.02em' }}>NOMAD</span>
|
||||
</div>
|
||||
|
||||
<div style={{ maxWidth: 320, textAlign: 'center' }}>
|
||||
<h2 style={{ margin: '0 0 16px', fontSize: 32, fontWeight: 800, color: 'white', lineHeight: 1.2 }}>
|
||||
{t('login.tagline')}
|
||||
</h2>
|
||||
<p style={{ margin: 0, fontSize: 15, color: 'rgba(255,255,255,0.55)', lineHeight: 1.65 }}>
|
||||
{t('login.description')}
|
||||
</p>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 12, marginTop: 40 }}>
|
||||
{[
|
||||
{ Icon: MapPin, label: t('login.features.places') },
|
||||
{ Icon: Calendar, label: t('login.features.schedule') },
|
||||
{ Icon: Package, label: t('login.features.packing') },
|
||||
].map(({ Icon, label }) => (
|
||||
<div key={label} style={{ background: 'rgba(255,255,255,0.07)', borderRadius: 14, padding: '18px 12px', border: '1px solid rgba(255,255,255,0.1)', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 8 }}>
|
||||
<Icon size={20} style={{ color: 'rgba(255,255,255,0.6)' }} />
|
||||
<div style={{ fontSize: 12, color: 'rgba(255,255,255,0.45)', fontWeight: 500 }}>{label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right — form */}
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '32px 24px', background: '#f9fafb' }}>
|
||||
<div style={{ width: '100%', maxWidth: 400 }}>
|
||||
|
||||
{/* Mobile logo */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 36, justifyContent: 'center' }}
|
||||
className="mobile-logo">
|
||||
<style>{`@media(min-width:1024px){.mobile-logo{display:none!important}}`}</style>
|
||||
<div style={{ width: 36, height: 36, background: '#111827', borderRadius: 10, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Plane size={18} style={{ color: 'white' }} />
|
||||
</div>
|
||||
<span style={{ fontSize: 22, fontWeight: 800, color: '#111827', letterSpacing: '-0.02em' }}>NOMAD</span>
|
||||
</div>
|
||||
|
||||
<div style={{ background: 'white', borderRadius: 20, border: '1px solid #e5e7eb', padding: '36px 32px', boxShadow: '0 2px 16px rgba(0,0,0,0.06)' }}>
|
||||
<h2 style={{ margin: '0 0 4px', fontSize: 22, fontWeight: 800, color: '#111827' }}>
|
||||
{mode === 'register' ? (!appConfig?.has_users ? t('login.createAdmin') : t('login.createAccount')) : t('login.title')}
|
||||
</h2>
|
||||
<p style={{ margin: '0 0 28px', fontSize: 13.5, color: '#9ca3af' }}>
|
||||
{mode === 'register' ? (!appConfig?.has_users ? t('login.createAdminHint') : t('login.createAccountHint')) : t('login.subtitle')}
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
{error && (
|
||||
<div style={{ padding: '10px 14px', background: '#fef2f2', border: '1px solid #fecaca', borderRadius: 10, fontSize: 13, color: '#dc2626' }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Username (register only) */}
|
||||
{mode === 'register' && (
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: 12.5, fontWeight: 600, color: '#374151', marginBottom: 6 }}>{t('login.username')}</label>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<User size={15} style={{ position: 'absolute', left: 13, top: '50%', transform: 'translateY(-50%)', color: '#9ca3af', pointerEvents: 'none' }} />
|
||||
<input
|
||||
type="text" value={username} onChange={e => setUsername(e.target.value)} required
|
||||
placeholder="admin" style={inputBase}
|
||||
onFocus={e => e.target.style.borderColor = '#111827'}
|
||||
onBlur={e => e.target.style.borderColor = '#e5e7eb'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Email */}
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: 12.5, fontWeight: 600, color: '#374151', marginBottom: 6 }}>{t('common.email')}</label>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<Mail size={15} style={{ position: 'absolute', left: 13, top: '50%', transform: 'translateY(-50%)', color: '#9ca3af', pointerEvents: 'none' }} />
|
||||
<input
|
||||
type="email" value={email} onChange={e => setEmail(e.target.value)} required
|
||||
placeholder="deine@email.de" style={inputBase}
|
||||
onFocus={e => e.target.style.borderColor = '#111827'}
|
||||
onBlur={e => e.target.style.borderColor = '#e5e7eb'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Password */}
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: 12.5, fontWeight: 600, color: '#374151', marginBottom: 6 }}>{t('common.password')}</label>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<Lock size={15} style={{ position: 'absolute', left: 13, top: '50%', transform: 'translateY(-50%)', color: '#9ca3af', pointerEvents: 'none' }} />
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'} value={password} onChange={e => setPassword(e.target.value)} required
|
||||
placeholder="••••••••" style={{ ...inputBase, paddingRight: 44 }}
|
||||
onFocus={e => e.target.style.borderColor = '#111827'}
|
||||
onBlur={e => e.target.style.borderColor = '#e5e7eb'}
|
||||
/>
|
||||
<button type="button" onClick={() => setShowPassword(v => !v)} style={{
|
||||
position: 'absolute', right: 12, top: '50%', transform: 'translateY(-50%)',
|
||||
background: 'none', border: 'none', cursor: 'pointer', padding: 2, display: 'flex', color: '#9ca3af',
|
||||
}}>
|
||||
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" disabled={isLoading} style={{
|
||||
marginTop: 4, width: '100%', padding: '12px', background: '#111827', color: 'white',
|
||||
border: 'none', borderRadius: 12, fontSize: 14, fontWeight: 700, cursor: isLoading ? 'default' : 'pointer',
|
||||
fontFamily: 'inherit', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
|
||||
opacity: isLoading ? 0.7 : 1, transition: 'opacity 0.15s',
|
||||
}}
|
||||
onMouseEnter={e => { if (!isLoading) e.currentTarget.style.background = '#1f2937' }}
|
||||
onMouseLeave={e => e.currentTarget.style.background = '#111827'}
|
||||
>
|
||||
{isLoading
|
||||
? <><div style={{ width: 15, height: 15, border: '2px solid rgba(255,255,255,0.3)', borderTopColor: 'white', borderRadius: '50%', animation: 'spin 0.7s linear infinite' }} />{mode === 'register' ? t('login.creating') : t('login.signingIn')}</>
|
||||
: mode === 'register' ? t('login.createAccount') : t('login.signIn')
|
||||
}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Toggle login/register */}
|
||||
{showRegisterOption && appConfig?.has_users && (
|
||||
<p style={{ textAlign: 'center', marginTop: 16, fontSize: 13, color: '#9ca3af' }}>
|
||||
{mode === 'login' ? t('login.noAccount') + ' ' : t('login.hasAccount') + ' '}
|
||||
<button onClick={() => { setMode(m => m === 'login' ? 'register' : 'login'); setError('') }}
|
||||
style={{ background: 'none', border: 'none', color: '#111827', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', fontSize: 13 }}>
|
||||
{mode === 'login' ? t('login.register') : t('login.signIn')}
|
||||
</button>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>{`@keyframes spin { to { transform: rotate(360deg) } }`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
107
client/src/pages/PhotosPage.jsx
Normal file
107
client/src/pages/PhotosPage.jsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { useParams, useNavigate, Link } from 'react-router-dom'
|
||||
import { useTripStore } from '../store/tripStore'
|
||||
import { tripsApi, daysApi, placesApi } from '../api/client'
|
||||
import Navbar from '../components/Layout/Navbar'
|
||||
import PhotoGallery from '../components/Photos/PhotoGallery'
|
||||
import { ArrowLeft } from 'lucide-react'
|
||||
|
||||
export default function PhotosPage() {
|
||||
const { id: tripId } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const tripStore = useTripStore()
|
||||
|
||||
const [trip, setTrip] = useState(null)
|
||||
const [days, setDays] = useState([])
|
||||
const [places, setPlaces] = useState([])
|
||||
const [photos, setPhotos] = useState([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
}, [tripId])
|
||||
|
||||
const loadData = async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const [tripData, daysData, placesData] = await Promise.all([
|
||||
tripsApi.get(tripId),
|
||||
daysApi.list(tripId),
|
||||
placesApi.list(tripId),
|
||||
])
|
||||
setTrip(tripData.trip)
|
||||
setDays(daysData.days)
|
||||
setPlaces(placesData.places)
|
||||
|
||||
// Load photos
|
||||
await tripStore.loadPhotos(tripId)
|
||||
} catch (err) {
|
||||
navigate('/dashboard')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Sync photos from store
|
||||
useEffect(() => {
|
||||
setPhotos(tripStore.photos)
|
||||
}, [tripStore.photos])
|
||||
|
||||
const handleUpload = async (formData) => {
|
||||
await tripStore.addPhoto(tripId, formData)
|
||||
}
|
||||
|
||||
const handleDelete = async (photoId) => {
|
||||
await tripStore.deletePhoto(tripId, photoId)
|
||||
}
|
||||
|
||||
const handleUpdate = async (photoId, data) => {
|
||||
await tripStore.updatePhoto(tripId, photoId, data)
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-slate-50">
|
||||
<div className="w-10 h-10 border-4 border-slate-200 border-t-slate-700 rounded-full animate-spin"></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50">
|
||||
<Navbar tripTitle={trip?.title} tripId={tripId} showBack onBack={() => navigate(`/trips/${tripId}`)} />
|
||||
|
||||
<div className="pt-14">
|
||||
<div className="max-w-7xl mx-auto px-4 py-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<Link
|
||||
to={`/trips/${tripId}`}
|
||||
className="flex items-center gap-1 text-sm text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
Zurück zur Planung
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Fotos</h1>
|
||||
<p className="text-gray-500 text-sm">{photos.length} Fotos für {trip?.title}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PhotoGallery
|
||||
photos={photos}
|
||||
onUpload={handleUpload}
|
||||
onDelete={handleDelete}
|
||||
onUpdate={handleUpdate}
|
||||
places={places}
|
||||
days={days}
|
||||
tripId={tripId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
185
client/src/pages/RegisterPage.jsx
Normal file
185
client/src/pages/RegisterPage.jsx
Normal file
@@ -0,0 +1,185 @@
|
||||
import React, { useState } from 'react'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { useAuthStore } from '../store/authStore'
|
||||
import { Map, Eye, EyeOff, Mail, Lock, User } from 'lucide-react'
|
||||
|
||||
export default function RegisterPage() {
|
||||
const [username, setUsername] = useState('')
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const { register } = useAuthStore()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
setError('Passwörter stimmen nicht überein')
|
||||
return
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
setError('Passwort muss mindestens 6 Zeichen lang sein')
|
||||
return
|
||||
}
|
||||
|
||||
setIsLoading(true)
|
||||
try {
|
||||
await register(username, email, password)
|
||||
navigate('/dashboard')
|
||||
} catch (err) {
|
||||
setError(err.message || 'Registrierung fehlgeschlagen')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex">
|
||||
{/* Left panel */}
|
||||
<div className="hidden lg:flex lg:w-1/2 bg-slate-900 flex-col justify-center items-center p-12 text-white">
|
||||
<div className="max-w-sm text-center">
|
||||
<div className="w-20 h-20 bg-white/10 rounded-2xl flex items-center justify-center mx-auto mb-6">
|
||||
<Map className="w-10 h-10 text-white" />
|
||||
</div>
|
||||
<h1 className="text-4xl font-bold mb-4">Jetzt starten</h1>
|
||||
<p className="text-slate-300 text-lg leading-relaxed">
|
||||
Erstellen Sie ein Konto und beginnen Sie, Ihre Traumreisen zu planen.
|
||||
</p>
|
||||
|
||||
<div className="mt-10 space-y-3 text-left">
|
||||
{[
|
||||
'✓ Unbegrenzte Reisepläne',
|
||||
'✓ Interaktive Kartenansicht',
|
||||
'✓ Orte und Kategorien verwalten',
|
||||
'✓ Reservierungen tracken',
|
||||
'✓ Packlisten erstellen',
|
||||
'✓ Fotos und Dateien speichern',
|
||||
].map(item => (
|
||||
<p key={item} className="text-slate-200 text-sm">{item}</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right panel */}
|
||||
<div className="flex-1 flex items-center justify-center p-8 bg-slate-50">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="lg:hidden flex items-center gap-2 mb-8 justify-center">
|
||||
<Map className="w-8 h-8 text-slate-900" />
|
||||
<span className="text-2xl font-bold text-slate-900">NOMAD</span>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-2xl shadow-sm border border-slate-200 p-8">
|
||||
<h2 className="text-2xl font-bold text-slate-900 mb-1">Konto erstellen</h2>
|
||||
<p className="text-slate-500 mb-8">Beginnen Sie Ihre Reiseplanung</p>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
{error && (
|
||||
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-600">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">Benutzername</label>
|
||||
<div className="relative">
|
||||
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={e => setUsername(e.target.value)}
|
||||
required
|
||||
placeholder="maxmustermann"
|
||||
minLength={3}
|
||||
className="w-full pl-10 pr-4 py-2.5 border border-slate-300 rounded-lg text-slate-900 placeholder-slate-400 focus:ring-2 focus:ring-slate-400 focus:border-transparent transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">E-Mail</label>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-400" />
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={e => setEmail(e.target.value)}
|
||||
required
|
||||
placeholder="ihre@email.de"
|
||||
className="w-full pl-10 pr-4 py-2.5 border border-slate-300 rounded-lg text-slate-900 placeholder-slate-400 focus:ring-2 focus:ring-slate-400 focus:border-transparent transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">Passwort</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-400" />
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
required
|
||||
placeholder="Mind. 6 Zeichen"
|
||||
className="w-full pl-10 pr-12 py-2.5 border border-slate-300 rounded-lg text-slate-900 placeholder-slate-400 focus:ring-2 focus:ring-slate-400 focus:border-transparent transition-all"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600"
|
||||
>
|
||||
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">Passwort bestätigen</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-400" />
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={confirmPassword}
|
||||
onChange={e => setConfirmPassword(e.target.value)}
|
||||
required
|
||||
placeholder="Passwort wiederholen"
|
||||
className="w-full pl-10 pr-4 py-2.5 border border-slate-300 rounded-lg text-slate-900 placeholder-slate-400 focus:ring-2 focus:ring-slate-400 focus:border-transparent transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="w-full py-2.5 px-4 bg-slate-900 hover:bg-slate-700 disabled:bg-slate-400 text-white font-medium rounded-lg transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin"></div>
|
||||
Registrieren...
|
||||
</>
|
||||
) : 'Registrieren'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 text-center">
|
||||
<p className="text-sm text-slate-500">
|
||||
Bereits ein Konto?{' '}
|
||||
<Link to="/login" className="text-slate-900 hover:text-slate-700 font-medium">
|
||||
Anmelden
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
421
client/src/pages/SettingsPage.jsx
Normal file
421
client/src/pages/SettingsPage.jsx
Normal file
@@ -0,0 +1,421 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useAuthStore } from '../store/authStore'
|
||||
import { useSettingsStore } from '../store/settingsStore'
|
||||
import { useTranslation } from '../i18n'
|
||||
import Navbar from '../components/Layout/Navbar'
|
||||
import CustomSelect from '../components/shared/CustomSelect'
|
||||
import { useToast } from '../components/shared/Toast'
|
||||
import { Save, Map, Palette, User, Moon, Sun, Shield, Camera, Trash2 } from 'lucide-react'
|
||||
|
||||
const MAP_PRESETS = [
|
||||
{ name: 'OpenStreetMap', url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png' },
|
||||
{ name: 'OpenStreetMap DE', url: 'https://tile.openstreetmap.de/{z}/{x}/{y}.png' },
|
||||
{ name: 'CartoDB Light', url: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png' },
|
||||
{ name: 'CartoDB Dark', url: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png' },
|
||||
{ name: 'Stadia Smooth', url: 'https://tiles.stadiamaps.com/tiles/alidade_smooth/{z}/{x}/{y}{r}.png' },
|
||||
]
|
||||
|
||||
function Section({ title, icon: Icon, children }) {
|
||||
return (
|
||||
<div className="rounded-xl border overflow-hidden" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
|
||||
<div className="px-6 py-4 border-b flex items-center gap-2" style={{ borderColor: 'var(--border-secondary)' }}>
|
||||
<Icon className="w-5 h-5" style={{ color: 'var(--text-secondary)' }} />
|
||||
<h2 className="font-semibold" style={{ color: 'var(--text-primary)' }}>{title}</h2>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { user, updateProfile, uploadAvatar, deleteAvatar } = useAuthStore()
|
||||
const avatarInputRef = React.useRef(null)
|
||||
const { settings, updateSetting, updateSettings } = useSettingsStore()
|
||||
const { t, locale } = useTranslation()
|
||||
const toast = useToast()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const [saving, setSaving] = useState({})
|
||||
|
||||
// Map settings
|
||||
const [mapTileUrl, setMapTileUrl] = useState(settings.map_tile_url || '')
|
||||
const [defaultLat, setDefaultLat] = useState(settings.default_lat || 48.8566)
|
||||
const [defaultLng, setDefaultLng] = useState(settings.default_lng || 2.3522)
|
||||
const [defaultZoom, setDefaultZoom] = useState(settings.default_zoom || 10)
|
||||
|
||||
// Display
|
||||
const [tempUnit, setTempUnit] = useState(settings.temperature_unit || 'celsius')
|
||||
|
||||
// Account
|
||||
const [username, setUsername] = useState(user?.username || '')
|
||||
const [email, setEmail] = useState(user?.email || '')
|
||||
|
||||
useEffect(() => {
|
||||
setMapTileUrl(settings.map_tile_url || '')
|
||||
setDefaultLat(settings.default_lat || 48.8566)
|
||||
setDefaultLng(settings.default_lng || 2.3522)
|
||||
setDefaultZoom(settings.default_zoom || 10)
|
||||
setTempUnit(settings.temperature_unit || 'celsius')
|
||||
}, [settings])
|
||||
|
||||
useEffect(() => {
|
||||
setUsername(user?.username || '')
|
||||
setEmail(user?.email || '')
|
||||
}, [user])
|
||||
|
||||
const saveMapSettings = async () => {
|
||||
setSaving(s => ({ ...s, map: true }))
|
||||
try {
|
||||
await updateSettings({
|
||||
map_tile_url: mapTileUrl,
|
||||
default_lat: parseFloat(defaultLat),
|
||||
default_lng: parseFloat(defaultLng),
|
||||
default_zoom: parseInt(defaultZoom),
|
||||
})
|
||||
toast.success(t('settings.toast.mapSaved'))
|
||||
} catch (err) {
|
||||
toast.error(err.message)
|
||||
} finally {
|
||||
setSaving(s => ({ ...s, map: false }))
|
||||
}
|
||||
}
|
||||
|
||||
const saveDisplay = async () => {
|
||||
setSaving(s => ({ ...s, display: true }))
|
||||
try {
|
||||
await updateSetting('temperature_unit', tempUnit)
|
||||
toast.success(t('settings.toast.displaySaved'))
|
||||
} catch (err) {
|
||||
toast.error(err.message)
|
||||
} finally {
|
||||
setSaving(s => ({ ...s, display: false }))
|
||||
}
|
||||
}
|
||||
|
||||
const handleAvatarUpload = async (e) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
try {
|
||||
await uploadAvatar(file)
|
||||
toast.success(t('settings.avatarUploaded'))
|
||||
} catch {
|
||||
toast.error(t('settings.avatarError'))
|
||||
}
|
||||
if (avatarInputRef.current) avatarInputRef.current.value = ''
|
||||
}
|
||||
|
||||
const handleAvatarRemove = async () => {
|
||||
try {
|
||||
await deleteAvatar()
|
||||
toast.success(t('settings.avatarRemoved'))
|
||||
} catch {
|
||||
toast.error(t('settings.avatarError'))
|
||||
}
|
||||
}
|
||||
|
||||
const saveProfile = async () => {
|
||||
setSaving(s => ({ ...s, profile: true }))
|
||||
try {
|
||||
await updateProfile({ username, email })
|
||||
toast.success(t('settings.toast.profileSaved'))
|
||||
} catch (err) {
|
||||
toast.error(err.message)
|
||||
} finally {
|
||||
setSaving(s => ({ ...s, profile: false }))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen" style={{ background: 'var(--bg-secondary)' }}>
|
||||
<Navbar />
|
||||
|
||||
<div className="pt-14">
|
||||
<div className="max-w-2xl mx-auto px-4 py-8 space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold" style={{ color: 'var(--text-primary)' }}>{t('settings.title')}</h1>
|
||||
<p className="text-sm mt-0.5" style={{ color: 'var(--text-muted)' }}>{t('settings.subtitle')}</p>
|
||||
</div>
|
||||
|
||||
{/* Map settings */}
|
||||
<Section title={t('settings.map')} icon={Map}>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.mapTemplate')}</label>
|
||||
<CustomSelect
|
||||
value=""
|
||||
onChange={value => { if (value) setMapTileUrl(value) }}
|
||||
placeholder={t('settings.mapTemplatePlaceholder.select')}
|
||||
options={MAP_PRESETS.map(p => ({
|
||||
value: p.url,
|
||||
label: p.name,
|
||||
}))}
|
||||
size="sm"
|
||||
style={{ marginBottom: 8 }}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={mapTileUrl}
|
||||
onChange={e => setMapTileUrl(e.target.value)}
|
||||
placeholder="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||
/>
|
||||
<p className="text-xs text-slate-400 mt-1">{t('settings.mapDefaultHint')}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.latitude')}</label>
|
||||
<input
|
||||
type="number"
|
||||
step="any"
|
||||
value={defaultLat}
|
||||
onChange={e => setDefaultLat(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.longitude')}</label>
|
||||
<input
|
||||
type="number"
|
||||
step="any"
|
||||
value={defaultLng}
|
||||
onChange={e => setDefaultLng(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={saveMapSettings}
|
||||
disabled={saving.map}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-slate-900 text-white rounded-lg text-sm hover:bg-slate-700 disabled:bg-slate-400"
|
||||
>
|
||||
{saving.map ? <div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" /> : <Save className="w-4 h-4" />}
|
||||
{t('settings.saveMap')}
|
||||
</button>
|
||||
</Section>
|
||||
|
||||
{/* Display */}
|
||||
<Section title={t('settings.display')} icon={Palette}>
|
||||
{/* Dark Mode Toggle */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>{t('settings.colorMode')}</label>
|
||||
<div className="flex gap-3">
|
||||
{[
|
||||
{ value: false, label: t('settings.light'), icon: Sun },
|
||||
{ value: true, label: t('settings.dark'), icon: Moon },
|
||||
].map(opt => (
|
||||
<button
|
||||
key={String(opt.value)}
|
||||
onClick={async () => {
|
||||
try {
|
||||
await updateSetting('dark_mode', opt.value)
|
||||
} catch (e) { toast.error(e.message) }
|
||||
}}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
padding: '10px 20px', borderRadius: 10, cursor: 'pointer',
|
||||
fontFamily: 'inherit', fontSize: 14, fontWeight: 500,
|
||||
border: settings.dark_mode === opt.value ? '2px solid var(--text-primary)' : '2px solid var(--border-primary)',
|
||||
background: settings.dark_mode === opt.value ? 'var(--bg-hover)' : 'var(--bg-card)',
|
||||
color: 'var(--text-primary)',
|
||||
transition: 'all 0.15s',
|
||||
}}
|
||||
>
|
||||
<opt.icon size={16} />
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sprache */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>{t('settings.language')}</label>
|
||||
<div className="flex gap-3">
|
||||
{[
|
||||
{ value: 'de', label: 'Deutsch' },
|
||||
{ value: 'en', label: 'English' },
|
||||
].map(opt => (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={async () => {
|
||||
try { await updateSetting('language', opt.value) }
|
||||
catch (e) { toast.error(e.message) }
|
||||
}}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
padding: '10px 20px', borderRadius: 10, cursor: 'pointer',
|
||||
fontFamily: 'inherit', fontSize: 14, fontWeight: 500,
|
||||
border: settings.language === opt.value ? '2px solid var(--text-primary)' : '2px solid var(--border-primary)',
|
||||
background: settings.language === opt.value ? 'var(--bg-hover)' : 'var(--bg-card)',
|
||||
color: 'var(--text-primary)',
|
||||
transition: 'all 0.15s',
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Temperature */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>{t('settings.temperature')}</label>
|
||||
<div className="flex gap-3">
|
||||
{[
|
||||
{ value: 'celsius', label: '°C Celsius' },
|
||||
{ value: 'fahrenheit', label: '°F Fahrenheit' },
|
||||
].map(opt => (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={async () => {
|
||||
setTempUnit(opt.value)
|
||||
try { await updateSetting('temperature_unit', opt.value) }
|
||||
catch (e) { toast.error(e.message) }
|
||||
}}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
padding: '10px 20px', borderRadius: 10, cursor: 'pointer',
|
||||
fontFamily: 'inherit', fontSize: 14, fontWeight: 500,
|
||||
border: tempUnit === opt.value ? '2px solid var(--text-primary)' : '2px solid var(--border-primary)',
|
||||
background: tempUnit === opt.value ? 'var(--bg-hover)' : 'var(--bg-card)',
|
||||
color: 'var(--text-primary)',
|
||||
transition: 'all 0.15s',
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Zeitformat */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>{t('settings.timeFormat')}</label>
|
||||
<div className="flex gap-3">
|
||||
{[
|
||||
{ value: '24h', label: '24h (14:30)' },
|
||||
{ value: '12h', label: '12h (2:30 PM)' },
|
||||
].map(opt => (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={async () => {
|
||||
try { await updateSetting('time_format', opt.value) }
|
||||
catch (e) { toast.error(e.message) }
|
||||
}}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
padding: '10px 20px', borderRadius: 10, cursor: 'pointer',
|
||||
fontFamily: 'inherit', fontSize: 14, fontWeight: 500,
|
||||
border: settings.time_format === opt.value ? '2px solid var(--text-primary)' : '2px solid var(--border-primary)',
|
||||
background: settings.time_format === opt.value ? 'var(--bg-hover)' : 'var(--bg-card)',
|
||||
color: 'var(--text-primary)',
|
||||
transition: 'all 0.15s',
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Account */}
|
||||
<Section title={t('settings.account')} icon={User}>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.username')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={e => setUsername(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.email')}</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={e => setEmail(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
{user?.avatar_url ? (
|
||||
<img src={user.avatar_url} alt="" style={{ width: 64, height: 64, borderRadius: '50%', objectFit: 'cover', flexShrink: 0 }} />
|
||||
) : (
|
||||
<div style={{
|
||||
width: 64, height: 64, borderRadius: '50%', flexShrink: 0,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 24, fontWeight: 700,
|
||||
background: 'var(--bg-hover)', color: 'var(--text-secondary)',
|
||||
}}>
|
||||
{user?.username?.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-2">
|
||||
<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)' }}>
|
||||
{user?.role === 'admin' ? <><Shield size={13} /> {t('settings.roleAdmin')}</> : t('settings.roleUser')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
ref={avatarInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleAvatarUpload}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
<button
|
||||
onClick={() => avatarInputRef.current?.click()}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors"
|
||||
style={{
|
||||
border: '1px solid var(--border-primary)',
|
||||
background: 'var(--bg-card)',
|
||||
color: 'var(--text-secondary)',
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-card)'}
|
||||
>
|
||||
<Camera size={14} />
|
||||
{t('settings.uploadAvatar')}
|
||||
</button>
|
||||
{user?.avatar_url && (
|
||||
<button
|
||||
onClick={handleAvatarRemove}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors"
|
||||
style={{
|
||||
border: '1px solid var(--border-primary)',
|
||||
background: 'var(--bg-card)',
|
||||
color: '#ef4444',
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-card)'}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
{t('settings.removeAvatar')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={saveProfile}
|
||||
disabled={saving.profile}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-slate-900 text-white rounded-lg text-sm hover:bg-slate-700 disabled:bg-slate-400"
|
||||
>
|
||||
{saving.profile ? <div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" /> : <Save className="w-4 h-4" />}
|
||||
{t('settings.saveProfile')}
|
||||
</button>
|
||||
</Section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
531
client/src/pages/TripPlannerPage.jsx
Normal file
531
client/src/pages/TripPlannerPage.jsx
Normal file
@@ -0,0 +1,531 @@
|
||||
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import { useTripStore } from '../store/tripStore'
|
||||
import { useSettingsStore } from '../store/settingsStore'
|
||||
import { MapView } from '../components/Map/MapView'
|
||||
import DayPlanSidebar from '../components/Planner/DayPlanSidebar'
|
||||
import PlacesSidebar from '../components/Planner/PlacesSidebar'
|
||||
import PlaceInspector from '../components/Planner/PlaceInspector'
|
||||
import PlaceFormModal from '../components/Planner/PlaceFormModal'
|
||||
import TripFormModal from '../components/Trips/TripFormModal'
|
||||
import TripMembersModal from '../components/Trips/TripMembersModal'
|
||||
import { ReservationModal } from '../components/Planner/ReservationModal'
|
||||
import ReservationsPanel from '../components/Planner/ReservationsPanel'
|
||||
import PackingListPanel from '../components/Packing/PackingListPanel'
|
||||
import FileManager from '../components/Files/FileManager'
|
||||
import BudgetPanel from '../components/Budget/BudgetPanel'
|
||||
import Navbar from '../components/Layout/Navbar'
|
||||
import { useToast } from '../components/shared/Toast'
|
||||
import { Map, X, PanelLeftClose, PanelLeftOpen, PanelRightClose, PanelRightOpen } from 'lucide-react'
|
||||
import { useTranslation } from '../i18n'
|
||||
|
||||
const MIN_SIDEBAR = 200
|
||||
const MAX_SIDEBAR = 520
|
||||
|
||||
export default function TripPlannerPage() {
|
||||
const { id: tripId } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const toast = useToast()
|
||||
const { t } = useTranslation()
|
||||
const { settings } = useSettingsStore()
|
||||
const tripStore = useTripStore()
|
||||
const { trip, days, places, assignments, packingItems, categories, reservations, budgetItems, files, selectedDayId, isLoading } = tripStore
|
||||
|
||||
const TRIP_TABS = [
|
||||
{ id: 'plan', label: t('trip.tabs.plan') },
|
||||
{ id: 'buchungen', label: t('trip.tabs.reservations') },
|
||||
{ id: 'packliste', label: t('trip.tabs.packing'), shortLabel: t('trip.tabs.packingShort') },
|
||||
{ id: 'finanzplan', label: t('trip.tabs.budget') },
|
||||
{ id: 'dateien', label: t('trip.tabs.files') },
|
||||
]
|
||||
|
||||
// Layout state
|
||||
const [activeTab, setActiveTab] = useState('plan')
|
||||
|
||||
const handleTabChange = (tabId) => {
|
||||
setActiveTab(tabId)
|
||||
if (tabId === 'finanzplan') tripStore.loadBudgetItems?.(tripId)
|
||||
if (tabId === 'dateien' && (!files || files.length === 0)) tripStore.loadFiles?.(tripId)
|
||||
}
|
||||
const [leftWidth, setLeftWidth] = useState(() => parseInt(localStorage.getItem('sidebarLeftWidth')) || 340)
|
||||
const [rightWidth, setRightWidth] = useState(() => parseInt(localStorage.getItem('sidebarRightWidth')) || 300)
|
||||
const [leftCollapsed, setLeftCollapsed] = useState(false)
|
||||
const [rightCollapsed, setRightCollapsed] = useState(false)
|
||||
const isResizingLeft = useRef(false)
|
||||
const isResizingRight = useRef(false)
|
||||
|
||||
// Content state
|
||||
const [selectedPlaceId, setSelectedPlaceId] = useState(null)
|
||||
const [showPlaceForm, setShowPlaceForm] = useState(false)
|
||||
const [editingPlace, setEditingPlace] = useState(null)
|
||||
const [showTripForm, setShowTripForm] = useState(false)
|
||||
const [showMembersModal, setShowMembersModal] = useState(false)
|
||||
const [showReservationModal, setShowReservationModal] = useState(false)
|
||||
const [editingReservation, setEditingReservation] = useState(null)
|
||||
const [route, setRoute] = useState(null)
|
||||
const [routeInfo, setRouteInfo] = useState(null)
|
||||
const [fitKey, setFitKey] = useState(0)
|
||||
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(null) // 'left' | 'right' | null
|
||||
|
||||
// Load trip + files (needed for place inspector file section)
|
||||
useEffect(() => {
|
||||
if (tripId) {
|
||||
tripStore.loadTrip(tripId).catch(() => { toast.error(t('trip.toast.loadError')); navigate('/dashboard') })
|
||||
tripStore.loadFiles(tripId)
|
||||
}
|
||||
}, [tripId])
|
||||
|
||||
useEffect(() => {
|
||||
if (tripId) tripStore.loadReservations(tripId)
|
||||
}, [tripId])
|
||||
|
||||
// Resize handlers
|
||||
useEffect(() => {
|
||||
const onMove = (e) => {
|
||||
if (isResizingLeft.current) {
|
||||
const w = Math.max(MIN_SIDEBAR, Math.min(MAX_SIDEBAR, e.clientX - 10))
|
||||
setLeftWidth(w)
|
||||
localStorage.setItem('sidebarLeftWidth', w)
|
||||
}
|
||||
if (isResizingRight.current) {
|
||||
const w = Math.max(MIN_SIDEBAR, Math.min(MAX_SIDEBAR, window.innerWidth - e.clientX - 10))
|
||||
setRightWidth(w)
|
||||
localStorage.setItem('sidebarRightWidth', w)
|
||||
}
|
||||
}
|
||||
const onUp = () => {
|
||||
isResizingLeft.current = false
|
||||
isResizingRight.current = false
|
||||
document.body.style.cursor = ''
|
||||
document.body.style.userSelect = ''
|
||||
}
|
||||
document.addEventListener('mousemove', onMove)
|
||||
document.addEventListener('mouseup', onUp)
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', onMove)
|
||||
document.removeEventListener('mouseup', onUp)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Map places — always show all places with coordinates
|
||||
const mapPlaces = useCallback(() => {
|
||||
return places.filter(p => p.lat && p.lng)
|
||||
}, [places])
|
||||
|
||||
const handleSelectDay = useCallback((dayId) => {
|
||||
tripStore.setSelectedDay(dayId)
|
||||
setRouteInfo(null)
|
||||
setFitKey(k => k + 1)
|
||||
setMobileSidebarOpen(null)
|
||||
|
||||
// Auto-show Luftlinien for the selected day
|
||||
const da = (tripStore.assignments[String(dayId)] || []).slice().sort((a, b) => a.order_index - b.order_index)
|
||||
const waypoints = da.map(a => a.place).filter(p => p?.lat && p?.lng)
|
||||
if (waypoints.length >= 2) {
|
||||
setRoute(waypoints.map(p => [p.lat, p.lng]))
|
||||
} else {
|
||||
setRoute(null)
|
||||
}
|
||||
}, [tripStore])
|
||||
|
||||
const handlePlaceClick = useCallback((placeId) => {
|
||||
setSelectedPlaceId(placeId)
|
||||
if (placeId) { setLeftCollapsed(false); setRightCollapsed(false) }
|
||||
}, [])
|
||||
|
||||
const handleMarkerClick = useCallback((placeId) => {
|
||||
const opening = placeId !== undefined
|
||||
setSelectedPlaceId(prev => prev === placeId ? null : placeId)
|
||||
if (opening) { setLeftCollapsed(false); setRightCollapsed(false) }
|
||||
}, [])
|
||||
|
||||
const handleMapClick = useCallback(() => {
|
||||
setSelectedPlaceId(null)
|
||||
}, [])
|
||||
|
||||
const handleSavePlace = useCallback(async (data) => {
|
||||
if (editingPlace) {
|
||||
await tripStore.updatePlace(tripId, editingPlace.id, data)
|
||||
toast.success(t('trip.toast.placeUpdated'))
|
||||
} else {
|
||||
await tripStore.addPlace(tripId, data)
|
||||
toast.success(t('trip.toast.placeAdded'))
|
||||
}
|
||||
}, [editingPlace, tripId, tripStore, toast])
|
||||
|
||||
const handleDeletePlace = useCallback(async (placeId) => {
|
||||
if (!confirm(t('trip.confirm.deletePlace'))) return
|
||||
try {
|
||||
await tripStore.deletePlace(tripId, placeId)
|
||||
if (selectedPlaceId === placeId) setSelectedPlaceId(null)
|
||||
toast.success(t('trip.toast.placeDeleted'))
|
||||
} catch (err) { toast.error(err.message) }
|
||||
}, [tripId, tripStore, toast, selectedPlaceId])
|
||||
|
||||
const handleAssignToDay = useCallback(async (placeId, dayId, position) => {
|
||||
const target = dayId || selectedDayId
|
||||
if (!target) { toast.error(t('trip.toast.selectDay')); return }
|
||||
try {
|
||||
await tripStore.assignPlaceToDay(tripId, target, placeId, position)
|
||||
toast.success(t('trip.toast.assignedToDay'))
|
||||
} catch (err) { toast.error(err.message) }
|
||||
}, [selectedDayId, tripId, tripStore, toast])
|
||||
|
||||
const handleRemoveAssignment = useCallback(async (dayId, assignmentId) => {
|
||||
try { await tripStore.removeAssignment(tripId, dayId, assignmentId) }
|
||||
catch (err) { toast.error(err.message) }
|
||||
}, [tripId, tripStore, toast])
|
||||
|
||||
const handleReorder = useCallback(async (dayId, orderedIds) => {
|
||||
try { await tripStore.reorderAssignments(tripId, dayId, orderedIds) }
|
||||
catch { toast.error(t('trip.toast.reorderError')) }
|
||||
}, [tripId, tripStore, toast])
|
||||
|
||||
const handleUpdateDayTitle = useCallback(async (dayId, title) => {
|
||||
try { await tripStore.updateDayTitle(tripId, dayId, title) }
|
||||
catch (err) { toast.error(err.message) }
|
||||
}, [tripId, tripStore, toast])
|
||||
|
||||
const handleSaveReservation = async (data) => {
|
||||
try {
|
||||
if (editingReservation) {
|
||||
const r = await tripStore.updateReservation(tripId, editingReservation.id, data)
|
||||
toast.success(t('trip.toast.reservationUpdated'))
|
||||
setShowReservationModal(false)
|
||||
return r
|
||||
} else {
|
||||
const r = await tripStore.addReservation(tripId, { ...data, day_id: selectedDayId || null })
|
||||
toast.success(t('trip.toast.reservationAdded'))
|
||||
setShowReservationModal(false)
|
||||
return r
|
||||
}
|
||||
} catch (err) { toast.error(err.message) }
|
||||
}
|
||||
|
||||
const handleDeleteReservation = async (id) => {
|
||||
try { await tripStore.deleteReservation(tripId, id); toast.success(t('trip.toast.deleted')) }
|
||||
catch (err) { toast.error(err.message) }
|
||||
}
|
||||
|
||||
const selectedPlace = selectedPlaceId ? places.find(p => p.id === selectedPlaceId) : null
|
||||
|
||||
// Build placeId → order-number map from the selected day's assignments
|
||||
const dayOrderMap = useMemo(() => {
|
||||
if (!selectedDayId) return {}
|
||||
const da = assignments[String(selectedDayId)] || []
|
||||
const sorted = [...da].sort((a, b) => a.order_index - b.order_index)
|
||||
const map = {}
|
||||
sorted.forEach((a, i) => { if (a.place?.id) map[a.place.id] = i + 1 })
|
||||
return map
|
||||
}, [selectedDayId, assignments])
|
||||
|
||||
const mapTileUrl = settings.map_tile_url || 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png'
|
||||
const defaultCenter = [settings.default_lat || 48.8566, settings.default_lng || 2.3522]
|
||||
const defaultZoom = settings.default_zoom || 10
|
||||
|
||||
const fontStyle = { fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', system-ui, sans-serif" }
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#f9fafb', ...fontStyle }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 12 }}>
|
||||
<div style={{ width: 32, height: 32, border: '3px solid rgba(0,0,0,0.1)', borderTopColor: '#111827', borderRadius: '50%', animation: 'spin 0.8s linear infinite' }} />
|
||||
<span style={{ fontSize: 13, color: '#9ca3af' }}>{t('trip.loading')}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (!trip) return null
|
||||
|
||||
return (
|
||||
<div style={{ height: '100vh', display: 'flex', flexDirection: 'column', overflow: 'hidden', ...fontStyle }}>
|
||||
<Navbar tripTitle={trip.title} tripId={tripId} showBack onBack={() => navigate('/dashboard')} onShare={() => setShowMembersModal(true)} />
|
||||
|
||||
{/* Tab bar */}
|
||||
<div style={{
|
||||
position: 'fixed', top: 56, 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,
|
||||
overflowX: 'auto', scrollbarWidth: 'none', msOverflowStyle: 'none',
|
||||
gap: 2,
|
||||
}}>
|
||||
{TRIP_TABS.map(tab => {
|
||||
const isActive = activeTab === tab.id
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => handleTabChange(tab.id)}
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
padding: '5px 14px', borderRadius: 20, border: 'none', cursor: 'pointer',
|
||||
fontSize: 13, fontWeight: isActive ? 600 : 400,
|
||||
background: isActive ? 'var(--accent)' : 'transparent',
|
||||
color: isActive ? 'var(--accent-text)' : 'var(--text-muted)',
|
||||
fontFamily: 'inherit', transition: 'all 0.15s',
|
||||
}}
|
||||
onMouseEnter={e => { if (!isActive) e.currentTarget.style.background = 'var(--bg-hover)'; e.currentTarget.style.color = isActive ? 'var(--accent-text)' : 'var(--text-primary)' }}
|
||||
onMouseLeave={e => { if (!isActive) e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = isActive ? 'var(--accent-text)' : 'var(--text-muted)' }}
|
||||
>{tab.shortLabel
|
||||
? <><span className="sm:hidden">{tab.shortLabel}</span><span className="hidden sm:inline">{tab.label}</span></>
|
||||
: tab.label
|
||||
}</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Content — offset by navbar (56px) + tab bar (44px) */}
|
||||
<div style={{ flex: 1, overflow: 'hidden', marginTop: 100, position: 'relative' }}>
|
||||
|
||||
{/* PLAN MODE */}
|
||||
{activeTab === 'plan' && (
|
||||
<div style={{ position: 'absolute', inset: 0 }}>
|
||||
{/* Map fills entire space */}
|
||||
<MapView
|
||||
places={mapPlaces()}
|
||||
route={route}
|
||||
selectedPlaceId={selectedPlaceId}
|
||||
onMarkerClick={handleMarkerClick}
|
||||
onMapClick={handleMapClick}
|
||||
center={defaultCenter}
|
||||
zoom={defaultZoom}
|
||||
tileUrl={mapTileUrl}
|
||||
fitKey={fitKey}
|
||||
dayOrderMap={dayOrderMap}
|
||||
/>
|
||||
|
||||
{/* Route info overlay */}
|
||||
{routeInfo && (
|
||||
<div style={{
|
||||
position: 'absolute', bottom: selectedPlace ? 180 : 20, left: '50%', transform: 'translateX(-50%)',
|
||||
background: 'rgba(255,255,255,0.95)', backdropFilter: 'blur(20px)',
|
||||
borderRadius: 99, padding: '6px 20px', zIndex: 30,
|
||||
boxShadow: '0 2px 16px rgba(0,0,0,0.1)',
|
||||
display: 'flex', gap: 12, fontSize: 13, color: '#374151',
|
||||
}}>
|
||||
<span>{routeInfo.distance}</span>
|
||||
<span style={{ color: '#d1d5db' }}>·</span>
|
||||
<span>{routeInfo.duration}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* LEFT SIDEBAR — glass, absolute, floating rounded */}
|
||||
<div className="hidden md:block" style={{ position: 'absolute', left: 10, top: 10, bottom: 10, zIndex: 20 }}>
|
||||
{/* Collapse toggle — am rechten Rand der Sidebar, halb herausstehend */}
|
||||
<button onClick={() => setLeftCollapsed(c => !c)}
|
||||
style={{
|
||||
position: leftCollapsed ? 'fixed' : 'absolute', top: leftCollapsed ? 'calc(56px + 44px + 14px)' : 14, left: leftCollapsed ? 10 : undefined, right: leftCollapsed ? undefined : -28, zIndex: 25,
|
||||
width: 36, height: 36, borderRadius: leftCollapsed ? 10 : '0 10px 10px 0',
|
||||
background: leftCollapsed ? '#000' : 'var(--sidebar-bg)', backdropFilter: 'blur(20px)', WebkitBackdropFilter: 'blur(20px)',
|
||||
boxShadow: leftCollapsed ? '0 2px 12px rgba(0,0,0,0.2)' : 'none', border: 'none',
|
||||
cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
color: leftCollapsed ? '#fff' : 'var(--text-faint)', transition: 'color 0.15s',
|
||||
}}
|
||||
onMouseEnter={e => { if (!leftCollapsed) e.currentTarget.style.color = 'var(--text-primary)' }}
|
||||
onMouseLeave={e => { if (!leftCollapsed) e.currentTarget.style.color = 'var(--text-faint)' }}>
|
||||
{leftCollapsed ? <PanelLeftOpen size={16} /> : <PanelLeftClose size={16} />}
|
||||
</button>
|
||||
|
||||
<div style={{
|
||||
width: leftCollapsed ? 0 : leftWidth, height: '100%',
|
||||
background: 'var(--sidebar-bg)',
|
||||
backdropFilter: 'blur(24px) saturate(180%)',
|
||||
WebkitBackdropFilter: 'blur(24px) saturate(180%)',
|
||||
boxShadow: leftCollapsed ? 'none' : 'var(--sidebar-shadow)',
|
||||
borderRadius: 16,
|
||||
overflow: 'hidden', display: 'flex', flexDirection: 'column',
|
||||
transition: 'width 0.25s ease',
|
||||
opacity: leftCollapsed ? 0 : 1,
|
||||
}}>
|
||||
<DayPlanSidebar
|
||||
tripId={tripId}
|
||||
trip={trip}
|
||||
days={days}
|
||||
places={places}
|
||||
categories={categories}
|
||||
assignments={assignments}
|
||||
selectedDayId={selectedDayId}
|
||||
selectedPlaceId={selectedPlaceId}
|
||||
onSelectDay={handleSelectDay}
|
||||
onPlaceClick={handlePlaceClick}
|
||||
onReorder={handleReorder}
|
||||
onUpdateDayTitle={handleUpdateDayTitle}
|
||||
onAssignToDay={handleAssignToDay}
|
||||
onRouteCalculated={(r) => { if (r) { setRoute(r.coordinates); setRouteInfo({ distance: r.distanceText, duration: r.durationText }) } else { setRoute(null); setRouteInfo(null) } }}
|
||||
reservations={reservations}
|
||||
onAddReservation={(dayId) => { setEditingReservation(null); tripStore.setSelectedDay(dayId); setShowReservationModal(true) }}
|
||||
/>
|
||||
{/* Resize handle — right edge */}
|
||||
{!leftCollapsed && (
|
||||
<div
|
||||
onMouseDown={() => { isResizingLeft.current = true; document.body.style.cursor = 'col-resize'; document.body.style.userSelect = 'none' }}
|
||||
style={{ position: 'absolute', right: 0, top: 0, bottom: 0, width: 4, cursor: 'col-resize', background: 'transparent' }}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'rgba(0,0,0,0.08)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* RIGHT SIDEBAR — glass, absolute, floating rounded */}
|
||||
<div className="hidden md:block" style={{ position: 'absolute', right: 10, top: 10, bottom: 10, zIndex: 20 }}>
|
||||
{/* Collapse toggle — am linken Rand der Sidebar, halb herausstehend */}
|
||||
<button onClick={() => setRightCollapsed(c => !c)}
|
||||
style={{
|
||||
position: rightCollapsed ? 'fixed' : 'absolute', top: rightCollapsed ? 'calc(56px + 44px + 14px)' : 14, right: rightCollapsed ? 10 : undefined, left: rightCollapsed ? undefined : -28, zIndex: 25,
|
||||
width: 36, height: 36, borderRadius: rightCollapsed ? 10 : '10px 0 0 10px',
|
||||
background: rightCollapsed ? '#000' : 'var(--sidebar-bg)', backdropFilter: 'blur(20px)', WebkitBackdropFilter: 'blur(20px)',
|
||||
boxShadow: rightCollapsed ? '0 2px 12px rgba(0,0,0,0.2)' : 'none', border: 'none',
|
||||
cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
color: rightCollapsed ? '#fff' : 'var(--text-faint)', transition: 'color 0.15s',
|
||||
}}
|
||||
onMouseEnter={e => { if (!rightCollapsed) e.currentTarget.style.color = 'var(--text-primary)' }}
|
||||
onMouseLeave={e => { if (!rightCollapsed) e.currentTarget.style.color = 'var(--text-faint)' }}>
|
||||
{rightCollapsed ? <PanelRightOpen size={16} /> : <PanelRightClose size={16} />}
|
||||
</button>
|
||||
|
||||
<div style={{
|
||||
width: rightCollapsed ? 0 : rightWidth, height: '100%',
|
||||
background: 'var(--sidebar-bg)',
|
||||
backdropFilter: 'blur(24px) saturate(180%)',
|
||||
WebkitBackdropFilter: 'blur(24px) saturate(180%)',
|
||||
boxShadow: rightCollapsed ? 'none' : 'var(--sidebar-shadow)',
|
||||
borderRadius: 16,
|
||||
overflow: 'hidden', display: 'flex', flexDirection: 'column',
|
||||
transition: 'width 0.25s ease',
|
||||
opacity: rightCollapsed ? 0 : 1,
|
||||
}}>
|
||||
{/* Resize handle — left edge */}
|
||||
{!rightCollapsed && (
|
||||
<div
|
||||
onMouseDown={() => { isResizingRight.current = true; document.body.style.cursor = 'col-resize'; document.body.style.userSelect = 'none' }}
|
||||
style={{ position: 'absolute', left: 0, top: 0, bottom: 0, width: 4, cursor: 'col-resize', background: 'transparent' }}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'rgba(0,0,0,0.08)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
|
||||
/>
|
||||
)}
|
||||
<div style={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column', paddingLeft: 4 }}>
|
||||
<PlacesSidebar
|
||||
places={places}
|
||||
categories={categories}
|
||||
assignments={assignments}
|
||||
selectedDayId={selectedDayId}
|
||||
selectedPlaceId={selectedPlaceId}
|
||||
onPlaceClick={handlePlaceClick}
|
||||
onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true) }}
|
||||
onAssignToDay={handleAssignToDay}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile controls */}
|
||||
<div className="flex md:hidden" style={{ position: 'absolute', top: 12, left: 12, right: 12, justifyContent: 'space-between', zIndex: 30 }}>
|
||||
<button onClick={() => setMobileSidebarOpen('left')}
|
||||
style={{ background: 'rgba(255,255,255,0.95)', backdropFilter: 'blur(12px)', border: 'none', 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' }}>
|
||||
{t('trip.mobilePlan')}
|
||||
</button>
|
||||
<button onClick={() => setMobileSidebarOpen('right')}
|
||||
style={{ background: 'rgba(255,255,255,0.95)', backdropFilter: 'blur(12px)', border: 'none', 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' }}>
|
||||
{t('trip.mobilePlaces')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Bottom inspector */}
|
||||
{selectedPlace && (
|
||||
<PlaceInspector
|
||||
place={selectedPlace}
|
||||
categories={categories}
|
||||
days={days}
|
||||
selectedDayId={selectedDayId}
|
||||
assignments={assignments}
|
||||
onClose={() => setSelectedPlaceId(null)}
|
||||
onEdit={() => { setEditingPlace(selectedPlace); setShowPlaceForm(true) }}
|
||||
onDelete={() => handleDeletePlace(selectedPlace.id)}
|
||||
onAssignToDay={handleAssignToDay}
|
||||
onRemoveAssignment={handleRemoveAssignment}
|
||||
files={files}
|
||||
onFileUpload={(fd) => tripStore.addFile(tripId, fd)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Mobile bottom sheet */}
|
||||
{mobileSidebarOpen && (
|
||||
<div style={{ position: 'absolute', inset: 0, background: 'rgba(0,0,0,0.3)', zIndex: 50 }} onClick={() => setMobileSidebarOpen(null)}>
|
||||
<div style={{ position: 'absolute', bottom: 0, left: 0, right: 0, background: 'white', borderRadius: '20px 20px 0 0', maxHeight: '80vh', 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 rgba(0,0,0,0.06)' }}>
|
||||
<span style={{ fontWeight: 600, fontSize: 14, color: '#111827' }}>{mobileSidebarOpen === 'left' ? t('trip.mobilePlan') : t('trip.mobilePlaces')}</span>
|
||||
<button onClick={() => setMobileSidebarOpen(null)} style={{ background: 'rgba(0,0,0,0.07)', border: 'none', borderRadius: '50%', width: 28, height: 28, cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ flex: 1, overflow: 'auto' }}>
|
||||
{mobileSidebarOpen === 'left'
|
||||
? <DayPlanSidebar trip={trip} days={days} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} onSelectDay={(id) => { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={handlePlaceClick} onReorder={handleReorder} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute(r.coordinates); setRouteInfo({ distance: r.distanceText, duration: r.durationText }) } }} reservations={reservations} onAddReservation={(dayId) => { setEditingReservation(null); tripStore.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} />
|
||||
: <PlacesSidebar places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} onPlaceClick={handlePlaceClick} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} />
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* BUCHUNGEN */}
|
||||
{activeTab === 'buchungen' && (
|
||||
<div style={{ height: '100%', maxWidth: 1200, margin: '0 auto', width: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||
<ReservationsPanel
|
||||
tripId={tripId}
|
||||
reservations={reservations}
|
||||
days={days}
|
||||
assignments={assignments}
|
||||
files={files}
|
||||
onAdd={() => { setEditingReservation(null); setShowReservationModal(true) }}
|
||||
onEdit={(r) => { setEditingReservation(r); setShowReservationModal(true) }}
|
||||
onDelete={handleDeleteReservation}
|
||||
onNavigateToFiles={() => handleTabChange('dateien')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* PACKLISTE */}
|
||||
{activeTab === 'packliste' && (
|
||||
<div style={{ height: '100%', overflowY: 'auto', maxWidth: 1200, margin: '0 auto', width: '100%', padding: '8px 0' }}>
|
||||
<PackingListPanel tripId={tripId} items={packingItems} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* FINANZPLAN */}
|
||||
{activeTab === 'finanzplan' && (
|
||||
<div style={{ height: '100%', overflowY: 'auto', maxWidth: 1400, margin: '0 auto', width: '100%', padding: '8px 0' }}>
|
||||
<BudgetPanel tripId={tripId} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* DATEIEN */}
|
||||
{activeTab === 'dateien' && (
|
||||
<div style={{ height: '100%', overflow: 'hidden' }}>
|
||||
<FileManager
|
||||
files={files || []}
|
||||
onUpload={(fd) => tripStore.addFile(tripId, fd)}
|
||||
onDelete={(id) => tripStore.deleteFile(tripId, id)}
|
||||
onUpdate={null}
|
||||
places={places}
|
||||
reservations={reservations}
|
||||
tripId={tripId}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<PlaceFormModal isOpen={showPlaceForm} onClose={() => { setShowPlaceForm(false); setEditingPlace(null) }} onSave={handleSavePlace} place={editingPlace} tripId={tripId} categories={categories} onCategoryCreated={cat => tripStore.addCategory?.(cat)} />
|
||||
<TripFormModal isOpen={showTripForm} onClose={() => setShowTripForm(false)} onSave={async (data) => { await tripStore.updateTrip(tripId, data); toast.success(t('trip.toast.tripUpdated')) }} trip={trip} />
|
||||
<TripMembersModal isOpen={showMembersModal} onClose={() => setShowMembersModal(false)} tripId={tripId} tripTitle={trip?.title} />
|
||||
<ReservationModal isOpen={showReservationModal} onClose={() => { setShowReservationModal(false); setEditingReservation(null) }} onSave={handleSaveReservation} reservation={editingReservation} days={days} places={places} selectedDayId={selectedDayId} files={files} onFileUpload={(fd) => tripStore.addFile(tripId, fd)} onFileDelete={(id) => tripStore.deleteFile(tripId, id)} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user