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:
Maurice
2026-03-18 23:58:08 +01:00
commit cb1e217bbe
100 changed files with 25545 additions and 0 deletions

10
.dockerignore Normal file
View File

@@ -0,0 +1,10 @@
node_modules
client/node_modules
server/node_modules
client/dist
data
uploads
.git
.env
*.log
*.md

31
.gitignore vendored Normal file
View File

@@ -0,0 +1,31 @@
# Dependencies
node_modules/
# Build output
client/dist/
# Database
*.db
*.db-shm
*.db-wal
# User data
server/data/
server/uploads/
# Environment
.env
.env.local
.env.production
# OS files
.DS_Store
Thumbs.db
# IDE
.vscode/
.idea/
# Logs
*.log
npm-debug.log*

36
Dockerfile Normal file
View File

@@ -0,0 +1,36 @@
# Stage 1: React Client bauen
FROM node:22-alpine AS client-builder
WORKDIR /app/client
COPY client/package*.json ./
RUN npm ci
COPY client/ ./
RUN npm run build
# Stage 2: Produktions-Server
FROM node:22-alpine
WORKDIR /app
# Server-Dependencies installieren
COPY server/package*.json ./
RUN npm ci --production
# Server-Code kopieren
COPY server/ ./
# Gebauten Client kopieren
COPY --from=client-builder /app/client/dist ./public
# Fonts für PDF-Export kopieren
COPY --from=client-builder /app/client/public/fonts ./public/fonts
# Verzeichnisse erstellen
RUN mkdir -p /app/data /app/uploads/files /app/uploads/covers
# Umgebung setzen
ENV NODE_ENV=production
ENV PORT=3000
EXPOSE 3000
CMD ["node", "--experimental-sqlite", "src/index.js"]

185
README.md Normal file
View File

@@ -0,0 +1,185 @@
# TripPlanner
A full-stack travel planning web application with drag-and-drop itinerary building, place management, packing lists, and Google Maps integration.
## Features
- **Drag & Drop Planner**: Drag places from the sidebar onto day columns; reorder within days or move between days
- **Place Management**: Add places with address, price, tags, categories, reservation status, and visit time
- **Google Maps Integration**: Search Google Places to auto-fill place details (requires API key)
- **Packing Lists**: Grouped packing lists with progress tracking and quick-add suggestions
- **Trip Management**: Multiple trips with date ranges, automatic day generation
- **Categories & Tags**: Color-coded organization of places
- **Admin Panel**: User management and platform statistics
- **JWT Authentication**: Secure login and registration
## Tech Stack
- **Backend**: Node.js + Express + SQLite (better-sqlite3)
- **Frontend**: React 18 + Vite + Tailwind CSS v3
- **State**: Zustand with optimistic updates
- **DnD**: @dnd-kit/core + @dnd-kit/sortable
- **Auth**: JWT tokens
- **Icons**: lucide-react
## Default Admin Credentials
```
Email: admin@admin.com
Password: admin123
```
Change these credentials after first login in production!
## Development Setup
### Prerequisites
- Node.js 18+
- npm or yarn
### 1. Install Server Dependencies
```bash
cd server
npm install
```
### 2. Configure Server Environment
```bash
cp .env.example .env
# Edit .env and set a secure JWT_SECRET
```
### 3. Install Client Dependencies
```bash
cd client
npm install
```
### 4. Run Development Servers
In two separate terminals:
```bash
# Terminal 1 - Server (from /server directory)
npm run dev
# Terminal 2 - Client (from /client directory)
npm run dev
```
The server runs on http://localhost:3001 and the client on http://localhost:5173.
The Vite dev server proxies /api requests to the Express server automatically.
## Production Build
### Manual
```bash
# Build the client
cd client
npm run build
# The built files go to client/dist/
# Copy them to server/public/
cp -r dist ../server/public
# Run the server in production mode
cd ../server
NODE_ENV=production node src/index.js
```
### Docker
```bash
# Build and run with Docker Compose
docker-compose up --build
# The app will be available at http://localhost:3000
```
## Environment Variables
### Server (.env)
| Variable | Default | Description |
|----------|---------|-------------|
| `PORT` | `3001` | Server port |
| `JWT_SECRET` | (required) | Secret key for JWT signing |
| `NODE_ENV` | `development` | Environment mode |
In production, set a strong random JWT_SECRET (at least 32 characters).
## Google Maps API Key Setup
1. Go to [Google Cloud Console](https://console.cloud.google.com/)
2. Create a new project or select existing
3. Enable the **Places API (New)**
4. Create an API key under Credentials
5. Optionally restrict the key to your domain
6. In the app: go to a trip → Settings tab → enter your API key
The Maps integration allows:
- Searching places by name/query
- Auto-filling place details (name, address, coordinates, phone, website)
## SQLite Database
The database is stored at `./data/travel.db` (relative to the server process working directory).
In Docker, this is mounted as a volume at `./data`.
## API Endpoints
### Auth
- `POST /api/auth/register`
- `POST /api/auth/login`
- `GET /api/auth/me`
- `PUT /api/auth/me/maps-key`
### Trips
- `GET /api/trips`
- `POST /api/trips`
- `GET /api/trips/:id`
- `PUT /api/trips/:id`
- `DELETE /api/trips/:id`
### Days
- `GET /api/trips/:tripId/days`
- `POST /api/trips/:tripId/days`
- `PUT /api/trips/:tripId/days/:id`
- `DELETE /api/trips/:tripId/days/:id`
### Places
- `GET /api/trips/:tripId/places`
- `POST /api/trips/:tripId/places`
- `PUT /api/trips/:tripId/places/:id`
- `DELETE /api/trips/:tripId/places/:id`
### Assignments
- `POST /api/trips/:tripId/days/:dayId/assignments`
- `DELETE /api/trips/:tripId/days/:dayId/assignments/:id`
- `PUT /api/trips/:tripId/days/:dayId/assignments/reorder`
- `PUT /api/trips/:tripId/assignments/:id/move`
### Packing
- `GET /api/trips/:tripId/packing`
- `POST /api/trips/:tripId/packing`
- `PUT /api/trips/:tripId/packing/:id`
- `DELETE /api/trips/:tripId/packing/:id`
### Tags & Categories
- `GET/POST/PUT/DELETE /api/tags`
- `GET/POST/PUT/DELETE /api/categories`
### Maps (requires API key)
- `POST /api/maps/search`
- `GET /api/maps/details/:placeId`
### Admin
- `GET /api/admin/users`
- `PUT /api/admin/users/:id`
- `DELETE /api/admin/users/:id`
- `GET /api/admin/stats`

14
client/index.html Normal file
View File

@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23111827' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><path d='M17.8 19.2 16 11l3.5-3.5C21 6 21 4 19 2c-2-2-4-2-5.5-.5L10 5 1.8 6.2c-.5.1-.9.6-.6 1.1l1.9 2.9 2.5-.9 4-4 2.7 2.7-4 4 .9 2.5 2.9 1.9c.5.3 1 0 1.1-.5z'/></svg>" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<title>NOMAD</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

3664
client/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

34
client/package.json Normal file
View File

@@ -0,0 +1,34 @@
{
"name": "nomad-client",
"version": "2.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@react-pdf/renderer": "^4.3.2",
"axios": "^1.6.7",
"leaflet": "^1.9.4",
"lucide-react": "^0.344.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-dropzone": "^14.4.1",
"react-leaflet": "^4.2.1",
"react-router-dom": "^6.22.2",
"topojson-client": "^3.1.0",
"zustand": "^4.5.2"
},
"devDependencies": {
"@types/leaflet": "^1.9.8",
"@types/react": "^18.2.61",
"@types/react-dom": "^18.2.19",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.18",
"postcss": "^8.4.35",
"tailwindcss": "^3.4.1",
"vite": "^5.1.4"
}
}

6
client/postcss.config.js Normal file
View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

133
client/src/App.jsx Normal file
View File

@@ -0,0 +1,133 @@
import React, { useEffect } from 'react'
import { Routes, Route, Navigate, useLocation } from 'react-router-dom'
import { useAuthStore } from './store/authStore'
import { useSettingsStore } from './store/settingsStore'
import LoginPage from './pages/LoginPage'
import RegisterPage from './pages/RegisterPage'
import DashboardPage from './pages/DashboardPage'
import TripPlannerPage from './pages/TripPlannerPage'
// PhotosPage removed - replaced by Finanzplan
import FilesPage from './pages/FilesPage'
import AdminPage from './pages/AdminPage'
import SettingsPage from './pages/SettingsPage'
import { ToastContainer } from './components/shared/Toast'
import { TranslationProvider } from './i18n'
function ProtectedRoute({ children, adminRequired = false }) {
const { isAuthenticated, user, isLoading } = useAuthStore()
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center bg-slate-50">
<div className="flex flex-col items-center gap-3">
<div className="w-10 h-10 border-4 border-slate-200 border-t-slate-900 rounded-full animate-spin"></div>
<p className="text-slate-500 text-sm">Wird geladen...</p>
</div>
</div>
)
}
if (!isAuthenticated) {
return <Navigate to="/login" replace />
}
if (adminRequired && user?.role !== 'admin') {
return <Navigate to="/dashboard" replace />
}
return children
}
function RootRedirect() {
const { isAuthenticated, isLoading } = useAuthStore()
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-900 rounded-full animate-spin"></div>
</div>
)
}
return <Navigate to={isAuthenticated ? '/dashboard' : '/login'} replace />
}
export default function App() {
const { loadUser, token, isAuthenticated } = useAuthStore()
const { loadSettings } = useSettingsStore()
useEffect(() => {
if (token) {
loadUser()
}
}, [])
const { settings } = useSettingsStore()
useEffect(() => {
if (isAuthenticated) {
loadSettings()
}
}, [isAuthenticated])
// Apply dark mode class to <html>
useEffect(() => {
if (settings.dark_mode) {
document.documentElement.classList.add('dark')
} else {
document.documentElement.classList.remove('dark')
}
}, [settings.dark_mode])
return (
<TranslationProvider>
<ToastContainer />
<Routes>
<Route path="/" element={<RootRedirect />} />
<Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<Navigate to="/login" replace />} />
<Route
path="/dashboard"
element={
<ProtectedRoute>
<DashboardPage />
</ProtectedRoute>
}
/>
<Route
path="/trips/:id"
element={
<ProtectedRoute>
<TripPlannerPage />
</ProtectedRoute>
}
/>
<Route
path="/trips/:id/files"
element={
<ProtectedRoute>
<FilesPage />
</ProtectedRoute>
}
/>
<Route
path="/admin"
element={
<ProtectedRoute adminRequired>
<AdminPage />
</ProtectedRoute>
}
/>
<Route
path="/settings"
element={
<ProtectedRoute>
<SettingsPage />
</ProtectedRoute>
}
/>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</TranslationProvider>
)
}

194
client/src/api/client.js Normal file
View File

@@ -0,0 +1,194 @@
import axios from 'axios'
const apiClient = axios.create({
baseURL: '/api',
headers: {
'Content-Type': 'application/json',
},
})
// Request interceptor - add auth token
apiClient.interceptors.request.use(
(config) => {
const token = localStorage.getItem('auth_token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
},
(error) => Promise.reject(error)
)
// Response interceptor - handle 401
apiClient.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
localStorage.removeItem('auth_token')
if (!window.location.pathname.includes('/login') && !window.location.pathname.includes('/register')) {
window.location.href = '/login'
}
}
return Promise.reject(error)
}
)
export const authApi = {
register: (data) => apiClient.post('/auth/register', data).then(r => r.data),
login: (data) => apiClient.post('/auth/login', data).then(r => r.data),
me: () => apiClient.get('/auth/me').then(r => r.data),
updateMapsKey: (key) => apiClient.put('/auth/me/maps-key', { maps_api_key: key }).then(r => r.data),
updateApiKeys: (data) => apiClient.put('/auth/me/api-keys', data).then(r => r.data),
updateSettings: (data) => apiClient.put('/auth/me/settings', data).then(r => r.data),
getSettings: () => apiClient.get('/auth/me/settings').then(r => r.data),
listUsers: () => apiClient.get('/auth/users').then(r => r.data),
uploadAvatar: (formData) => apiClient.post('/auth/avatar', formData, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data),
deleteAvatar: () => apiClient.delete('/auth/avatar').then(r => r.data),
getAppConfig: () => apiClient.get('/auth/app-config').then(r => r.data),
updateAppSettings: (data) => apiClient.put('/auth/app-settings', data).then(r => r.data),
validateKeys: () => apiClient.get('/auth/validate-keys').then(r => r.data),
travelStats: () => apiClient.get('/auth/travel-stats').then(r => r.data),
}
export const tripsApi = {
list: (params) => apiClient.get('/trips', { params }).then(r => r.data),
create: (data) => apiClient.post('/trips', data).then(r => r.data),
get: (id) => apiClient.get(`/trips/${id}`).then(r => r.data),
update: (id, data) => apiClient.put(`/trips/${id}`, data).then(r => r.data),
delete: (id) => apiClient.delete(`/trips/${id}`).then(r => r.data),
uploadCover: (id, formData) => apiClient.post(`/trips/${id}/cover`, formData, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data),
archive: (id) => apiClient.put(`/trips/${id}`, { is_archived: true }).then(r => r.data),
unarchive: (id) => apiClient.put(`/trips/${id}`, { is_archived: false }).then(r => r.data),
getMembers: (id) => apiClient.get(`/trips/${id}/members`).then(r => r.data),
addMember: (id, identifier) => apiClient.post(`/trips/${id}/members`, { identifier }).then(r => r.data),
removeMember: (id, userId) => apiClient.delete(`/trips/${id}/members/${userId}`).then(r => r.data),
}
export const daysApi = {
list: (tripId) => apiClient.get(`/trips/${tripId}/days`).then(r => r.data),
create: (tripId, data) => apiClient.post(`/trips/${tripId}/days`, data).then(r => r.data),
update: (tripId, dayId, data) => apiClient.put(`/trips/${tripId}/days/${dayId}`, data).then(r => r.data),
delete: (tripId, dayId) => apiClient.delete(`/trips/${tripId}/days/${dayId}`).then(r => r.data),
}
export const placesApi = {
list: (tripId, params) => apiClient.get(`/trips/${tripId}/places`, { params }).then(r => r.data),
create: (tripId, data) => apiClient.post(`/trips/${tripId}/places`, data).then(r => r.data),
get: (tripId, id) => apiClient.get(`/trips/${tripId}/places/${id}`).then(r => r.data),
update: (tripId, id, data) => apiClient.put(`/trips/${tripId}/places/${id}`, data).then(r => r.data),
delete: (tripId, id) => apiClient.delete(`/trips/${tripId}/places/${id}`).then(r => r.data),
searchImage: (tripId, id) => apiClient.get(`/trips/${tripId}/places/${id}/image`).then(r => r.data),
}
export const assignmentsApi = {
list: (tripId, dayId) => apiClient.get(`/trips/${tripId}/days/${dayId}/assignments`).then(r => r.data),
create: (tripId, dayId, data) => apiClient.post(`/trips/${tripId}/days/${dayId}/assignments`, data).then(r => r.data),
delete: (tripId, dayId, id) => apiClient.delete(`/trips/${tripId}/days/${dayId}/assignments/${id}`).then(r => r.data),
reorder: (tripId, dayId, orderedIds) => apiClient.put(`/trips/${tripId}/days/${dayId}/assignments/reorder`, { orderedIds }).then(r => r.data),
move: (tripId, assignmentId, newDayId, orderIndex) => apiClient.put(`/trips/${tripId}/assignments/${assignmentId}/move`, { new_day_id: newDayId, order_index: orderIndex }).then(r => r.data),
}
export const packingApi = {
list: (tripId) => apiClient.get(`/trips/${tripId}/packing`).then(r => r.data),
create: (tripId, data) => apiClient.post(`/trips/${tripId}/packing`, data).then(r => r.data),
update: (tripId, id, data) => apiClient.put(`/trips/${tripId}/packing/${id}`, data).then(r => r.data),
delete: (tripId, id) => apiClient.delete(`/trips/${tripId}/packing/${id}`).then(r => r.data),
reorder: (tripId, orderedIds) => apiClient.put(`/trips/${tripId}/packing/reorder`, { orderedIds }).then(r => r.data),
}
export const tagsApi = {
list: () => apiClient.get('/tags').then(r => r.data),
create: (data) => apiClient.post('/tags', data).then(r => r.data),
update: (id, data) => apiClient.put(`/tags/${id}`, data).then(r => r.data),
delete: (id) => apiClient.delete(`/tags/${id}`).then(r => r.data),
}
export const categoriesApi = {
list: () => apiClient.get('/categories').then(r => r.data),
create: (data) => apiClient.post('/categories', data).then(r => r.data),
update: (id, data) => apiClient.put(`/categories/${id}`, data).then(r => r.data),
delete: (id) => apiClient.delete(`/categories/${id}`).then(r => r.data),
}
export const adminApi = {
users: () => apiClient.get('/admin/users').then(r => r.data),
updateUser: (id, data) => apiClient.put(`/admin/users/${id}`, data).then(r => r.data),
deleteUser: (id) => apiClient.delete(`/admin/users/${id}`).then(r => r.data),
stats: () => apiClient.get('/admin/stats').then(r => r.data),
}
export const mapsApi = {
search: (query, lang) => apiClient.post(`/maps/search?lang=${lang || 'en'}`, { query }).then(r => r.data),
details: (placeId, lang) => apiClient.get(`/maps/details/${placeId}`, { params: { lang } }).then(r => r.data),
placePhoto: (placeId) => apiClient.get(`/maps/place-photo/${placeId}`).then(r => r.data),
}
export const budgetApi = {
list: (tripId) => apiClient.get(`/trips/${tripId}/budget`).then(r => r.data),
create: (tripId, data) => apiClient.post(`/trips/${tripId}/budget`, data).then(r => r.data),
update: (tripId, id, data) => apiClient.put(`/trips/${tripId}/budget/${id}`, data).then(r => r.data),
delete: (tripId, id) => apiClient.delete(`/trips/${tripId}/budget/${id}`).then(r => r.data),
}
export const filesApi = {
list: (tripId) => apiClient.get(`/trips/${tripId}/files`).then(r => r.data),
upload: (tripId, formData) => apiClient.post(`/trips/${tripId}/files`, formData, {
headers: { 'Content-Type': 'multipart/form-data' }
}).then(r => r.data),
update: (tripId, id, data) => apiClient.put(`/trips/${tripId}/files/${id}`, data).then(r => r.data),
delete: (tripId, id) => apiClient.delete(`/trips/${tripId}/files/${id}`).then(r => r.data),
}
export const reservationsApi = {
list: (tripId) => apiClient.get(`/trips/${tripId}/reservations`).then(r => r.data),
create: (tripId, data) => apiClient.post(`/trips/${tripId}/reservations`, data).then(r => r.data),
update: (tripId, id, data) => apiClient.put(`/trips/${tripId}/reservations/${id}`, data).then(r => r.data),
delete: (tripId, id) => apiClient.delete(`/trips/${tripId}/reservations/${id}`).then(r => r.data),
}
export const weatherApi = {
get: (lat, lng, date) => apiClient.get('/weather', { params: { lat, lng, date, units: 'metric' } }).then(r => r.data),
}
export const settingsApi = {
get: () => apiClient.get('/settings').then(r => r.data),
set: (key, value) => apiClient.put('/settings', { key, value }).then(r => r.data),
setBulk: (settings) => apiClient.post('/settings/bulk', { settings }).then(r => r.data),
}
export const dayNotesApi = {
list: (tripId, dayId) => apiClient.get(`/trips/${tripId}/days/${dayId}/notes`).then(r => r.data),
create: (tripId, dayId, data) => apiClient.post(`/trips/${tripId}/days/${dayId}/notes`, data).then(r => r.data),
update: (tripId, dayId, id, data) => apiClient.put(`/trips/${tripId}/days/${dayId}/notes/${id}`, data).then(r => r.data),
delete: (tripId, dayId, id) => apiClient.delete(`/trips/${tripId}/days/${dayId}/notes/${id}`).then(r => r.data),
}
export const backupApi = {
list: () => apiClient.get('/backup/list').then(r => r.data),
create: () => apiClient.post('/backup/create').then(r => r.data),
download: async (filename) => {
const token = localStorage.getItem('auth_token')
const res = await fetch(`/api/backup/download/${filename}`, {
headers: { Authorization: `Bearer ${token}` },
})
if (!res.ok) throw new Error('Download fehlgeschlagen')
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
a.click()
URL.revokeObjectURL(url)
},
delete: (filename) => apiClient.delete(`/backup/${filename}`).then(r => r.data),
restore: (filename) => apiClient.post(`/backup/restore/${filename}`).then(r => r.data),
uploadRestore: (file) => {
const form = new FormData()
form.append('backup', file)
return apiClient.post('/backup/upload-restore', form, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data)
},
getAutoSettings: () => apiClient.get('/backup/auto-settings').then(r => r.data),
setAutoSettings: (settings) => apiClient.put('/backup/auto-settings', settings).then(r => r.data),
}
export default apiClient

View File

@@ -0,0 +1,360 @@
import React, { useState, useEffect, useRef } from 'react'
import { backupApi } from '../../api/client'
import { useToast } from '../shared/Toast'
import { Download, Trash2, Plus, RefreshCw, RotateCcw, Upload, Clock, Check, HardDrive } from 'lucide-react'
import { useTranslation } from '../../i18n'
const INTERVAL_OPTIONS = [
{ value: 'hourly', labelKey: 'backup.interval.hourly' },
{ value: 'daily', labelKey: 'backup.interval.daily' },
{ value: 'weekly', labelKey: 'backup.interval.weekly' },
{ value: 'monthly', labelKey: 'backup.interval.monthly' },
]
const KEEP_OPTIONS = [
{ value: 1, labelKey: 'backup.keep.1day' },
{ value: 3, labelKey: 'backup.keep.3days' },
{ value: 7, labelKey: 'backup.keep.7days' },
{ value: 14, labelKey: 'backup.keep.14days' },
{ value: 30, labelKey: 'backup.keep.30days' },
{ value: 0, labelKey: 'backup.keep.forever' },
]
export default function BackupPanel() {
const [backups, setBackups] = useState([])
const [isLoading, setIsLoading] = useState(false)
const [isCreating, setIsCreating] = useState(false)
const [restoringFile, setRestoringFile] = useState(null)
const [isUploading, setIsUploading] = useState(false)
const [autoSettings, setAutoSettings] = useState({ enabled: false, interval: 'daily', keep_days: 7 })
const [autoSettingsSaving, setAutoSettingsSaving] = useState(false)
const [autoSettingsDirty, setAutoSettingsDirty] = useState(false)
const fileInputRef = useRef(null)
const toast = useToast()
const { t, locale } = useTranslation()
const loadBackups = async () => {
setIsLoading(true)
try {
const data = await backupApi.list()
setBackups(data.backups || [])
} catch {
toast.error(t('backup.toast.loadError'))
} finally {
setIsLoading(false)
}
}
const loadAutoSettings = async () => {
try {
const data = await backupApi.getAutoSettings()
setAutoSettings(data.settings)
} catch {}
}
useEffect(() => { loadBackups(); loadAutoSettings() }, [])
const handleCreate = async () => {
setIsCreating(true)
try {
await backupApi.create()
toast.success(t('backup.toast.created'))
await loadBackups()
} catch {
toast.error(t('backup.toast.createError'))
} finally {
setIsCreating(false)
}
}
const handleRestore = async (filename) => {
if (!confirm(t('backup.confirm.restore', { name: filename }))) return
setRestoringFile(filename)
try {
await backupApi.restore(filename)
toast.success(t('backup.toast.restored'))
setTimeout(() => window.location.reload(), 1500)
} catch (err) {
toast.error(err.response?.data?.error || t('backup.toast.restoreError'))
setRestoringFile(null)
}
}
const handleUploadRestore = async (e) => {
const file = e.target.files?.[0]
if (!file) return
e.target.value = ''
if (!confirm(t('backup.confirm.uploadRestore', { name: file.name }))) return
setIsUploading(true)
try {
await backupApi.uploadRestore(file)
toast.success(t('backup.toast.restored'))
setTimeout(() => window.location.reload(), 1500)
} catch (err) {
toast.error(err.response?.data?.error || t('backup.toast.uploadError'))
setIsUploading(false)
}
}
const handleDelete = async (filename) => {
if (!confirm(t('backup.confirm.delete', { name: filename }))) return
try {
await backupApi.delete(filename)
toast.success(t('backup.toast.deleted'))
setBackups(prev => prev.filter(b => b.filename !== filename))
} catch {
toast.error(t('backup.toast.deleteError'))
}
}
const handleAutoSettingsChange = (key, value) => {
setAutoSettings(prev => ({ ...prev, [key]: value }))
setAutoSettingsDirty(true)
}
const handleSaveAutoSettings = async () => {
setAutoSettingsSaving(true)
try {
const data = await backupApi.setAutoSettings(autoSettings)
setAutoSettings(data.settings)
setAutoSettingsDirty(false)
toast.success(t('backup.toast.settingsSaved'))
} catch {
toast.error(t('backup.toast.settingsError'))
} finally {
setAutoSettingsSaving(false)
}
}
const formatSize = (bytes) => {
if (!bytes) return '-'
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
}
const formatDate = (dateStr) => {
if (!dateStr) return '-'
try {
return new Date(dateStr).toLocaleString(locale, {
day: '2-digit', month: '2-digit', year: 'numeric',
hour: '2-digit', minute: '2-digit',
})
} catch { return dateStr }
}
const isAuto = (filename) => filename.startsWith('auto-backup-')
return (
<div className="flex flex-col gap-6">
{/* Manual Backups */}
<div className="bg-white rounded-2xl border border-gray-200 p-6">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<HardDrive className="w-5 h-5 text-gray-400" />
<div>
<h2 className="text-lg font-semibold text-gray-900">{t('backup.title')}</h2>
<p className="text-sm text-gray-500 mt-0.5">{t('backup.subtitle')}</p>
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={loadBackups}
disabled={isLoading}
className="p-2 text-gray-500 hover:bg-gray-100 rounded-lg transition-colors"
title={t('backup.refresh')}
>
<RefreshCw className={`w-4 h-4 ${isLoading ? 'animate-spin' : ''}`} />
</button>
{/* Upload & Restore */}
<input
ref={fileInputRef}
type="file"
accept=".zip"
className="hidden"
onChange={handleUploadRestore}
/>
<button
onClick={() => fileInputRef.current?.click()}
disabled={isUploading}
className="flex items-center gap-2 border border-gray-200 text-gray-700 px-3 py-2 rounded-lg hover:bg-gray-50 text-sm font-medium disabled:opacity-60"
>
{isUploading ? (
<div className="w-4 h-4 border-2 border-gray-400 border-t-transparent rounded-full animate-spin" />
) : (
<Upload className="w-4 h-4" />
)}
{isUploading ? t('backup.uploading') : t('backup.upload')}
</button>
<button
onClick={handleCreate}
disabled={isCreating}
className="flex items-center gap-2 bg-slate-700 text-white px-4 py-2 rounded-lg hover:bg-slate-900 text-sm font-medium disabled:opacity-60"
>
{isCreating ? (
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
) : (
<Plus className="w-4 h-4" />
)}
{isCreating ? t('backup.creating') : t('backup.create')}
</button>
</div>
</div>
{isLoading && backups.length === 0 ? (
<div className="flex items-center justify-center py-12 text-gray-400">
<div className="w-6 h-6 border-2 border-gray-300 border-t-slate-700 rounded-full animate-spin mr-2" />
{t('common.loading')}
</div>
) : backups.length === 0 ? (
<div className="text-center py-12 text-gray-400">
<HardDrive className="w-10 h-10 mb-3 mx-auto opacity-40" />
<p className="text-sm">{t('backup.empty')}</p>
<button onClick={handleCreate} className="mt-4 text-slate-700 text-sm hover:underline">
{t('backup.createFirst')}
</button>
</div>
) : (
<div className="divide-y divide-gray-100">
{backups.map(backup => (
<div key={backup.filename} className="flex items-center gap-4 py-3">
<div className="w-8 h-8 rounded-lg bg-gray-100 flex items-center justify-center flex-shrink-0">
{isAuto(backup.filename)
? <RefreshCw className="w-4 h-4 text-blue-500" />
: <HardDrive className="w-4 h-4 text-gray-500" />
}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className="font-medium text-sm text-gray-900 truncate">{backup.filename}</p>
{isAuto(backup.filename) && (
<span className="text-xs bg-blue-50 text-blue-600 border border-blue-100 rounded-full px-2 py-0.5 whitespace-nowrap">Auto</span>
)}
</div>
<div className="flex items-center gap-3 mt-0.5">
<span className="text-xs text-gray-400">{formatDate(backup.created_at)}</span>
<span className="text-xs text-gray-400">{formatSize(backup.size)}</span>
</div>
</div>
<div className="flex items-center gap-1.5 flex-shrink-0">
<button
onClick={() => backupApi.download(backup.filename).catch(() => toast.error(t('backup.toast.downloadError')))}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs text-slate-700 border border-slate-200 rounded-lg hover:bg-slate-50"
>
<Download className="w-3.5 h-3.5" />
{t('backup.download')}
</button>
<button
onClick={() => handleRestore(backup.filename)}
disabled={restoringFile === backup.filename}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs text-amber-700 border border-amber-200 rounded-lg hover:bg-amber-50 disabled:opacity-60"
>
{restoringFile === backup.filename
? <div className="w-3.5 h-3.5 border-2 border-amber-400 border-t-transparent rounded-full animate-spin" />
: <RotateCcw className="w-3.5 h-3.5" />
}
{t('backup.restore')}
</button>
<button
onClick={() => handleDelete(backup.filename)}
className="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
))}
</div>
)}
</div>
{/* Auto-Backup Settings */}
<div className="bg-white rounded-2xl border border-gray-200 p-6">
<div className="flex items-center gap-3 mb-6">
<Clock className="w-5 h-5 text-gray-400" />
<div>
<h2 className="text-lg font-semibold text-gray-900">{t('backup.auto.title')}</h2>
<p className="text-sm text-gray-500 mt-0.5">{t('backup.auto.subtitle')}</p>
</div>
</div>
<div className="flex flex-col gap-5">
{/* Enable toggle */}
<label className="flex items-center justify-between cursor-pointer">
<div>
<span className="text-sm font-medium text-gray-900">{t('backup.auto.enable')}</span>
<p className="text-xs text-gray-500 mt-0.5">{t('backup.auto.enableHint')}</p>
</div>
<button
onClick={() => handleAutoSettingsChange('enabled', !autoSettings.enabled)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${autoSettings.enabled ? 'bg-slate-700' : 'bg-gray-200'}`}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${autoSettings.enabled ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
</label>
{autoSettings.enabled && (
<>
{/* Interval */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">{t('backup.auto.interval')}</label>
<div className="flex flex-wrap gap-2">
{INTERVAL_OPTIONS.map(opt => (
<button
key={opt.value}
onClick={() => handleAutoSettingsChange('interval', opt.value)}
className={`px-4 py-2 rounded-lg text-sm font-medium border transition-colors ${
autoSettings.interval === opt.value
? 'bg-slate-700 text-white border-slate-700'
: 'bg-white text-gray-600 border-gray-200 hover:border-gray-300'
}`}
>
{t(opt.labelKey)}
</button>
))}
</div>
</div>
{/* Keep duration */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">{t('backup.auto.keepLabel')}</label>
<div className="flex flex-wrap gap-2">
{KEEP_OPTIONS.map(opt => (
<button
key={opt.value}
onClick={() => handleAutoSettingsChange('keep_days', opt.value)}
className={`px-4 py-2 rounded-lg text-sm font-medium border transition-colors ${
autoSettings.keep_days === opt.value
? 'bg-slate-700 text-white border-slate-700'
: 'bg-white text-gray-600 border-gray-200 hover:border-gray-300'
}`}
>
{t(opt.labelKey)}
</button>
))}
</div>
</div>
</>
)}
{/* Save button */}
<div className="flex justify-end pt-2 border-t border-gray-100">
<button
onClick={handleSaveAutoSettings}
disabled={autoSettingsSaving || !autoSettingsDirty}
className="flex items-center gap-2 bg-slate-700 text-white px-5 py-2 rounded-lg hover:bg-slate-900 text-sm font-medium disabled:opacity-50 transition-colors"
>
{autoSettingsSaving
? <div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
: <Check className="w-4 h-4" />
}
{autoSettingsSaving ? t('common.saving') : t('common.save')}
</button>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,255 @@
import React, { useState, useEffect, useRef } from 'react'
import { categoriesApi } from '../../api/client'
import { useToast } from '../shared/Toast'
import { Plus, Edit2, Trash2, Pipette } from 'lucide-react'
import { CATEGORY_ICON_MAP, ICON_LABELS, getCategoryIcon } from '../shared/categoryIcons'
import { useTranslation } from '../../i18n'
const PRESET_COLORS = [
'#6366f1', '#8b5cf6', '#ec4899', '#ef4444', '#f97316',
'#f59e0b', '#10b981', '#06b6d4', '#3b82f6', '#84cc16',
'#6b7280', '#1f2937',
]
const ICON_NAMES = Object.keys(CATEGORY_ICON_MAP)
export default function CategoryManager() {
const [categories, setCategories] = useState([])
const [showForm, setShowForm] = useState(false)
const [editingId, setEditingId] = useState(null)
const [form, setForm] = useState({ name: '', color: '#6366f1', icon: 'MapPin' })
const [isSaving, setIsSaving] = useState(false)
const [isLoading, setIsLoading] = useState(true)
const colorInputRef = useRef(null)
const toast = useToast()
const { t } = useTranslation()
useEffect(() => { loadCategories() }, [])
const loadCategories = async () => {
setIsLoading(true)
try {
const data = await categoriesApi.list()
setCategories(data.categories || [])
} catch (err) {
toast.error(t('categories.toast.loadError'))
} finally {
setIsLoading(false)
}
}
const handleStartEdit = (cat) => {
setEditingId(cat.id)
setForm({ name: cat.name, color: cat.color || '#6366f1', icon: cat.icon || 'MapPin' })
setShowForm(false)
}
const handleStartCreate = () => {
setEditingId(null)
setForm({ name: '', color: '#6366f1', icon: 'MapPin' })
setShowForm(true)
}
const handleCancel = () => {
setShowForm(false)
setEditingId(null)
}
const handleSave = async () => {
if (!form.name.trim()) { toast.error(t('categories.toast.nameRequired')); return }
setIsSaving(true)
try {
if (editingId) {
const result = await categoriesApi.update(editingId, form)
setCategories(prev => prev.map(c => c.id === editingId ? result.category : c))
setEditingId(null)
toast.success(t('categories.toast.updated'))
} else {
const result = await categoriesApi.create(form)
setCategories(prev => [...prev, result.category])
setShowForm(false)
toast.success(t('categories.toast.created'))
}
setForm({ name: '', color: '#6366f1', icon: 'MapPin' })
} catch (err) {
toast.error(err.response?.data?.error || t('categories.toast.saveError'))
} finally {
setIsSaving(false)
}
}
const handleDelete = async (id) => {
if (!confirm(t('categories.confirm.delete'))) return
try {
await categoriesApi.delete(id)
setCategories(prev => prev.filter(c => c.id !== id))
toast.success(t('categories.toast.deleted'))
} catch (err) {
toast.error(err.response?.data?.error || t('categories.toast.deleteError'))
}
}
const isPresetColor = PRESET_COLORS.includes(form.color)
const PreviewIcon = getCategoryIcon(form.icon)
const categoryForm = (
<div className="bg-gray-50 rounded-xl p-4 space-y-3 border border-gray-200">
<input
type="text"
value={form.name}
onChange={e => setForm(prev => ({ ...prev, name: e.target.value }))}
placeholder={t('categories.namePlaceholder')}
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-slate-400 bg-white"
autoFocus
/>
<div>
<label className="block text-xs font-medium text-gray-600 mb-2">{t('categories.icon')}</label>
<div className="max-h-48 overflow-y-auto">
<div className="flex flex-wrap gap-1.5 px-1.5 py-1.5">
{ICON_NAMES.map(name => {
const Icon = CATEGORY_ICON_MAP[name]
const isSelected = form.icon === name
return (
<button
key={name}
type="button"
title={ICON_LABELS[name] || name}
onClick={() => setForm(prev => ({ ...prev, icon: name }))}
className={`w-9 h-9 flex items-center justify-center rounded-lg transition-all ${
isSelected
? 'ring-2 ring-offset-1 ring-slate-700'
: 'hover:bg-gray-200'
}`}
style={{ background: isSelected ? `${form.color}18` : undefined }}
>
<Icon size={17} strokeWidth={1.8} color={isSelected ? form.color : '#374151'} />
</button>
)
})}
</div>
</div>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 mb-1.5">{t('categories.color')}</label>
<div className="flex items-center gap-2 flex-wrap">
{PRESET_COLORS.map(color => (
<button key={color} type="button" onClick={() => setForm(prev => ({ ...prev, color }))}
className={`w-7 h-7 rounded-full transition-transform hover:scale-110 ${form.color === color ? 'ring-2 ring-offset-2 ring-gray-400 scale-110' : ''}`}
style={{ backgroundColor: color }} />
))}
{/* Custom color button */}
<input
ref={colorInputRef}
type="color"
value={form.color}
onChange={e => setForm(prev => ({ ...prev, color: e.target.value }))}
className="sr-only"
/>
<button
type="button"
title={t('categories.customColor')}
onClick={() => colorInputRef.current?.click()}
className={`w-7 h-7 rounded-full flex items-center justify-center border-2 transition-transform hover:scale-110 ${
!isPresetColor
? 'ring-2 ring-offset-2 ring-gray-400 scale-110 border-transparent'
: 'border-dashed border-gray-300 hover:border-gray-400'
}`}
style={!isPresetColor ? { backgroundColor: form.color } : undefined}
>
{isPresetColor && <Pipette className="w-3 h-3 text-gray-400" />}
</button>
</div>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-gray-500">{t('categories.preview')}:</span>
<span className="inline-flex items-center gap-1.5 text-sm px-2.5 py-1 rounded-full font-medium"
style={{ backgroundColor: `${form.color}20`, color: form.color }}>
<PreviewIcon size={14} strokeWidth={1.8} />
{form.name || t('categories.defaultName')}
</span>
</div>
<div className="flex justify-end gap-2">
<button type="button" onClick={handleCancel}
className="px-3 py-1.5 text-sm text-gray-600 border border-gray-200 rounded-lg hover:bg-gray-50">
{t('common.cancel')}
</button>
<button type="button" onClick={handleSave} disabled={isSaving || !form.name.trim()}
className="px-4 py-1.5 text-sm bg-slate-900 text-white rounded-lg hover:bg-slate-700 disabled:opacity-60 font-medium">
{isSaving ? t('common.saving') : editingId ? t('categories.update') : t('categories.create')}
</button>
</div>
</div>
)
return (
<div className="bg-white rounded-2xl border border-gray-200 p-6">
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-lg font-semibold text-gray-900">{t('categories.title')}</h2>
<p className="text-sm text-gray-500 mt-0.5">{t('categories.subtitle')}</p>
</div>
<button onClick={handleStartCreate}
className="flex items-center gap-2 bg-slate-900 text-white px-4 py-2 rounded-lg hover:bg-slate-700 text-sm font-medium">
<Plus className="w-4 h-4" />
{t('categories.new')}
</button>
</div>
{showForm && <div className="mb-4">{categoryForm}</div>}
{isLoading ? (
<div className="flex items-center justify-center py-8 text-gray-400">
<div className="w-6 h-6 border-2 border-gray-300 border-t-slate-600 rounded-full animate-spin" />
</div>
) : categories.length === 0 ? (
<div className="text-center py-8 text-gray-400">
<p className="text-sm">{t('categories.empty')}</p>
</div>
) : (
<div className="space-y-2">
{categories.map(cat => {
const Icon = getCategoryIcon(cat.icon)
return (
<div key={cat.id}>
{editingId === cat.id ? (
<div className="mb-2">{categoryForm}</div>
) : (
<div className="flex items-center gap-3 p-3 border border-gray-100 rounded-xl hover:border-gray-200 group">
<div className="w-10 h-10 rounded-xl flex items-center justify-center flex-shrink-0"
style={{ backgroundColor: `${cat.color}20` }}>
<Icon size={18} strokeWidth={1.8} color={cat.color} />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-gray-900 text-sm">{cat.name}</span>
<span className="text-xs px-2 py-0.5 rounded-full"
style={{ backgroundColor: `${cat.color}20`, color: cat.color }}>
{cat.color}
</span>
</div>
</div>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button onClick={() => handleStartEdit(cat)}
className="p-1.5 text-gray-400 hover:text-slate-700 hover:bg-slate-100 rounded-lg">
<Edit2 className="w-4 h-4" />
</button>
<button onClick={() => handleDelete(cat.id)}
className="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg">
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
)}
</div>
)
})}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,407 @@
import React, { useState, useEffect, useRef, useMemo } from 'react'
import { useTripStore } from '../../store/tripStore'
import { useTranslation } from '../../i18n'
import { Plus, Trash2, Calculator, Wallet } from 'lucide-react'
import CustomSelect from '../shared/CustomSelect'
// ── Helpers ──────────────────────────────────────────────────────────────────
const CURRENCIES = ['EUR', 'USD', 'GBP', 'JPY', 'CHF', 'CZK', 'PLN', 'SEK', 'NOK', 'DKK', 'TRY', 'THB', 'AUD', 'CAD']
const SYMBOLS = { EUR: '€', USD: '$', GBP: '£', JPY: '¥', CHF: 'CHF', CZK: 'Kč', PLN: 'zł', SEK: 'kr', NOK: 'kr', DKK: 'kr', TRY: '₺', THB: '฿', AUD: 'A$', CAD: 'C$' }
const PIE_COLORS = ['#6366f1', '#ec4899', '#f59e0b', '#10b981', '#3b82f6', '#8b5cf6', '#ef4444', '#14b8a6', '#f97316', '#06b6d4', '#84cc16', '#a855f7']
const fmtNum = (v, locale, cur) => {
if (v == null || isNaN(v)) return '-'
return Number(v).toLocaleString(locale, { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + ' ' + (SYMBOLS[cur] || cur)
}
const calcPP = (p, n) => (n > 0 ? p / n : null)
const calcPD = (p, d) => (d > 0 ? p / d : null)
const calcPPD = (p, n, d) => (n > 0 && d > 0 ? p / (n * d) : null)
// ── Inline Edit Cell ─────────────────────────────────────────────────────────
function InlineEditCell({ value, onSave, type = 'text', style = {}, placeholder = '', decimals = 2, locale, editTooltip }) {
const [editing, setEditing] = useState(false)
const [editValue, setEditValue] = useState(value ?? '')
const inputRef = useRef(null)
useEffect(() => { if (editing && inputRef.current) { inputRef.current.focus(); inputRef.current.select() } }, [editing])
const save = () => {
setEditing(false)
let v = editValue
if (type === 'number') { const p = parseFloat(String(editValue).replace(',', '.')); v = isNaN(p) ? null : p }
if (v !== value) onSave(v)
}
if (editing) {
return <input ref={inputRef} type="text" inputMode={type === 'number' ? 'decimal' : 'text'} value={editValue}
onChange={e => setEditValue(e.target.value)} onBlur={save}
onKeyDown={e => { if (e.key === 'Enter') save(); if (e.key === 'Escape') { setEditValue(value ?? ''); setEditing(false) } }}
style={{ width: '100%', border: '1px solid #6366f1', borderRadius: 4, padding: '4px 6px', fontSize: 13, outline: 'none', background: 'var(--bg-input)', color: 'var(--text-primary)', fontFamily: 'inherit', ...style }}
placeholder={placeholder} />
}
const display = type === 'number' && value != null
? Number(value).toLocaleString(locale, { minimumFractionDigits: decimals, maximumFractionDigits: decimals })
: (value || '')
return (
<div onClick={() => { setEditValue(value ?? ''); setEditing(true) }} title={editTooltip}
style={{ cursor: 'pointer', padding: '4px 6px', borderRadius: 4, minHeight: 28, display: 'flex', alignItems: 'center',
justifyContent: style?.textAlign === 'center' ? 'center' : 'flex-start', transition: 'background 0.15s',
color: display ? 'var(--text-primary)' : 'var(--text-faint)', fontSize: 13, ...style }}
onMouseEnter={e => e.currentTarget.style.background = 'rgba(99,102,241,0.06)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
{display || placeholder || '-'}
</div>
)
}
// ── Add Item Row ─────────────────────────────────────────────────────────────
function AddItemRow({ onAdd, t }) {
const [name, setName] = useState('')
const [price, setPrice] = useState('')
const [persons, setPersons] = useState('')
const [days, setDays] = useState('')
const [note, setNote] = useState('')
const nameRef = useRef(null)
const handleAdd = () => {
if (!name.trim()) return
onAdd({ name: name.trim(), total_price: parseFloat(String(price).replace(',', '.')) || 0, persons: parseInt(persons) || null, days: parseInt(days) || null, note: note.trim() || null })
setName(''); setPrice(''); setPersons(''); setDays(''); setNote('')
setTimeout(() => nameRef.current?.focus(), 50)
}
const inp = { border: '1px solid var(--border-primary)', borderRadius: 4, padding: '4px 6px', fontSize: 13, outline: 'none', fontFamily: 'inherit', width: '100%', background: 'var(--bg-input)', color: 'var(--text-primary)' }
return (
<tr style={{ background: 'var(--bg-secondary)' }}>
<td style={{ padding: '4px 6px' }}>
<input ref={nameRef} value={name} onChange={e => setName(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()}
placeholder={t('budget.newEntry')} style={inp} />
</td>
<td style={{ padding: '4px 6px' }}>
<input value={price} onChange={e => setPrice(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()}
placeholder="0,00" inputMode="decimal" style={{ ...inp, textAlign: 'center' }} />
</td>
<td className="hidden sm:table-cell" style={{ padding: '4px 6px', textAlign: 'center' }}>
<input value={persons} onChange={e => setPersons(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()}
placeholder="-" inputMode="numeric" style={{ ...inp, textAlign: 'center', maxWidth: 50, margin: '0 auto' }} />
</td>
<td className="hidden sm:table-cell" style={{ padding: '4px 6px', textAlign: 'center' }}>
<input value={days} onChange={e => setDays(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()}
placeholder="-" inputMode="numeric" style={{ ...inp, textAlign: 'center', maxWidth: 50, margin: '0 auto' }} />
</td>
<td className="hidden md:table-cell" style={{ padding: '4px 6px', color: 'var(--text-faint)', fontSize: 12, textAlign: 'center' }}>-</td>
<td className="hidden md:table-cell" style={{ padding: '4px 6px', color: 'var(--text-faint)', fontSize: 12, textAlign: 'center' }}>-</td>
<td className="hidden lg:table-cell" style={{ padding: '4px 6px', color: 'var(--text-faint)', fontSize: 12, textAlign: 'center' }}>-</td>
<td className="hidden sm:table-cell" style={{ padding: '4px 6px' }}>
<input value={note} onChange={e => setNote(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()} placeholder={t('budget.table.note')} style={inp} />
</td>
<td style={{ padding: '4px 6px', textAlign: 'center' }}>
<button onClick={handleAdd} disabled={!name.trim()} title={t('reservations.add')}
style={{ background: name.trim() ? '#6366f1' : 'var(--border-primary)', border: 'none', borderRadius: 4, color: '#fff',
cursor: name.trim() ? 'pointer' : 'default', padding: '4px 8px', display: 'inline-flex', alignItems: 'center' }}>
<Plus size={14} />
</button>
</td>
</tr>
)
}
// ── Pie Chart (pure CSS conic-gradient) ──────────────────────────────────────
function PieChart({ segments, size = 200, totalLabel }) {
if (!segments.length) return null
const total = segments.reduce((s, x) => s + x.value, 0)
if (total === 0) return null
let cumDeg = 0
const stops = segments.map(seg => {
const start = cumDeg
const deg = (seg.value / total) * 360
cumDeg += deg
return `${seg.color} ${start}deg ${start + deg}deg`
}).join(', ')
return (
<div style={{ position: 'relative', width: size, height: size, margin: '0 auto' }}>
<div style={{
width: size, height: size, borderRadius: '50%',
background: `conic-gradient(${stops})`,
boxShadow: '0 4px 24px rgba(0,0,0,0.08)',
}} />
{/* Center hole */}
<div style={{
position: 'absolute', top: '50%', left: '50%',
transform: 'translate(-50%, -50%)',
width: size * 0.55, height: size * 0.55,
borderRadius: '50%', background: 'var(--bg-card)',
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
boxShadow: 'inset 0 0 12px rgba(0,0,0,0.04)',
}}>
<Wallet size={18} color="var(--text-faint)" style={{ marginBottom: 2 }} />
<span style={{ fontSize: 10, color: 'var(--text-faint)', fontWeight: 500 }}>{totalLabel}</span>
</div>
</div>
)
}
// ── Main Component ───────────────────────────────────────────────────────────
export default function BudgetPanel({ tripId }) {
const { trip, budgetItems, addBudgetItem, updateBudgetItem, deleteBudgetItem, loadBudgetItems, updateTrip } = useTripStore()
const { t, locale } = useTranslation()
const [newCategoryName, setNewCategoryName] = useState('')
const currency = trip?.currency || 'EUR'
const fmt = (v, cur) => fmtNum(v, locale, cur)
const setCurrency = (cur) => {
if (tripId) updateTrip(tripId, { currency: cur })
}
useEffect(() => { if (tripId) loadBudgetItems(tripId) }, [tripId])
const grouped = useMemo(() => (budgetItems || []).reduce((acc, item) => {
const cat = item.category || 'Sonstiges'
if (!acc[cat]) acc[cat] = []
acc[cat].push(item)
return acc
}, {}), [budgetItems])
const categoryNames = Object.keys(grouped)
const grandTotal = (budgetItems || []).reduce((s, i) => s + (i.total_price || 0), 0)
const pieSegments = useMemo(() =>
categoryNames.map((cat, i) => ({
name: cat,
value: grouped[cat].reduce((s, x) => s + (x.total_price || 0), 0),
color: PIE_COLORS[i % PIE_COLORS.length],
})).filter(s => s.value > 0)
, [grouped, categoryNames])
const handleAddItem = async (category, data) => { try { await addBudgetItem(tripId, { ...data, category }) } catch {} }
const handleUpdateField = async (id, field, value) => { try { await updateBudgetItem(tripId, id, { [field]: value }) } catch {} }
const handleDeleteItem = async (id) => { try { await deleteBudgetItem(tripId, id) } catch {} }
const handleDeleteCategory = async (cat) => {
const items = grouped[cat] || []
for (const item of items) await deleteBudgetItem(tripId, item.id)
}
const handleAddCategory = () => {
if (!newCategoryName.trim()) return
addBudgetItem(tripId, { name: t('budget.defaultEntry'), category: newCategoryName.trim(), total_price: 0 })
setNewCategoryName(''); setShowAddCategory(false)
}
const th = { padding: '6px 8px', textAlign: 'center', fontSize: 11, fontWeight: 600, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em', borderBottom: '2px solid var(--border-primary)', whiteSpace: 'nowrap', background: 'var(--bg-secondary)' }
const td = { padding: '2px 6px', borderBottom: '1px solid var(--border-secondary)', fontSize: 13, verticalAlign: 'middle', color: 'var(--text-primary)' }
// ── Empty State ──────────────────────────────────────────────────────────
if (!budgetItems || budgetItems.length === 0) {
return (
<div style={{ padding: 24, maxWidth: 600, margin: '60px auto', textAlign: 'center' }}>
<div style={{ width: 64, height: 64, borderRadius: 16, background: 'var(--bg-tertiary)', display: 'flex', alignItems: 'center', justifyContent: 'center', margin: '0 auto 20px' }}>
<Calculator size={28} color="#6b7280" />
</div>
<h2 style={{ fontSize: 20, fontWeight: 700, color: 'var(--text-primary)', margin: '0 0 8px' }}>{t('budget.emptyTitle')}</h2>
<p style={{ fontSize: 14, color: 'var(--text-muted)', margin: '0 0 24px', lineHeight: 1.5 }}>{t('budget.emptyText')}</p>
<div style={{ display: 'flex', gap: 8, justifyContent: 'center', alignItems: 'center' }}>
<input value={newCategoryName} onChange={e => setNewCategoryName(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleAddCategory()}
placeholder={t('budget.emptyPlaceholder')}
style={{ padding: '10px 16px', borderRadius: 10, border: '1px solid var(--border-primary)', fontSize: 14, fontFamily: 'inherit', width: 260, outline: 'none' }} />
<button onClick={handleAddCategory} disabled={!newCategoryName.trim()}
style={{ background: 'var(--accent)', color: 'var(--accent-text)', border: 'none', borderRadius: 10, padding: '10px 20px', fontSize: 14, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', display: 'inline-flex', alignItems: 'center', gap: 6, opacity: newCategoryName.trim() ? 1 : 0.5 }}>
<Plus size={16} /> {t('budget.createCategory')}
</button>
</div>
</div>
)
}
// ── Main Layout ──────────────────────────────────────────────────────────
return (
<div style={{ fontFamily: "'Poppins', -apple-system, BlinkMacSystemFont, system-ui, sans-serif" }}>
{/* Header */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '16px 16px 12px', flexWrap: 'wrap', gap: 8 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<Calculator size={20} color="var(--text-primary)" />
<h2 style={{ fontSize: 18, fontWeight: 700, color: 'var(--text-primary)', margin: 0 }}>{t('budget.title')}</h2>
</div>
</div>
{/* Main: table + sidebar */}
<div style={{ display: 'flex', gap: 20, padding: '0 16px 40px', alignItems: 'flex-start', flexWrap: 'wrap' }}>
{/* Left: Tables */}
<div style={{ flex: 1, minWidth: 0 }}>
{categoryNames.map((cat, ci) => {
const items = grouped[cat]
const subtotal = items.reduce((s, x) => s + (x.total_price || 0), 0)
const color = PIE_COLORS[ci % PIE_COLORS.length]
return (
<div key={cat} style={{ marginBottom: 16 }}>
{/* Category header */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', background: '#000000', color: '#fff', borderRadius: '10px 10px 0 0', padding: '9px 14px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div style={{ width: 10, height: 10, borderRadius: 3, background: color, flexShrink: 0 }} />
<span style={{ fontWeight: 600, fontSize: 13 }}>{cat}</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<span style={{ fontSize: 13, fontWeight: 500, opacity: 0.9 }}>{fmt(subtotal, currency)}</span>
<button onClick={() => handleDeleteCategory(cat)} title={t('budget.deleteCategory')}
style={{ background: 'rgba(255,255,255,0.1)', border: 'none', borderRadius: 4, color: '#fff', cursor: 'pointer', padding: '3px 6px', display: 'flex', alignItems: 'center', opacity: 0.6 }}
onMouseEnter={e => e.currentTarget.style.opacity = '1'} onMouseLeave={e => e.currentTarget.style.opacity = '0.6'}>
<Trash2 size={13} />
</button>
</div>
</div>
{/* Table */}
<div style={{ overflowX: 'auto', border: '1px solid var(--border-primary)', borderTop: 'none', borderRadius: '0 0 10px 10px' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr>
<th style={{ ...th, textAlign: 'left', minWidth: 100 }}>{t('budget.table.name')}</th>
<th style={{ ...th, minWidth: 80 }}>{t('budget.table.total')}</th>
<th className="hidden sm:table-cell" style={{ ...th, minWidth: 50 }}>{t('budget.table.persons')}</th>
<th className="hidden sm:table-cell" style={{ ...th, minWidth: 45 }}>{t('budget.table.days')}</th>
<th className="hidden md:table-cell" style={{ ...th, minWidth: 90 }}>{t('budget.table.perPerson')}</th>
<th className="hidden md:table-cell" style={{ ...th, minWidth: 80 }}>{t('budget.table.perDay')}</th>
<th className="hidden lg:table-cell" style={{ ...th, minWidth: 95 }}>{t('budget.table.perPersonDay')}</th>
<th className="hidden sm:table-cell" style={{ ...th, textAlign: 'left', minWidth: 80 }}>{t('budget.table.note')}</th>
<th style={{ ...th, width: 36 }}></th>
</tr>
</thead>
<tbody>
{items.map(item => {
const pp = calcPP(item.total_price, item.persons)
const pd = calcPD(item.total_price, item.days)
const ppd = calcPPD(item.total_price, item.persons, item.days)
return (
<tr key={item.id} style={{ transition: 'background 0.1s' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
<td style={td}><InlineEditCell value={item.name} onSave={v => handleUpdateField(item.id, 'name', v)} placeholder={t('budget.table.name')} locale={locale} editTooltip={t('budget.editTooltip')} /></td>
<td style={{ ...td, textAlign: 'center' }}>
<InlineEditCell value={item.total_price} type="number" onSave={v => handleUpdateField(item.id, 'total_price', v)} style={{ textAlign: 'center' }} placeholder="0,00" locale={locale} editTooltip={t('budget.editTooltip')} />
</td>
<td className="hidden sm:table-cell" style={{ ...td, textAlign: 'center' }}>
<InlineEditCell value={item.persons} type="number" decimals={0} onSave={v => handleUpdateField(item.id, 'persons', v != null ? parseInt(v) || null : null)} style={{ textAlign: 'center' }} placeholder="-" locale={locale} editTooltip={t('budget.editTooltip')} />
</td>
<td className="hidden sm:table-cell" style={{ ...td, textAlign: 'center' }}>
<InlineEditCell value={item.days} type="number" decimals={0} onSave={v => handleUpdateField(item.id, 'days', v != null ? parseInt(v) || null : null)} style={{ textAlign: 'center' }} placeholder="-" locale={locale} editTooltip={t('budget.editTooltip')} />
</td>
<td className="hidden md:table-cell" style={{ ...td, textAlign: 'center', color: pp != null ? 'var(--text-secondary)' : 'var(--text-faint)' }}>{pp != null ? fmt(pp, currency) : '-'}</td>
<td className="hidden md:table-cell" style={{ ...td, textAlign: 'center', color: pd != null ? 'var(--text-secondary)' : 'var(--text-faint)' }}>{pd != null ? fmt(pd, currency) : '-'}</td>
<td className="hidden lg:table-cell" style={{ ...td, textAlign: 'center', color: ppd != null ? 'var(--text-secondary)' : 'var(--text-faint)' }}>{ppd != null ? fmt(ppd, currency) : '-'}</td>
<td className="hidden sm:table-cell" style={td}><InlineEditCell value={item.note} onSave={v => handleUpdateField(item.id, 'note', v)} placeholder={t('budget.table.note')} locale={locale} editTooltip={t('budget.editTooltip')} /></td>
<td style={{ ...td, textAlign: 'center' }}>
<button onClick={() => handleDeleteItem(item.id)} title={t('common.delete')}
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 4, color: 'var(--text-faint)', borderRadius: 4, display: 'inline-flex', transition: 'color 0.15s' }}
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'} onMouseLeave={e => e.currentTarget.style.color = '#d1d5db'}>
<Trash2 size={14} />
</button>
</td>
</tr>
)
})}
<AddItemRow onAdd={data => handleAddItem(cat, data)} t={t} />
</tbody>
</table>
</div>
</div>
)
})}
</div>
{/* Right: Sidebar */}
<div className="w-full md:w-[280px]" style={{ flexShrink: 0, position: 'sticky', top: 16, alignSelf: 'flex-start' }}>
{/* Currency selector */}
<div style={{ marginBottom: 12 }}>
<CustomSelect
value={currency}
onChange={setCurrency}
options={CURRENCIES.map(c => ({ value: c, label: `${c} (${SYMBOLS[c] || c})` }))}
searchable
/>
</div>
{/* Add category */}
<div style={{ display: 'flex', gap: 6, marginBottom: 12 }}>
<input
value={newCategoryName}
onChange={e => setNewCategoryName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') handleAddCategory() }}
placeholder={t('budget.categoryName')}
style={{ flex: 1, border: '1px solid var(--border-primary)', borderRadius: 10, padding: '9px 14px', fontSize: 13, outline: 'none', fontFamily: 'inherit', background: 'var(--bg-input)', color: 'var(--text-primary)' }}
/>
<button onClick={handleAddCategory} disabled={!newCategoryName.trim()}
style={{ background: 'var(--accent)', color: 'var(--accent-text)', border: 'none', borderRadius: 10, padding: '9px 12px', cursor: 'pointer', display: 'flex', alignItems: 'center', opacity: newCategoryName.trim() ? 1 : 0.4, flexShrink: 0 }}>
<Plus size={16} />
</button>
</div>
{/* Grand total card */}
<div style={{
background: 'linear-gradient(135deg, #000000 0%, #18181b 100%)',
borderRadius: 16, padding: '24px 20px', color: '#fff', marginBottom: 16,
boxShadow: '0 8px 32px rgba(15,23,42,0.18)',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 16 }}>
<div style={{ width: 36, height: 36, borderRadius: 10, background: 'rgba(255,255,255,0.1)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Wallet size={18} color="rgba(255,255,255,0.8)" />
</div>
<div>
<div style={{ fontSize: 11, color: 'rgba(255,255,255,0.5)', fontWeight: 500, letterSpacing: 0.5 }}>{t('budget.totalBudget')}</div>
</div>
</div>
<div style={{ fontSize: 28, fontWeight: 700, lineHeight: 1, marginBottom: 4 }}>
{Number(grandTotal).toLocaleString(locale, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</div>
<div style={{ fontSize: 14, color: 'rgba(255,255,255,0.5)', fontWeight: 500 }}>{SYMBOLS[currency] || currency} {currency}</div>
</div>
{/* Pie chart card */}
{pieSegments.length > 0 && (
<div style={{
background: 'var(--bg-card)', borderRadius: 16, padding: '20px 16px',
border: '1px solid var(--border-primary)',
boxShadow: '0 2px 12px rgba(0,0,0,0.04)',
}}>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', marginBottom: 16, textAlign: 'center' }}>{t('budget.byCategory')}</div>
<PieChart segments={pieSegments} size={180} totalLabel={t('budget.total')} />
{/* Legend */}
<div style={{ marginTop: 20, display: 'flex', flexDirection: 'column', gap: 8 }}>
{pieSegments.map(seg => {
const pct = grandTotal > 0 ? ((seg.value / grandTotal) * 100).toFixed(1) : '0.0'
return (
<div key={seg.name} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div style={{ width: 10, height: 10, borderRadius: 3, background: seg.color, flexShrink: 0 }} />
<span style={{ flex: 1, fontSize: 12, color: 'var(--text-secondary)', fontWeight: 500 }}>{seg.name}</span>
<span style={{ fontSize: 12, color: 'var(--text-muted)', fontWeight: 600, whiteSpace: 'nowrap' }}>{pct}%</span>
</div>
)
})}
</div>
{/* Category amounts */}
<div style={{ marginTop: 12, borderTop: '1px solid var(--border-secondary)', paddingTop: 12, display: 'flex', flexDirection: 'column', gap: 6 }}>
{pieSegments.map(seg => (
<div key={seg.name} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ fontSize: 11, color: 'var(--text-faint)' }}>{seg.name}</span>
<span style={{ fontSize: 12, color: 'var(--text-secondary)', fontWeight: 600 }}>{fmt(seg.value, currency)}</span>
</div>
))}
</div>
</div>
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,193 @@
import React, { useState, useEffect, useMemo, useRef } from 'react'
import { Globe, MapPin, Plane } from 'lucide-react'
import { authApi } from '../../api/client'
import { useTranslation } from '../../i18n'
import { useSettingsStore } from '../../store/settingsStore'
// Numeric ISO → country name lookup (countries-110m uses numeric IDs)
const NUMERIC_TO_NAME = {"004":"Afghanistan","008":"Albania","012":"Algeria","024":"Angola","032":"Argentina","036":"Australia","040":"Austria","050":"Bangladesh","056":"Belgium","064":"Bhutan","068":"Bolivia","070":"Bosnia and Herzegovina","072":"Botswana","076":"Brazil","100":"Bulgaria","104":"Myanmar","108":"Burundi","112":"Belarus","116":"Cambodia","120":"Cameroon","124":"Canada","140":"Central African Republic","144":"Sri Lanka","148":"Chad","152":"Chile","156":"China","170":"Colombia","178":"Congo","180":"Democratic Republic of the Congo","188":"Costa Rica","191":"Croatia","192":"Cuba","196":"Cyprus","203":"Czech Republic","204":"Benin","208":"Denmark","214":"Dominican Republic","218":"Ecuador","818":"Egypt","222":"El Salvador","226":"Equatorial Guinea","232":"Eritrea","233":"Estonia","231":"Ethiopia","238":"Falkland Islands","246":"Finland","250":"France","266":"Gabon","270":"Gambia","268":"Georgia","276":"Germany","288":"Ghana","300":"Greece","320":"Guatemala","324":"Guinea","328":"Guyana","332":"Haiti","340":"Honduras","348":"Hungary","352":"Iceland","356":"India","360":"Indonesia","364":"Iran","368":"Iraq","372":"Ireland","376":"Israel","380":"Italy","384":"Ivory Coast","388":"Jamaica","392":"Japan","400":"Jordan","398":"Kazakhstan","404":"Kenya","408":"North Korea","410":"South Korea","414":"Kuwait","417":"Kyrgyzstan","418":"Laos","422":"Lebanon","426":"Lesotho","430":"Liberia","434":"Libya","440":"Lithuania","442":"Luxembourg","450":"Madagascar","454":"Malawi","458":"Malaysia","466":"Mali","478":"Mauritania","484":"Mexico","496":"Mongolia","498":"Moldova","504":"Morocco","508":"Mozambique","516":"Namibia","524":"Nepal","528":"Netherlands","540":"New Caledonia","554":"New Zealand","558":"Nicaragua","562":"Niger","566":"Nigeria","578":"Norway","512":"Oman","586":"Pakistan","591":"Panama","598":"Papua New Guinea","600":"Paraguay","604":"Peru","608":"Philippines","616":"Poland","620":"Portugal","630":"Puerto Rico","634":"Qatar","642":"Romania","643":"Russia","646":"Rwanda","682":"Saudi Arabia","686":"Senegal","688":"Serbia","694":"Sierra Leone","703":"Slovakia","705":"Slovenia","706":"Somalia","710":"South Africa","724":"Spain","144":"Sri Lanka","729":"Sudan","740":"Suriname","748":"Swaziland","752":"Sweden","756":"Switzerland","760":"Syria","762":"Tajikistan","764":"Thailand","768":"Togo","780":"Trinidad and Tobago","788":"Tunisia","792":"Turkey","795":"Turkmenistan","800":"Uganda","804":"Ukraine","784":"United Arab Emirates","826":"United Kingdom","840":"United States of America","858":"Uruguay","860":"Uzbekistan","862":"Venezuela","704":"Vietnam","887":"Yemen","894":"Zambia","716":"Zimbabwe"}
// Our country names from addresses → match against GeoJSON names
function isCountryMatch(geoName, visitedCountries) {
if (!geoName) return false
const lower = geoName.toLowerCase()
return visitedCountries.some(c => {
const cl = c.toLowerCase()
return lower === cl || lower.includes(cl) || cl.includes(lower)
// Handle common mismatches
|| (cl === 'usa' && lower.includes('united states'))
|| (cl === 'uk' && lower === 'united kingdom')
|| (cl === 'south korea' && lower === 'korea' || lower === 'south korea')
|| (cl === 'deutschland' && lower === 'germany')
|| (cl === 'frankreich' && lower === 'france')
|| (cl === 'italien' && lower === 'italy')
|| (cl === 'spanien' && lower === 'spain')
|| (cl === 'österreich' && lower === 'austria')
|| (cl === 'schweiz' && lower === 'switzerland')
|| (cl === 'niederlande' && lower === 'netherlands')
|| (cl === 'türkei' && (lower === 'turkey' || lower === 'türkiye'))
|| (cl === 'griechenland' && lower === 'greece')
|| (cl === 'tschechien' && (lower === 'czech republic' || lower === 'czechia'))
|| (cl === 'ägypten' && lower === 'egypt')
|| (cl === 'südkorea' && lower.includes('korea'))
|| (cl === 'indien' && lower === 'india')
|| (cl === 'brasilien' && lower === 'brazil')
|| (cl === 'argentinien' && lower === 'argentina')
|| (cl === 'russland' && lower === 'russia')
|| (cl === 'australien' && lower === 'australia')
|| (cl === 'kanada' && lower === 'canada')
|| (cl === 'mexiko' && lower === 'mexico')
|| (cl === 'neuseeland' && lower === 'new zealand')
|| (cl === 'singapur' && lower === 'singapore')
|| (cl === 'kroatien' && lower === 'croatia')
|| (cl === 'ungarn' && lower === 'hungary')
|| (cl === 'rumänien' && lower === 'romania')
|| (cl === 'polen' && lower === 'poland')
|| (cl === 'schweden' && lower === 'sweden')
|| (cl === 'norwegen' && lower === 'norway')
|| (cl === 'dänemark' && lower === 'denmark')
|| (cl === 'finnland' && lower === 'finland')
|| (cl === 'irland' && lower === 'ireland')
|| (cl === 'portugal' && lower === 'portugal')
|| (cl === 'belgien' && lower === 'belgium')
})
}
const TOTAL_COUNTRIES = 195
// Simple Mercator projection for SVG
function project(lon, lat, width, height) {
const clampedLat = Math.max(-75, Math.min(83, lat))
const x = ((lon + 180) / 360) * width
const latRad = (clampedLat * Math.PI) / 180
const mercN = Math.log(Math.tan(Math.PI / 4 + latRad / 2))
const y = (height / 2) - (width * mercN) / (2 * Math.PI)
return [x, y]
}
function geoToPath(coords, width, height) {
return coords.map((ring) => {
// Split ring at dateline crossings to avoid horizontal stripes
const segments = [[]]
for (let i = 0; i < ring.length; i++) {
const [lon, lat] = ring[i]
if (i > 0) {
const prevLon = ring[i - 1][0]
if (Math.abs(lon - prevLon) > 180) {
// Dateline crossing — start new segment
segments.push([])
}
}
const [x, y] = project(lon, Math.max(-75, Math.min(83, lat)), width, height)
segments[segments.length - 1].push(`${x.toFixed(1)},${y.toFixed(1)}`)
}
return segments
.filter(s => s.length > 2)
.map(s => 'M' + s.join('L') + 'Z')
.join(' ')
}).join(' ')
}
let geoJsonCache = null
async function loadGeoJson() {
if (geoJsonCache) return geoJsonCache
try {
const res = await fetch('https://cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json')
const topo = await res.json()
const { feature } = await import('topojson-client')
const geo = feature(topo, topo.objects.countries)
geo.features.forEach(f => {
f.properties.name = NUMERIC_TO_NAME[f.id] || f.properties?.name || ''
})
geoJsonCache = geo
return geo
} catch { return null }
}
export default function TravelStats() {
const { t } = useTranslation()
const dark = useSettingsStore(s => s.settings.dark_mode)
const [stats, setStats] = useState(null)
const [geoData, setGeoData] = useState(null)
useEffect(() => {
authApi.travelStats().then(setStats).catch(() => {})
loadGeoJson().then(setGeoData)
}, [])
const countryCount = stats?.countries?.length || 0
const worldPercent = ((countryCount / TOTAL_COUNTRIES) * 100).toFixed(1)
if (!stats || stats.totalPlaces === 0) return null
return (
<div style={{ width: 340 }}>
{/* Stats Card */}
<div style={{
borderRadius: 20, overflow: 'hidden', height: 300,
display: 'flex', flexDirection: 'column', justifyContent: 'center',
border: '1px solid var(--border-primary)',
background: 'var(--bg-card)',
padding: 16,
}}>
{/* Progress bar */}
<div style={{ marginBottom: 14 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 6 }}>
<span style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-secondary)' }}>{t('stats.worldProgress')}</span>
<span style={{ fontSize: 20, fontWeight: 800, color: 'var(--text-primary)' }}>{worldPercent}%</span>
</div>
<div style={{ height: 6, borderRadius: 99, background: 'var(--bg-hover)', overflow: 'hidden' }}>
<div style={{
height: '100%', borderRadius: 99,
background: dark ? 'linear-gradient(90deg, #e2e8f0, #cbd5e1)' : 'linear-gradient(90deg, #111827, #374151)',
width: `${Math.max(1, parseFloat(worldPercent))}%`,
transition: 'width 0.5s ease',
}} />
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', marginTop: 4 }}>
<span style={{ fontSize: 10, color: 'var(--text-faint)' }}>{countryCount} {t('stats.visited')}</span>
<span style={{ fontSize: 10, color: 'var(--text-faint)' }}>{TOTAL_COUNTRIES - countryCount} {t('stats.remaining')}</span>
</div>
</div>
{/* Stat grid */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8, marginBottom: 14 }}>
<StatBox icon={Globe} value={countryCount} label={t('stats.countries')} />
<StatBox icon={MapPin} value={stats.cities.length} label={t('stats.cities')} />
<StatBox icon={Plane} value={stats.totalTrips} label={t('stats.trips')} />
<StatBox icon={MapPin} value={stats.totalPlaces} label={t('stats.places')} />
</div>
{/* Country tags */}
{stats.countries.length > 0 && (
<>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-muted)', marginBottom: 6 }}>{t('stats.visitedCountries')}</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
{stats.countries.map(c => (
<span key={c} style={{
fontSize: 10.5, fontWeight: 500, color: 'var(--text-secondary)',
background: 'var(--bg-hover)', borderRadius: 99, padding: '3px 9px',
}}>{c}</span>
))}
</div>
</>
)}
</div>
</div>
)
}
function StatBox({ icon: Icon, value, label }) {
return (
<div style={{
display: 'flex', alignItems: 'center', gap: 8, padding: '8px 10px',
borderRadius: 10, background: 'var(--bg-hover)',
}}>
<Icon size={14} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
<div>
<div style={{ fontSize: 15, fontWeight: 700, color: 'var(--text-primary)', lineHeight: 1 }}>{value}</div>
<div style={{ fontSize: 10, color: 'var(--text-faint)', marginTop: 1 }}>{label}</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,331 @@
import React, { useState, useCallback } from 'react'
import { useDropzone } from 'react-dropzone'
import { Upload, Trash2, ExternalLink, X, FileText, FileImage, File, MapPin, Ticket } from 'lucide-react'
import { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n'
function isImage(mimeType) {
if (!mimeType) return false
return mimeType.startsWith('image/') // covers jpg, png, gif, webp, etc.
}
function getFileIcon(mimeType) {
if (!mimeType) return File
if (mimeType === 'application/pdf') return FileText
if (isImage(mimeType)) return FileImage
return File
}
function formatSize(bytes) {
if (!bytes) return ''
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
}
function formatDateWithLocale(dateStr, locale) {
if (!dateStr) return ''
try {
return new Date(dateStr).toLocaleDateString(locale, { day: '2-digit', month: '2-digit', year: 'numeric' })
} catch { return '' }
}
// Image lightbox
function ImageLightbox({ file, onClose }) {
const { t } = useTranslation()
return (
<div
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.88)', zIndex: 2000, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
onClick={onClose}
>
<div style={{ position: 'relative', maxWidth: '90vw', maxHeight: '90vh' }} onClick={e => e.stopPropagation()}>
<img
src={file.url}
alt={file.original_name}
style={{ maxWidth: '90vw', maxHeight: '90vh', objectFit: 'contain', borderRadius: 8, display: 'block' }}
/>
<div style={{ position: 'absolute', top: -40, left: 0, right: 0, display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '0 4px' }}>
<span style={{ fontSize: 12, color: 'rgba(255,255,255,0.7)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '80%' }}>{file.original_name}</span>
<div style={{ display: 'flex', gap: 8 }}>
<a href={file.url} target="_blank" rel="noreferrer" style={{ color: 'rgba(255,255,255,0.7)', display: 'flex' }} title={t('files.openTab')}>
<ExternalLink size={16} />
</a>
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.7)', display: 'flex', padding: 0 }}>
<X size={18} />
</button>
</div>
</div>
</div>
</div>
)
}
// Source badge — unified style for both place and reservation
function SourceBadge({ icon: Icon, label }) {
return (
<span style={{
display: 'inline-flex', alignItems: 'center', gap: 4,
fontSize: 10.5, color: '#4b5563',
background: 'var(--bg-tertiary)', border: '1px solid var(--border-primary)',
borderRadius: 6, padding: '2px 7px',
fontWeight: 500, whiteSpace: 'nowrap',
}}>
<Icon size={10} style={{ flexShrink: 0, color: 'var(--text-muted)' }} />
{label}
</span>
)
}
export default function FileManager({ files = [], onUpload, onDelete, onUpdate, places, reservations = [], tripId }) {
const [uploading, setUploading] = useState(false)
const [filterType, setFilterType] = useState('all')
const [lightboxFile, setLightboxFile] = useState(null)
const toast = useToast()
const { t, locale } = useTranslation()
const onDrop = useCallback(async (acceptedFiles) => {
if (acceptedFiles.length === 0) return
setUploading(true)
try {
for (const file of acceptedFiles) {
const formData = new FormData()
formData.append('file', file)
await onUpload(formData)
}
toast.success(t('files.uploaded', { count: acceptedFiles.length }))
} catch {
toast.error(t('files.uploadError'))
} finally {
setUploading(false)
}
}, [onUpload, toast, t])
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
maxSize: 50 * 1024 * 1024,
noClick: false,
})
const filteredFiles = files.filter(f => {
if (filterType === 'pdf') return f.mime_type === 'application/pdf'
if (filterType === 'image') return isImage(f.mime_type)
if (filterType === 'doc') return (f.mime_type || '').includes('word') || (f.mime_type || '').includes('excel') || (f.mime_type || '').includes('text')
return true
})
const handleDelete = async (id) => {
if (!confirm(t('files.confirm.delete'))) return
try {
await onDelete(id)
toast.success(t('files.toast.deleted'))
} catch {
toast.error(t('files.toast.deleteError'))
}
}
const [previewFile, setPreviewFile] = useState(null)
const openFile = (file) => {
if (isImage(file.mime_type)) {
setLightboxFile(file)
} else {
setPreviewFile(file)
}
}
return (
<div className="flex flex-col h-full" style={{ fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }}>
{/* Lightbox */}
{lightboxFile && <ImageLightbox file={lightboxFile} onClose={() => setLightboxFile(null)} />}
{/* Datei-Vorschau Modal */}
{previewFile && (
<div
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.7)', zIndex: 2000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 8 }}
onClick={() => setPreviewFile(null)}
>
<div
style={{ width: '100%', maxWidth: 950, height: '95vh', background: 'var(--bg-card)', borderRadius: 12, overflow: 'hidden', display: 'flex', flexDirection: 'column', boxShadow: '0 20px 60px rgba(0,0,0,0.3)' }}
onClick={e => e.stopPropagation()}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 16px', borderBottom: '1px solid var(--border-primary)', flexShrink: 0 }}>
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>{previewFile.original_name}</span>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexShrink: 0 }}>
<a href={previewFile.url || `/uploads/files/${previewFile.filename}`} target="_blank" rel="noreferrer"
style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 12, color: 'var(--text-muted)', textDecoration: 'none', padding: '4px 8px', borderRadius: 6, transition: 'color 0.15s' }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-muted)'}>
<ExternalLink size={13} /> {t('files.openTab')}
</a>
<button onClick={() => setPreviewFile(null)}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', padding: 4, borderRadius: 6, transition: 'color 0.15s' }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<X size={18} />
</button>
</div>
</div>
<iframe
src={`${previewFile.url || `/uploads/files/${previewFile.filename}`}#view=FitH`}
style={{ flex: 1, width: '100%', border: 'none' }}
title={previewFile.original_name}
/>
</div>
</div>
)}
{/* Header */}
<div style={{ padding: '20px 24px 16px', borderBottom: '1px solid rgba(0,0,0,0.06)', display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexShrink: 0 }}>
<div>
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 700, color: 'var(--text-primary)' }}>{t('files.title')}</h2>
<p style={{ margin: '2px 0 0', fontSize: 12.5, color: 'var(--text-faint)' }}>
{files.length === 1 ? t('files.countSingular') : t('files.count', { count: files.length })}
</p>
</div>
</div>
{/* Upload zone */}
<div
{...getRootProps()}
style={{
margin: '16px 16px 0', border: '2px dashed', borderRadius: 14, padding: '20px 16px',
textAlign: 'center', cursor: 'pointer', transition: 'all 0.15s',
borderColor: isDragActive ? 'var(--text-secondary)' : 'var(--border-primary)',
background: isDragActive ? 'var(--bg-secondary)' : 'var(--bg-card)',
}}
>
<input {...getInputProps()} />
<Upload size={24} style={{ margin: '0 auto 8px', color: isDragActive ? 'var(--text-secondary)' : 'var(--text-faint)', display: 'block' }} />
{uploading ? (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6, fontSize: 13, color: 'var(--text-secondary)' }}>
<div style={{ width: 14, height: 14, border: '2px solid var(--text-secondary)', borderTopColor: 'transparent', borderRadius: '50%', animation: 'spin 0.8s linear infinite' }} />
{t('files.uploading')}
</div>
) : (
<>
<p style={{ fontSize: 13, color: 'var(--text-secondary)', fontWeight: 500, margin: 0 }}>{t('files.dropzone')}</p>
<p style={{ fontSize: 11.5, color: 'var(--text-faint)', marginTop: 3 }}>{t('files.dropzoneHint')}</p>
</>
)}
</div>
{/* Filter tabs */}
<div style={{ display: 'flex', gap: 4, padding: '12px 16px 0', flexShrink: 0 }}>
{[
{ id: 'all', label: t('files.filterAll') },
{ id: 'pdf', label: t('files.filterPdf') },
{ id: 'image', label: t('files.filterImages') },
{ id: 'doc', label: t('files.filterDocs') },
].map(tab => (
<button key={tab.id} onClick={() => setFilterType(tab.id)} style={{
padding: '4px 12px', borderRadius: 99, border: 'none', cursor: 'pointer', fontSize: 12,
fontFamily: 'inherit', transition: 'all 0.12s',
background: filterType === tab.id ? 'var(--accent)' : 'transparent',
color: filterType === tab.id ? 'var(--accent-text)' : 'var(--text-muted)',
fontWeight: filterType === tab.id ? 600 : 400,
}}>{tab.label}</button>
))}
<span style={{ marginLeft: 'auto', fontSize: 11.5, color: 'var(--text-faint)', alignSelf: 'center' }}>
{filteredFiles.length === 1 ? t('files.countSingular') : t('files.count', { count: filteredFiles.length })}
</span>
</div>
{/* File list */}
<div style={{ flex: 1, overflowY: 'auto', padding: '12px 16px 16px' }}>
{filteredFiles.length === 0 ? (
<div style={{ textAlign: 'center', padding: '60px 20px', color: 'var(--text-faint)' }}>
<FileText size={40} style={{ color: 'var(--text-faint)', display: 'block', margin: '0 auto 12px' }} />
<p style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-secondary)', margin: '0 0 4px' }}>{t('files.empty')}</p>
<p style={{ fontSize: 13, color: 'var(--text-faint)', margin: 0 }}>{t('files.emptyHint')}</p>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{filteredFiles.map(file => {
const FileIcon = getFileIcon(file.mime_type)
const linkedPlace = places?.find(p => p.id === file.place_id)
const linkedReservation = file.reservation_id
? (reservations?.find(r => r.id === file.reservation_id) || { title: file.reservation_title })
: null
const fileUrl = file.url || `/uploads/files/${file.filename}`
return (
<div key={file.id} style={{
background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 12,
padding: '10px 12px', display: 'flex', alignItems: 'flex-start', gap: 10,
transition: 'border-color 0.12s',
}}
onMouseEnter={e => e.currentTarget.style.borderColor = 'var(--text-faint)'}
onMouseLeave={e => e.currentTarget.style.borderColor = 'var(--border-primary)'}
className="group"
>
{/* Icon or thumbnail */}
<div
onClick={() => openFile({ ...file, url: fileUrl })}
style={{
flexShrink: 0, width: 36, height: 36, borderRadius: 8,
background: 'var(--bg-tertiary)', display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer', overflow: 'hidden',
}}
>
{isImage(file.mime_type)
? <img src={fileUrl} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
: <FileIcon size={16} style={{ color: 'var(--text-muted)' }} />
}
</div>
{/* Info */}
<div style={{ flex: 1, minWidth: 0 }}>
<div
onClick={() => openFile({ ...file, url: fileUrl })}
style={{ fontWeight: 500, fontSize: 13, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', cursor: 'pointer' }}
>
{file.original_name}
</div>
<div style={{ display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: 6, marginTop: 4 }}>
{file.file_size && <span style={{ fontSize: 11, color: 'var(--text-faint)' }}>{formatSize(file.file_size)}</span>}
<span style={{ fontSize: 11, color: 'var(--text-faint)' }}>{formatDateWithLocale(file.created_at, locale)}</span>
{linkedPlace && (
<SourceBadge
icon={MapPin}
label={`${t('files.sourcePlan')} · ${linkedPlace.name}`}
/>
)}
{linkedReservation && (
<SourceBadge
icon={Ticket}
label={`${t('files.sourceBooking')} · ${linkedReservation.title || t('files.sourceBooking')}`}
/>
)}
</div>
{file.description && !linkedReservation && (
<p style={{ fontSize: 11.5, color: 'var(--text-faint)', marginTop: 3, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{file.description}</p>
)}
</div>
{/* Actions */}
<div style={{ display: 'flex', gap: 2, flexShrink: 0, opacity: 0, transition: 'opacity 0.12s' }} className="file-actions">
<button onClick={() => openFile({ ...file, url: fileUrl })} title={t('common.open')} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<ExternalLink size={14} />
</button>
<button onClick={() => handleDelete(file.id)} title={t('common.delete')} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'} onMouseLeave={e => e.currentTarget.style.color = '#9ca3af'}>
<Trash2 size={14} />
</button>
</div>
</div>
)
})}
</div>
)}
</div>
<style>{`
div:hover > .file-actions { opacity: 1 !important; }
`}</style>
</div>
)
}

View File

@@ -0,0 +1,157 @@
import React, { useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { useAuthStore } from '../../store/authStore'
import { useSettingsStore } from '../../store/settingsStore'
import { useTranslation } from '../../i18n'
import { Plane, LogOut, Settings, ChevronDown, Shield, ArrowLeft, Users, Moon, Sun } from 'lucide-react'
export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }) {
const { user, logout } = useAuthStore()
const { settings, updateSetting } = useSettingsStore()
const { t, locale } = useTranslation()
const navigate = useNavigate()
const [userMenuOpen, setUserMenuOpen] = useState(false)
const dark = settings.dark_mode
const handleLogout = () => {
logout()
navigate('/login')
}
const toggleDark = () => {
updateSetting('dark_mode', !dark).catch(() => {})
}
return (
<nav style={{
background: dark ? 'rgba(9,9,11,0.95)' : 'rgba(255,255,255,0.95)',
backdropFilter: 'blur(20px)', WebkitBackdropFilter: 'blur(20px)',
borderBottom: `1px solid ${dark ? 'rgba(255,255,255,0.07)' : 'rgba(0,0,0,0.07)'}`,
boxShadow: dark ? '0 1px 12px rgba(0,0,0,0.2)' : '0 1px 12px rgba(0,0,0,0.05)',
}} className="h-14 flex items-center px-4 gap-4 fixed top-0 left-0 right-0 z-[200]">
{/* Left side */}
<div className="flex items-center gap-3 min-w-0">
{showBack && (
<button onClick={onBack}
className="p-1.5 rounded-lg transition-colors flex items-center gap-1.5 text-sm flex-shrink-0"
style={{ color: 'var(--text-muted)' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
<ArrowLeft className="w-4 h-4" />
<span className="hidden sm:inline">{t('common.back')}</span>
</button>
)}
<Link to="/dashboard" className="flex items-center gap-2 transition-colors flex-shrink-0"
style={{ color: 'var(--text-primary)' }}>
<Plane className="w-5 h-5" style={{ color: 'var(--text-primary)' }} />
<span className="font-bold text-sm hidden sm:inline">{t('nav.trip')}</span>
</Link>
{tripTitle && (
<>
<span className="hidden sm:inline" style={{ color: 'var(--text-faint)' }}>/</span>
<span className="text-sm font-medium truncate max-w-48" style={{ color: 'var(--text-muted)' }}>
{tripTitle}
</span>
</>
)}
</div>
{/* Spacer */}
<div className="flex-1" />
{/* Share button */}
{onShare && (
<button onClick={onShare}
className="flex items-center gap-1.5 py-1.5 px-3 rounded-lg border transition-colors text-sm font-medium flex-shrink-0"
style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)', background: 'var(--bg-card)' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-card)'}>
<Users className="w-4 h-4" />
<span className="hidden sm:inline">{t('nav.share')}</span>
</button>
)}
{/* Dark mode toggle */}
<button onClick={toggleDark} title={dark ? t('nav.lightMode') : t('nav.darkMode')}
className="p-2 rounded-lg transition-colors flex-shrink-0"
style={{ color: 'var(--text-muted)' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
{dark ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />}
</button>
{/* User menu */}
{user && (
<div className="relative">
<button onClick={() => setUserMenuOpen(!userMenuOpen)}
className="flex items-center gap-2 py-1.5 px-3 rounded-lg transition-colors"
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
{user.avatar_url ? (
<img src={user.avatar_url} alt="" style={{ width: 28, height: 28, borderRadius: '50%', objectFit: 'cover' }} />
) : (
<div className="w-7 h-7 rounded-full flex items-center justify-center text-xs font-bold"
style={{ background: dark ? '#e2e8f0' : '#111827', color: dark ? '#0f172a' : '#ffffff' }}>
{user.username?.charAt(0).toUpperCase()}
</div>
)}
<span className="text-sm hidden sm:inline max-w-24 truncate" style={{ color: 'var(--text-secondary)' }}>
{user.username}
</span>
<ChevronDown className="w-4 h-4" style={{ color: 'var(--text-faint)' }} />
</button>
{userMenuOpen && (
<>
<div className="fixed inset-0 z-10" onClick={() => setUserMenuOpen(false)} />
<div className="absolute right-0 top-full mt-2 w-52 rounded-xl shadow-xl border z-20 overflow-hidden"
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
<div className="px-4 py-3 border-b" style={{ borderColor: 'var(--border-secondary)' }}>
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>{user.username}</p>
<p className="text-xs truncate" style={{ color: 'var(--text-muted)' }}>{user.email}</p>
{user.role === 'admin' && (
<span className="inline-flex items-center gap-1 text-xs font-medium mt-1" style={{ color: 'var(--text-secondary)' }}>
<Shield className="w-3 h-3" /> {t('nav.administrator')}
</span>
)}
</div>
<div className="py-1">
<Link to="/settings" onClick={() => setUserMenuOpen(false)}
className="flex items-center gap-2 px-4 py-2 text-sm transition-colors"
style={{ color: 'var(--text-secondary)' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
<Settings className="w-4 h-4" />
{t('nav.settings')}
</Link>
{user.role === 'admin' && (
<Link to="/admin" onClick={() => setUserMenuOpen(false)}
className="flex items-center gap-2 px-4 py-2 text-sm transition-colors"
style={{ color: 'var(--text-secondary)' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
<Shield className="w-4 h-4" />
{t('nav.admin')}
</Link>
)}
</div>
<div className="py-1 border-t" style={{ borderColor: 'var(--border-secondary)' }}>
<button onClick={handleLogout}
className="flex items-center gap-2 w-full px-4 py-2 text-sm text-red-500 hover:bg-red-500/10 transition-colors">
<LogOut className="w-4 h-4" />
{t('nav.logout')}
</button>
</div>
</div>
</>
)}
</div>
)}
</nav>
)
}

View File

@@ -0,0 +1,258 @@
import React, { useEffect, useRef, useState } from 'react'
import { MapContainer, TileLayer, Marker, Tooltip, Polyline, useMap } from 'react-leaflet'
import L from 'leaflet'
import { mapsApi } from '../../api/client'
import { getCategoryIcon } from '../shared/categoryIcons'
// Fix default marker icons for vite
delete L.Icon.Default.prototype._getIconUrl
L.Icon.Default.mergeOptions({
iconRetinaUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-icon-2x.png',
iconUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-icon.png',
shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-shadow.png',
})
/**
* Create a round photo-circle marker.
* Shows image_url if available, otherwise category icon in colored circle.
*/
function createPlaceIcon(place, orderNumber, isSelected) {
const size = isSelected ? 44 : 36
const borderColor = isSelected ? '#111827' : 'white'
const borderWidth = isSelected ? 3 : 2.5
const shadow = isSelected
? '0 0 0 3px rgba(17,24,39,0.25), 0 4px 14px rgba(0,0,0,0.3)'
: '0 2px 8px rgba(0,0,0,0.22)'
const bgColor = place.category_color || '#6b7280'
const icon = place.category_icon || '📍'
// White semi-transparent number badge (bottom-right), only when orderNumber is set
const badgeHtml = orderNumber != null ? `
<span style="
position:absolute;bottom:-3px;right:-3px;
min-width:18px;height:18px;border-radius:9px;
padding:0 3px;
background:rgba(255,255,255,0.92);
border:1.5px solid rgba(0,0,0,0.18);
box-shadow:0 1px 4px rgba(0,0,0,0.18);
display:flex;align-items:center;justify-content:center;
font-size:9px;font-weight:800;color:#111827;
font-family:-apple-system,system-ui,sans-serif;line-height:1;
box-sizing:border-box;
">${orderNumber}</span>` : ''
if (place.image_url) {
return L.divIcon({
className: '',
html: `<div style="
width:${size}px;height:${size}px;border-radius:50%;
border:${borderWidth}px solid ${borderColor};
box-shadow:${shadow};
overflow:visible;background:${bgColor};
cursor:pointer;flex-shrink:0;position:relative;
">
<div style="width:100%;height:100%;border-radius:50%;overflow:hidden;">
<img src="${place.image_url}" style="width:100%;height:100%;object-fit:cover;" />
</div>
${badgeHtml}
</div>`,
iconSize: [size, size],
iconAnchor: [size / 2, size / 2],
tooltipAnchor: [size / 2 + 6, 0],
})
}
return L.divIcon({
className: '',
html: `<div style="
width:${size}px;height:${size}px;border-radius:50%;
border:${borderWidth}px solid ${borderColor};
box-shadow:${shadow};
background:${bgColor};
display:flex;align-items:center;justify-content:center;
cursor:pointer;position:relative;
">
<span style="font-size:${isSelected ? 18 : 15}px;line-height:1;">${icon}</span>
${badgeHtml}
</div>`,
iconSize: [size, size],
iconAnchor: [size / 2, size / 2],
tooltipAnchor: [size / 2 + 6, 0],
})
}
// Pan/zoom to selected place
function SelectionController({ places, selectedPlaceId }) {
const map = useMap()
const prev = useRef(null)
useEffect(() => {
if (selectedPlaceId && selectedPlaceId !== prev.current) {
const place = places.find(p => p.id === selectedPlaceId)
if (place?.lat && place?.lng) {
map.setView([place.lat, place.lng], Math.max(map.getZoom(), 15), { animate: true, duration: 0.5 })
}
}
prev.current = selectedPlaceId
}, [selectedPlaceId, places, map])
return null
}
// Recenter map when default center changes
function MapController({ center, zoom }) {
const map = useMap()
const prevCenter = useRef(center)
useEffect(() => {
if (prevCenter.current[0] !== center[0] || prevCenter.current[1] !== center[1]) {
map.setView(center, zoom)
prevCenter.current = center
}
}, [center, zoom, map])
return null
}
// Fit bounds when places change (fitKey triggers re-fit)
function BoundsController({ places, fitKey }) {
const map = useMap()
const prevFitKey = useRef(-1)
useEffect(() => {
if (fitKey === prevFitKey.current) return
prevFitKey.current = fitKey
if (places.length === 0) return
try {
const bounds = L.latLngBounds(places.map(p => [p.lat, p.lng]))
if (bounds.isValid()) map.fitBounds(bounds, { padding: [60, 60], maxZoom: 15, animate: true })
} catch {}
}, [fitKey, places, map])
return null
}
function MapClickHandler({ onClick }) {
const map = useMap()
useEffect(() => {
if (!onClick) return
map.on('click', onClick)
return () => map.off('click', onClick)
}, [map, onClick])
return null
}
// Module-level photo cache shared with PlaceAvatar
const mapPhotoCache = new Map()
export function MapView({
places = [],
route = null,
selectedPlaceId = null,
onMarkerClick,
onMapClick,
center = [48.8566, 2.3522],
zoom = 10,
tileUrl = 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png',
fitKey = 0,
dayOrderMap = {},
}) {
const [photoUrls, setPhotoUrls] = useState({})
// Fetch Google photos for places that have google_place_id but no image_url
useEffect(() => {
places.forEach(place => {
if (place.image_url || !place.google_place_id) return
if (mapPhotoCache.has(place.google_place_id)) {
const cached = mapPhotoCache.get(place.google_place_id)
if (cached) setPhotoUrls(prev => ({ ...prev, [place.google_place_id]: cached }))
return
}
mapsApi.placePhoto(place.google_place_id)
.then(data => {
if (data.photoUrl) {
mapPhotoCache.set(place.google_place_id, data.photoUrl)
setPhotoUrls(prev => ({ ...prev, [place.google_place_id]: data.photoUrl }))
}
})
.catch(() => { mapPhotoCache.set(place.google_place_id, null) })
})
}, [places])
return (
<MapContainer
center={center}
zoom={zoom}
zoomControl={false}
className="w-full h-full"
style={{ background: '#e5e7eb' }}
>
<TileLayer
url={tileUrl}
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
maxZoom={19}
/>
<MapController center={center} zoom={zoom} />
<BoundsController places={places} fitKey={fitKey} />
<SelectionController places={places} selectedPlaceId={selectedPlaceId} />
<MapClickHandler onClick={onMapClick} />
{places.map((place) => {
const isSelected = place.id === selectedPlaceId
const resolvedPhotoUrl = place.image_url || (place.google_place_id && photoUrls[place.google_place_id]) || null
const orderNumber = dayOrderMap[place.id] ?? null
const icon = createPlaceIcon({ ...place, image_url: resolvedPhotoUrl }, orderNumber, isSelected)
return (
<Marker
key={place.id}
position={[place.lat, place.lng]}
icon={icon}
eventHandlers={{
click: () => onMarkerClick && onMarkerClick(place.id),
}}
zIndexOffset={isSelected ? 1000 : 0}
>
<Tooltip
direction="right"
offset={[0, 0]}
opacity={1}
className="map-tooltip"
>
<div style={{ fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }}>
<div style={{ fontWeight: 600, fontSize: 12, color: 'var(--text-primary)', whiteSpace: 'nowrap' }}>
{place.name}
</div>
{place.category_name && (() => {
const CatIcon = getCategoryIcon(place.category_icon)
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 3, marginTop: 1 }}>
<CatIcon size={10} style={{ color: place.category_color || 'var(--text-muted)', flexShrink: 0 }} />
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>{place.category_name}</span>
</div>
)
})()}
{place.address && (
<div style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: 2, maxWidth: 180, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{place.address}
</div>
)}
</div>
</Tooltip>
</Marker>
)
})}
{route && route.length > 1 && (
<Polyline
positions={route}
color="#111827"
weight={3}
opacity={0.9}
dashArray="6, 5"
/>
)}
</MapContainer>
)
}

View File

@@ -0,0 +1,109 @@
// OSRM routing utility - free, no API key required
const OSRM_BASE = 'https://router.project-osrm.org/route/v1'
/**
* Calculate a route between multiple waypoints using OSRM
* @param {Array<{lat: number, lng: number}>} waypoints
* @param {string} profile - 'driving' | 'walking' | 'cycling'
* @returns {Promise<{coordinates: Array<[number,number]>, distance: number, duration: number, distanceText: string, durationText: string}>}
*/
export async function calculateRoute(waypoints, profile = 'driving') {
if (!waypoints || waypoints.length < 2) {
throw new Error('Mindestens 2 Wegpunkte erforderlich')
}
const coords = waypoints.map(p => `${p.lng},${p.lat}`).join(';')
// OSRM public API only supports driving; we override duration for other modes
const url = `${OSRM_BASE}/driving/${coords}?overview=full&geometries=geojson&steps=false`
const response = await fetch(url)
if (!response.ok) {
throw new Error('Route konnte nicht berechnet werden')
}
const data = await response.json()
if (data.code !== 'Ok' || !data.routes || data.routes.length === 0) {
throw new Error('Keine Route gefunden')
}
const route = data.routes[0]
const coordinates = route.geometry.coordinates.map(([lng, lat]) => [lat, lng])
const distance = route.distance // meters
// Compute duration based on mode (walking: 5 km/h, cycling: 15 km/h)
let duration
if (profile === 'walking') {
duration = distance / (5000 / 3600)
} else if (profile === 'cycling') {
duration = distance / (15000 / 3600)
} else {
duration = route.duration // driving: use OSRM value
}
return {
coordinates,
distance,
duration,
distanceText: formatDistance(distance),
durationText: formatDuration(duration),
}
}
/**
* Generate a Google Maps directions URL for the given places
*/
export function generateGoogleMapsUrl(places) {
const valid = places.filter(p => p.lat && p.lng)
if (valid.length === 0) return null
if (valid.length === 1) {
return `https://www.google.com/maps/search/?api=1&query=${valid[0].lat},${valid[0].lng}`
}
// Use /dir/stop1/stop2/.../stopN format — all stops as path segments
const stops = valid.map(p => `${p.lat},${p.lng}`).join('/')
return `https://www.google.com/maps/dir/${stops}`
}
/**
* Simple nearest-neighbor route optimization
*/
export function optimizeRoute(places) {
const valid = places.filter(p => p.lat && p.lng)
if (valid.length <= 2) return places
const visited = new Set()
const result = []
let current = valid[0]
visited.add(current.id)
result.push(current)
while (result.length < valid.length) {
let nearest = null
let minDist = Infinity
for (const place of valid) {
if (visited.has(place.id)) continue
const d = Math.sqrt(
Math.pow(place.lat - current.lat, 2) + Math.pow(place.lng - current.lng, 2)
)
if (d < minDist) { minDist = d; nearest = place }
}
if (nearest) { visited.add(nearest.id); result.push(nearest); current = nearest }
}
return result
}
function formatDistance(meters) {
if (meters < 1000) {
return `${Math.round(meters)} m`
}
return `${(meters / 1000).toFixed(1)} km`
}
function formatDuration(seconds) {
const h = Math.floor(seconds / 3600)
const m = Math.floor((seconds % 3600) / 60)
if (h > 0) {
return `${h} Std. ${m} Min.`
}
return `${m} Min.`
}

View File

@@ -0,0 +1,395 @@
// Trip PDF via browser print window
import { createElement } from 'react'
import { getCategoryIcon } from '../shared/categoryIcons'
import { mapsApi } from '../../api/client'
// ── SVG inline icons (for chips) ─────────────────────────────────────────────
const svgPin = `<svg width="11" height="11" viewBox="0 0 24 24" fill="#94a3b8" style="flex-shrink:0;margin-top:1px"><path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7z"/><circle cx="12" cy="9" r="2.5" fill="white"/></svg>`
const svgClock = `<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="#374151" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 3"/></svg>`
const svgClock2= `<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="#d97706" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 3"/></svg>`
const svgCheck = `<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="#059669" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12l5 5L19 7"/></svg>`
const svgEuro = `<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="#059669" stroke-width="2" stroke-linecap="round"><path d="M14 5c-3.87 0-7 3.13-7 7s3.13 7 7 7c2.17 0 4.1-.99 5.4-2.55"/><path d="M5 11h8M5 13h8"/></svg>`
function escHtml(str) {
if (!str) return ''
return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
}
function absUrl(url) {
if (!url) return null
if (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('data:')) return url
return window.location.origin + (url.startsWith('/') ? '' : '/') + url
}
function safeImg(url) {
if (!url) return null
if (url.startsWith('https://') || url.startsWith('http://')) return url
return /\.(jpe?g|png|webp|bmp|tiff?)(\?.*)?$/i.test(url) ? absUrl(url) : null
}
// Generate SVG string from Lucide icon name (for category thumbnails)
let _renderToStaticMarkup = null
async function ensureRenderer() {
if (!_renderToStaticMarkup) {
const mod = await import('react-dom/server')
_renderToStaticMarkup = mod.renderToStaticMarkup
}
}
function categoryIconSvg(iconName, color = '#6366f1', size = 24) {
if (!_renderToStaticMarkup) return ''
const Icon = getCategoryIcon(iconName)
return _renderToStaticMarkup(
createElement(Icon, { size, strokeWidth: 1.8, color: 'rgba(255,255,255,0.92)' })
)
}
function shortDate(d, locale) {
if (!d) return ''
return new Date(d + 'T00:00:00').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short' })
}
function longDateRange(days, locale) {
const dd = [...days].filter(d => d.date).sort((a, b) => a.day_number - b.day_number)
if (!dd.length) return null
const f = new Date(dd[0].date + 'T00:00:00')
const l = new Date(dd[dd.length - 1].date + 'T00:00:00')
return `${f.toLocaleDateString(locale, { day: 'numeric', month: 'long' })} ${l.toLocaleDateString(locale, { day: 'numeric', month: 'long', year: 'numeric' })}`
}
function dayCost(assignments, dayId, locale) {
const total = (assignments[String(dayId)] || []).reduce((s, a) => s + (parseFloat(a.place?.price) || 0), 0)
return total > 0 ? `${total.toLocaleString(locale)} EUR` : null
}
// Pre-fetch Google Place photos for all assigned places
async function fetchPlacePhotos(assignments) {
const photoMap = {} // placeId → photoUrl
const allPlaces = Object.values(assignments).flatMap(a => a.map(x => x.place)).filter(Boolean)
const unique = [...new Map(allPlaces.map(p => [p.id, p])).values()]
const toFetch = unique.filter(p => !p.image_url && p.google_place_id)
await Promise.allSettled(
toFetch.map(async (place) => {
try {
const data = await mapsApi.placePhoto(place.google_place_id)
if (data.photoUrl) photoMap[place.id] = data.photoUrl
} catch {}
})
)
return photoMap
}
export async function downloadTripPDF({ trip, days, places, assignments, categories, dayNotes, t: _t, locale: _locale }) {
await ensureRenderer()
const loc = _locale || 'de-DE'
const tr = _t || (k => k)
const sorted = [...(days || [])].sort((a, b) => a.day_number - b.day_number)
const range = longDateRange(sorted, loc)
const coverImg = safeImg(trip?.cover_image)
// Pre-fetch place photos from Google
const photoMap = await fetchPlacePhotos(assignments)
const totalAssigned = new Set(
Object.values(assignments || {}).flatMap(a => a.map(x => x.place?.id)).filter(Boolean)
).size
const totalCost = Object.values(assignments || {})
.flatMap(a => a).reduce((s, a) => s + (parseFloat(a.place?.price) || 0), 0)
// Build day HTML
const daysHtml = sorted.map((day, di) => {
const assigned = assignments[String(day.id)] || []
const notes = (dayNotes || []).filter(n => n.day_id === day.id)
const cost = dayCost(assignments, day.id, loc)
const merged = []
assigned.forEach(a => merged.push({ type: 'place', k: a.sort_order ?? 0, data: a }))
notes.forEach(n => merged.push({ type: 'note', k: n.sort_order ?? 0, data: n }))
merged.sort((a, b) => a.k - b.k)
let pi = 0
const itemsHtml = merged.length === 0
? `<div class="empty-day">${escHtml(tr('dayplan.emptyDay'))}</div>`
: merged.map(item => {
if (item.type === 'note') {
const note = item.data
return `
<div class="note-card">
<div class="note-line"></div>
<svg class="note-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#94a3b8" stroke-width="1.8" stroke-linecap="round">
<rect x="4" y="3" width="16" height="18" rx="2"/>
<line x1="8" y1="8" x2="16" y2="8"/>
<line x1="8" y1="12" x2="16" y2="12"/>
<line x1="8" y1="16" x2="13" y2="16"/>
</svg>
<div class="note-body">
<div class="note-text">${escHtml(note.text)}</div>
${note.time ? `<div class="note-time">${escHtml(note.time)}</div>` : ''}
</div>
</div>`
}
pi++
const place = item.data.place
if (!place) return ''
const cat = categories.find(c => c.id === place.category_id)
const color = cat?.color || '#6366f1'
// Image: direct > google photo > fallback icon
const directImg = safeImg(place.image_url)
const googleImg = photoMap[place.id] || null
const img = directImg || googleImg
const confirmed = place.reservation_status === 'confirmed'
const pending = place.reservation_status === 'pending'
const iconSvg = categoryIconSvg(cat?.icon, color, 24)
const thumbHtml = img
? `<img class="place-thumb" src="${escHtml(img)}" />`
: `<div class="place-thumb-fallback" style="background:${color}">
${iconSvg}
</div>`
const chips = [
place.place_time ? `<span class="chip">${svgClock}${escHtml(place.place_time)}</span>` : '',
place.price && parseFloat(place.price) > 0 ? `<span class="chip chip-green">${svgEuro}${Number(place.price).toLocaleString('de-DE')} EUR</span>` : '',
confirmed ? `<span class="chip chip-green">${svgCheck}${escHtml(tr('reservations.confirmed'))}</span>` : '',
pending ? `<span class="chip chip-amber">${svgClock2}${escHtml(tr('reservations.pending'))}</span>` : '',
].filter(Boolean).join('')
return `
<div class="place-card">
<div class="place-bar" style="background:${color}"></div>
${thumbHtml}
<div class="place-info">
<div class="place-name-row">
<span class="place-num">${pi}</span>
<span class="place-name">${escHtml(place.name)}</span>
${cat ? `<span class="cat-badge" style="background:${color}">${escHtml(cat.name)}</span>` : ''}
</div>
${place.address ? `<div class="info-row">${svgPin}<span class="info-text">${escHtml(place.address)}</span></div>` : ''}
${place.description ? `<div class="info-row"><span class="info-spacer"></span><span class="info-text muted italic">${escHtml(place.description)}</span></div>` : ''}
${chips ? `<div class="chips">${chips}</div>` : ''}
${place.notes ? `<div class="info-row"><span class="info-spacer"></span><span class="info-text muted italic">${escHtml(place.notes)}</span></div>` : ''}
</div>
</div>`
}).join('')
return `
<div class="day-section${di > 0 ? ' page-break' : ''}">
<div class="day-header">
<span class="day-tag">${escHtml(tr('dayplan.dayN', { n: day.day_number })).toUpperCase()}</span>
<span class="day-title">${escHtml(day.title || `Tag ${day.day_number}`)}</span>
${day.date ? `<span class="day-date">${shortDate(day.date, loc)}</span>` : ''}
${cost ? `<span class="day-cost">${cost}</span>` : ''}
</div>
<div class="day-body">${itemsHtml}</div>
</div>`
}).join('')
const html = `<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<base href="${window.location.origin}/">
<title>${escHtml(trip?.name || tr('pdf.travelPlan'))}</title>
<link href="https://fonts.googleapis.com/css2?family=Poppins:ital,wght@0,400;0,500;0,600;0,700;1,400&display=swap" rel="stylesheet">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: 'Poppins', sans-serif; background: #fff; color: #1e293b; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
svg { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
/* ── Cover ─────────────────────────────────────── */
.cover {
width: 100%; min-height: 100vh;
background: #0f172a;
display: flex; flex-direction: column; justify-content: flex-end;
padding: 52px; position: relative; overflow: hidden;
}
.cover-bg {
position: absolute; inset: 0;
background-size: cover; background-position: center;
opacity: 0.28;
}
.cover-dim { position: absolute; inset: 0; background: rgba(8,12,28,0.55); }
.cover-brand {
position: absolute; top: 36px; right: 52px;
font-size: 9px; font-weight: 600; letter-spacing: 2.5px;
color: rgba(255,255,255,0.3); text-transform: uppercase;
}
.cover-body { position: relative; z-index: 1; }
.cover-circle {
width: 100px; height: 100px; border-radius: 50%;
overflow: hidden; border: 2.5px solid rgba(255,255,255,0.25);
margin-bottom: 26px; flex-shrink: 0;
}
.cover-circle img { width: 100%; height: 100%; object-fit: cover; }
.cover-circle-ph {
width: 100px; height: 100px; border-radius: 50%;
background: rgba(255,255,255,0.07);
margin-bottom: 26px;
}
.cover-label { font-size: 9px; font-weight: 600; letter-spacing: 2.5px; color: rgba(255,255,255,0.4); text-transform: uppercase; margin-bottom: 8px; }
.cover-title { font-size: 42px; font-weight: 700; color: #fff; line-height: 1.1; margin-bottom: 8px; }
.cover-desc { font-size: 13px; color: rgba(255,255,255,0.55); line-height: 1.6; margin-bottom: 18px; max-width: 420px; }
.cover-dates { font-size: 12px; color: rgba(255,255,255,0.45); margin-bottom: 30px; }
.cover-line { height: 1px; background: rgba(255,255,255,0.1); margin-bottom: 24px; }
.cover-stats { display: flex; gap: 36px; }
.cover-stat-num { font-size: 28px; font-weight: 700; color: #fff; line-height: 1; }
.cover-stat-lbl { font-size: 9px; font-weight: 500; color: rgba(255,255,255,0.4); letter-spacing: 1px; margin-top: 4px; text-transform: uppercase; }
/* ── Day ───────────────────────────────────────── */
.page-break { page-break-before: always; }
.day-header {
background: #0f172a; padding: 11px 28px;
display: flex; align-items: center; gap: 8px;
}
.day-tag { font-size: 8px; font-weight: 700; color: #fff; letter-spacing: 0.8px; background: rgba(255,255,255,0.12); border-radius: 4px; padding: 3px 8px; flex-shrink: 0; }
.day-title { font-size: 13px; font-weight: 600; color: #fff; flex: 1; }
.day-date { font-size: 9px; color: rgba(255,255,255,0.45); }
.day-cost { font-size: 9px; font-weight: 600; color: rgba(255,255,255,0.65); }
.day-body { padding: 12px 28px 6px; }
/* ── Place card ────────────────────────────────── */
.place-card {
display: flex; align-items: stretch;
border: 1px solid #e2e8f0; border-radius: 8px;
margin-bottom: 8px; overflow: hidden;
background: #fff; page-break-inside: avoid;
}
.place-bar { width: 4px; flex-shrink: 0; }
.place-thumb {
width: 52px; height: 52px; object-fit: cover;
margin: 8px; border-radius: 6px; flex-shrink: 0;
}
.place-thumb-fallback {
width: 52px; height: 52px; margin: 8px; border-radius: 8px;
flex-shrink: 0; display: flex; align-items: center; justify-content: center;
}
.place-thumb-fallback svg { width: 24px; height: 24px; }
.place-info { flex: 1; padding: 9px 10px 8px 0; min-width: 0; }
.place-name-row { display: flex; align-items: center; gap: 5px; margin-bottom: 4px; }
.place-num {
width: 16px; height: 16px; border-radius: 50%;
background: #1e293b; color: #fff; font-size: 8px; font-weight: 700;
display: flex; align-items: center; justify-content: center; flex-shrink: 0;
}
.place-name { font-size: 11.5px; font-weight: 600; color: #1e293b; flex: 1; }
.cat-badge { font-size: 7.5px; font-weight: 600; color: #fff; border-radius: 99px; padding: 2px 7px; flex-shrink: 0; white-space: nowrap; }
.info-row { display: flex; align-items: flex-start; gap: 4px; margin-bottom: 2px; padding-left: 21px; }
.info-row svg { flex-shrink: 0; margin-top: 1px; }
.info-spacer { width: 13px; flex-shrink: 0; }
.info-text { font-size: 9px; color: #64748b; line-height: 1.5; }
.info-text.muted { color: #94a3b8; }
.info-text.italic { font-style: italic; }
.chips { display: flex; flex-wrap: wrap; gap: 4px; padding-left: 21px; margin-top: 4px; }
.chip { display: inline-flex; align-items: center; gap: 3px; font-size: 8px; font-weight: 600; background: #f1f5f9; color: #374151; border-radius: 99px; padding: 2px 7px; white-space: nowrap; }
.chip svg { flex-shrink: 0; }
.chip-green { background: #ecfdf5; color: #059669; }
.chip-amber { background: #fffbeb; color: #d97706; }
/* ── Note card ─────────────────────────────────── */
.note-card {
display: flex; align-items: center; gap: 8px;
background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 6px;
padding: 8px 10px; margin-bottom: 7px; page-break-inside: avoid;
}
.note-line { width: 3px; border-radius: 99px; background: #94a3b8; align-self: stretch; flex-shrink: 0; }
.note-icon { flex-shrink: 0; }
.note-body { flex: 1; min-width: 0; }
.note-text { font-size: 9.5px; color: #334155; line-height: 1.55; }
.note-time { font-size: 8px; color: #94a3b8; margin-top: 2px; }
.empty-day { font-size: 9.5px; color: #cbd5e1; font-style: italic; text-align: center; padding: 14px 0; }
/* ── Print ─────────────────────────────────────── */
@media print {
body { margin: 0; }
.cover { min-height: 100vh; page-break-after: always; }
@page { margin: 0; }
}
</style>
</head>
<body>
<!-- Cover -->
<div class="cover">
${coverImg ? `<div class="cover-bg" style="background-image:url('${escHtml(coverImg)}')"></div>` : ''}
<div class="cover-dim"></div>
<div class="cover-brand">NOMAD</div>
<div class="cover-body">
${coverImg
? `<div class="cover-circle"><img src="${escHtml(coverImg)}" /></div>`
: `<div class="cover-circle-ph"></div>`}
<div class="cover-label">${escHtml(tr('pdf.travelPlan'))}</div>
<div class="cover-title">${escHtml(trip?.name || 'Meine Reise')}</div>
${trip?.description ? `<div class="cover-desc">${escHtml(trip.description)}</div>` : ''}
${range ? `<div class="cover-dates">${range}</div>` : ''}
<div class="cover-line"></div>
<div class="cover-stats">
<div>
<div class="cover-stat-num">${sorted.length}</div>
<div class="cover-stat-lbl">${escHtml(tr('dashboard.days'))}</div>
</div>
<div>
<div class="cover-stat-num">${places?.length || 0}</div>
<div class="cover-stat-lbl">${escHtml(tr('dashboard.places'))}</div>
</div>
<div>
<div class="cover-stat-num">${totalAssigned}</div>
<div class="cover-stat-lbl">${escHtml(tr('pdf.planned'))}</div>
</div>
${totalCost > 0 ? `<div>
<div class="cover-stat-num">${totalCost.toLocaleString('de-DE')}</div>
<div class="cover-stat-lbl">${escHtml(tr('pdf.costLabel'))}</div>
</div>` : ''}
</div>
</div>
</div>
<!-- Days -->
${daysHtml}
</body></html>`
// Open print window
const blob = new Blob([html], { type: 'text/html' })
const url = URL.createObjectURL(blob)
// Modal in die App einfügen
const overlay = document.createElement('div')
overlay.id = 'pdf-preview-overlay'
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.7);z-index:9999;display:flex;align-items:center;justify-content:center;padding:8px;'
overlay.onclick = (e) => { if (e.target === overlay) { overlay.remove(); URL.revokeObjectURL(url) } }
const card = document.createElement('div')
card.style.cssText = 'width:100%;max-width:1000px;height:95vh;background:var(--bg-card);border-radius:12px;overflow:hidden;display:flex;flex-direction:column;box-shadow:0 20px 60px rgba(0,0,0,0.3);'
const header = document.createElement('div')
header.style.cssText = 'display:flex;align-items:center;justify-content:space-between;padding:10px 16px;border-bottom:1px solid var(--border-primary);flex-shrink:0;'
header.innerHTML = `
<span style="font-size:13px;font-weight:600;color:var(--text-primary)">${escHtml(trip?.name || tr('pdf.travelPlan'))}</span>
<div style="display:flex;align-items:center;gap:8px">
<button id="pdf-print-btn" style="display:flex;align-items:center;gap:5px;font-size:12px;font-weight:500;color:var(--text-muted);background:none;border:none;cursor:pointer;padding:4px 8px;border-radius:6px;font-family:inherit">${tr('pdf.saveAsPdf')}</button>
<button id="pdf-close-btn" style="background:none;border:none;cursor:pointer;color:var(--text-faint);display:flex;padding:4px;border-radius:6px">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
`
const iframe = document.createElement('iframe')
iframe.style.cssText = 'flex:1;width:100%;border:none;'
iframe.src = url
card.appendChild(header)
card.appendChild(iframe)
overlay.appendChild(card)
document.body.appendChild(overlay)
header.querySelector('#pdf-close-btn').onclick = () => { overlay.remove(); URL.revokeObjectURL(url) }
header.querySelector('#pdf-print-btn').onclick = () => { iframe.contentWindow?.print() }
}

View File

@@ -0,0 +1,529 @@
import React, { useState, useMemo, useRef } from 'react'
import { useTripStore } from '../../store/tripStore'
import { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n'
import {
CheckSquare, Square, Trash2, Plus, ChevronDown, ChevronRight,
Sparkles, X, Pencil, Check, MoreHorizontal, CheckCheck, RotateCcw, Luggage,
} from 'lucide-react'
const VORSCHLAEGE = [
{ name: 'Reisepass', kategorie: 'Dokumente' },
{ name: 'Reiseversicherung', kategorie: 'Dokumente' },
{ name: 'Visum-Unterlagen', kategorie: 'Dokumente' },
{ name: 'Flugtickets', kategorie: 'Dokumente' },
{ name: 'Hotelbuchungen', kategorie: 'Dokumente' },
{ name: 'Impfpass', kategorie: 'Dokumente' },
{ name: 'T-Shirts (5×)', kategorie: 'Kleidung' },
{ name: 'Hosen (2×)', kategorie: 'Kleidung' },
{ name: 'Unterwäsche (7×)', kategorie: 'Kleidung' },
{ name: 'Socken (7×)', kategorie: 'Kleidung' },
{ name: 'Jacke', kategorie: 'Kleidung' },
{ name: 'Badeanzug / Badehose', kategorie: 'Kleidung' },
{ name: 'Sportschuhe', kategorie: 'Kleidung' },
{ name: 'Zahnbürste', kategorie: 'Körperpflege' },
{ name: 'Zahnpasta', kategorie: 'Körperpflege' },
{ name: 'Shampoo', kategorie: 'Körperpflege' },
{ name: 'Sonnencreme', kategorie: 'Körperpflege' },
{ name: 'Deo', kategorie: 'Körperpflege' },
{ name: 'Rasierer', kategorie: 'Körperpflege' },
{ name: 'Ladekabel Handy', kategorie: 'Elektronik' },
{ name: 'Reiseadapter', kategorie: 'Elektronik' },
{ name: 'Kopfhörer', kategorie: 'Elektronik' },
{ name: 'Kamera', kategorie: 'Elektronik' },
{ name: 'Powerbank', kategorie: 'Elektronik' },
{ name: 'Erste-Hilfe-Set', kategorie: 'Gesundheit' },
{ name: 'Verschreibungspflichtige Medikamente', kategorie: 'Gesundheit' },
{ name: 'Schmerzmittel', kategorie: 'Gesundheit' },
{ name: 'Mückenschutz', kategorie: 'Gesundheit' },
{ name: 'Bargeld', kategorie: 'Finanzen' },
{ name: 'Kreditkarte', kategorie: 'Finanzen' },
]
const KAT_DOTS = {
'Dokumente': '#3b82f6',
'Kleidung': '#a855f7',
'Körperpflege': '#ec4899',
'Elektronik': '#22c55e',
'Gesundheit': '#f97316',
'Finanzen': '#16a34a',
}
function katDot(kat) { return KAT_DOTS[kat] || '#9ca3af' }
// ── Artikel-Zeile ──────────────────────────────────────────────────────────
function ArtikelZeile({ item, tripId, categories, onCategoryChange }) {
const [editing, setEditing] = useState(false)
const [editName, setEditName] = useState(item.name)
const [hovered, setHovered] = useState(false)
const [showCatPicker, setShowCatPicker] = useState(false)
const { togglePackingItem, updatePackingItem, deletePackingItem } = useTripStore()
const toast = useToast()
const { t } = useTranslation()
const handleToggle = () => togglePackingItem(tripId, item.id, !item.checked)
const handleSaveName = async () => {
if (!editName.trim()) { setEditing(false); setEditName(item.name); return }
try { await updatePackingItem(tripId, item.id, { name: editName.trim() }); setEditing(false) }
catch { toast.error(t('packing.toast.saveError')) }
}
const handleDelete = async () => {
try { await deletePackingItem(tripId, item.id) }
catch { toast.error(t('packing.toast.deleteError')) }
}
const handleCatChange = async (cat) => {
setShowCatPicker(false)
if (cat === item.category) return
try { await updatePackingItem(tripId, item.id, { category: cat }) }
catch { toast.error(t('common.error')) }
}
return (
<div
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => { setHovered(false); setShowCatPicker(false) }}
style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '6px 10px', borderRadius: 10, position: 'relative',
background: hovered ? 'var(--bg-secondary)' : 'transparent',
transition: 'background 0.1s',
}}
>
{/* Checkbox */}
<button onClick={handleToggle} style={{
flexShrink: 0, background: 'none', border: 'none', cursor: 'pointer', padding: 0, display: 'flex',
color: item.checked ? '#10b981' : 'var(--text-faint)', transition: 'color 0.15s',
}}>
{item.checked ? <CheckSquare size={18} /> : <Square size={18} />}
</button>
{/* Name */}
{editing ? (
<input
type="text" value={editName} autoFocus
onChange={e => setEditName(e.target.value)}
onBlur={handleSaveName}
onKeyDown={e => { if (e.key === 'Enter') handleSaveName(); if (e.key === 'Escape') { setEditing(false); setEditName(item.name) } }}
style={{ flex: 1, fontSize: 13.5, padding: '2px 8px', borderRadius: 6, border: '1px solid var(--border-primary)', outline: 'none', fontFamily: 'inherit' }}
/>
) : (
<span
onClick={() => !item.checked && setEditing(true)}
style={{
flex: 1, fontSize: 13.5,
cursor: item.checked ? 'default' : 'text',
color: item.checked ? 'var(--text-faint)' : 'var(--text-primary)',
textDecoration: item.checked ? 'line-through' : 'none',
}}
>
{item.name}
</span>
)}
{/* Actions — always in DOM, visible on hover */}
<div style={{ display: 'flex', gap: 2, alignItems: 'center', opacity: hovered ? 1 : 0, transition: 'opacity 0.12s', flexShrink: 0 }}>
{/* Category change */}
<div style={{ position: 'relative' }}>
<button
onClick={() => setShowCatPicker(p => !p)}
title={t('packing.changeCategory')}
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '3px 5px', borderRadius: 6, display: 'flex', alignItems: 'center', color: 'var(--text-faint)', fontSize: 10, gap: 2 }}
>
<span style={{ width: 7, height: 7, borderRadius: '50%', background: katDot(item.category || t('packing.defaultCategory')), display: 'inline-block' }} />
</button>
{showCatPicker && (
<div style={{
position: 'absolute', right: 0, top: '100%', zIndex: 50, background: 'var(--bg-card)',
border: '1px solid var(--border-primary)', borderRadius: 10, boxShadow: '0 4px 16px rgba(0,0,0,0.1)',
padding: 4, minWidth: 140,
}}>
{categories.map(cat => (
<button key={cat} onClick={() => handleCatChange(cat)} style={{
display: 'flex', alignItems: 'center', gap: 7, width: '100%',
padding: '6px 10px', background: cat === (item.category || t('packing.defaultCategory')) ? 'var(--bg-tertiary)' : 'none',
border: 'none', cursor: 'pointer', fontSize: 12.5, fontFamily: 'inherit',
color: 'var(--text-secondary)', borderRadius: 7, textAlign: 'left',
}}>
<span style={{ width: 8, height: 8, borderRadius: '50%', background: katDot(cat), flexShrink: 0 }} />
{cat}
</button>
))}
</div>
)}
</div>
{/* Edit */}
<button onClick={() => setEditing(true)} title={t('common.rename')} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '3px 4px', borderRadius: 6, display: 'flex', color: 'var(--text-faint)' }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-secondary)'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<Pencil size={13} />
</button>
{/* Delete */}
<button onClick={handleDelete} title={t('common.delete')} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '3px 4px', borderRadius: 6, display: 'flex', color: 'var(--text-faint)' }}
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<Trash2 size={13} />
</button>
</div>
</div>
)
}
// ── Kategorie-Gruppe ───────────────────────────────────────────────────────
function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, onDeleteAll }) {
const [offen, setOffen] = useState(true)
const [editingName, setEditingName] = useState(false)
const [editKatName, setEditKatName] = useState(kategorie)
const [showMenu, setShowMenu] = useState(false)
const { togglePackingItem } = useTripStore()
const toast = useToast()
const { t } = useTranslation()
const abgehakt = items.filter(i => i.checked).length
const alleAbgehakt = abgehakt === items.length
const dot = katDot(kategorie)
const handleSaveKatName = async () => {
const neu = editKatName.trim()
if (!neu || neu === kategorie) { setEditingName(false); setEditKatName(kategorie); return }
try { await onRename(kategorie, neu); setEditingName(false) }
catch { toast.error(t('packing.toast.renameError')) }
}
const handleCheckAll = async () => {
for (const item of items) {
if (!item.checked) await togglePackingItem(tripId, item.id, true)
}
}
const handleUncheckAll = async () => {
for (const item of items) {
if (item.checked) await togglePackingItem(tripId, item.id, false)
}
}
const handleDeleteAll = async () => {
await onDeleteAll(items)
setShowMenu(false)
}
return (
<div style={{ marginBottom: 6, background: 'var(--bg-card)', borderRadius: 14, border: '1px solid var(--border-secondary)', overflow: 'visible' }}>
{/* Header */}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '10px 12px', borderBottom: offen ? '1px solid var(--border-secondary)' : 'none' }}>
<button onClick={() => setOffen(o => !o)} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 0, display: 'flex', color: 'var(--text-faint)', flexShrink: 0 }}>
{offen ? <ChevronDown size={15} /> : <ChevronRight size={15} />}
</button>
<span style={{ width: 10, height: 10, borderRadius: '50%', background: dot, flexShrink: 0 }} />
{editingName ? (
<input
autoFocus value={editKatName}
onChange={e => setEditKatName(e.target.value)}
onBlur={handleSaveKatName}
onKeyDown={e => { if (e.key === 'Enter') handleSaveKatName(); if (e.key === 'Escape') { setEditingName(false); setEditKatName(kategorie) } }}
style={{ flex: 1, fontSize: 12.5, fontWeight: 600, border: 'none', borderBottom: '2px solid var(--text-primary)', outline: 'none', background: 'transparent', fontFamily: 'inherit', color: 'var(--text-primary)', padding: '0 2px' }}
/>
) : (
<span style={{ fontSize: 12.5, fontWeight: 700, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: '0.04em', flex: 1 }}>
{kategorie}
</span>
)}
{/* Progress pill */}
<span style={{
fontSize: 11, fontWeight: 600, padding: '1px 8px', borderRadius: 99,
background: alleAbgehakt ? '#dcfce7' : 'var(--bg-tertiary)',
color: alleAbgehakt ? '#16a34a' : 'var(--text-muted)',
}}>
{abgehakt}/{items.length}
</span>
{/* Kategorie-Menü */}
<div style={{ position: 'relative' }}>
<button onClick={() => setShowMenu(m => !m)} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '2px 4px', borderRadius: 6, display: 'flex', color: 'var(--text-faint)' }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-secondary)'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<MoreHorizontal size={15} />
</button>
{showMenu && (
<div style={{ position: 'absolute', right: 0, top: '100%', zIndex: 50, background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 10, boxShadow: '0 4px 16px rgba(0,0,0,0.1)', padding: 4, minWidth: 170 }}
onMouseLeave={() => setShowMenu(false)}>
<MenuItem icon={<Pencil size={13} />} label={t('packing.menuRename')} onClick={() => { setEditingName(true); setShowMenu(false) }} />
<MenuItem icon={<CheckCheck size={13} />} label={t('packing.menuCheckAll')} onClick={() => { handleCheckAll(); setShowMenu(false) }} />
<MenuItem icon={<RotateCcw size={13} />} label={t('packing.menuUncheckAll')} onClick={() => { handleUncheckAll(); setShowMenu(false) }} />
<div style={{ height: 1, background: 'var(--bg-tertiary)', margin: '4px 0' }} />
<MenuItem icon={<Trash2 size={13} />} label={t('packing.menuDeleteCat')} danger onClick={handleDeleteAll} />
</div>
)}
</div>
</div>
{/* Items */}
{offen && (
<div style={{ padding: '4px 4px 6px' }}>
{items.map(item => (
<ArtikelZeile key={item.id} item={item} tripId={tripId} categories={allCategories} onCategoryChange={() => {}} />
))}
</div>
)}
</div>
)
}
function MenuItem({ icon, label, onClick, danger }) {
return (
<button onClick={onClick} style={{
display: 'flex', alignItems: 'center', gap: 8, width: '100%',
padding: '7px 10px', background: 'none', border: 'none', cursor: 'pointer',
fontSize: 12.5, fontFamily: 'inherit', borderRadius: 7, textAlign: 'left',
color: danger ? '#ef4444' : 'var(--text-secondary)',
}}
onMouseEnter={e => e.currentTarget.style.background = danger ? '#fef2f2' : 'var(--bg-tertiary)'}
onMouseLeave={e => e.currentTarget.style.background = 'none'}
>
{icon}{label}
</button>
)
}
// ── Haupt-Panel ────────────────────────────────────────────────────────────
export default function PackingListPanel({ tripId, items }) {
const [neuerName, setNeuerName] = useState('')
const [neueKategorie, setNeueKategorie] = useState('')
const [zeigeVorschlaege, setZeigeVorschlaege] = useState(false)
const [filter, setFilter] = useState('alle') // 'alle' | 'offen' | 'erledigt'
const [showKatDropdown, setShowKatDropdown] = useState(false)
const katInputRef = useRef(null)
const { addPackingItem, updatePackingItem, deletePackingItem } = useTripStore()
const toast = useToast()
const { t } = useTranslation()
const allCategories = useMemo(() => {
const cats = new Set(items.map(i => i.category || t('packing.defaultCategory')))
return Array.from(cats).sort()
}, [items, t])
const gruppiert = useMemo(() => {
const filtered = items.filter(i => {
if (filter === 'offen') return !i.checked
if (filter === 'erledigt') return i.checked
return true
})
const groups = {}
for (const item of filtered) {
const kat = item.category || t('packing.defaultCategory')
if (!groups[kat]) groups[kat] = []
groups[kat].push(item)
}
return groups
}, [items, filter, t])
const abgehakt = items.filter(i => i.checked).length
const fortschritt = items.length > 0 ? Math.round((abgehakt / items.length) * 100) : 0
const handleAdd = async (e) => {
e.preventDefault()
if (!neuerName.trim()) return
const kat = neueKategorie.trim() || (allCategories[0] || t('packing.defaultCategory'))
try {
await addPackingItem(tripId, { name: neuerName.trim(), category: kat })
setNeuerName('')
} catch { toast.error(t('packing.toast.addError')) }
}
const vorschlaege = t('packing.suggestions.items') || VORSCHLAEGE
const handleVorschlag = async (v) => {
try { await addPackingItem(tripId, { name: v.name, category: v.category || v.kategorie }) }
catch { toast.error(t('packing.toast.addError')) }
}
// Rename all items in a category
const handleRenameCategory = async (oldName, newName) => {
const toUpdate = items.filter(i => (i.category || t('packing.defaultCategory')) === oldName)
for (const item of toUpdate) {
await updatePackingItem(tripId, item.id, { category: newName })
}
}
// Delete all items in a category
const handleDeleteCategory = async (catItems) => {
for (const item of catItems) {
try { await deletePackingItem(tripId, item.id) } catch {}
}
}
// Clear all checked items
const handleClearChecked = async () => {
if (!confirm(t('packing.confirm.clearChecked', { count: abgehakt }))) return
for (const item of items.filter(i => i.checked)) {
try { await deletePackingItem(tripId, item.id) } catch {}
}
}
const vorhandeneNamen = new Set(items.map(i => i.name.toLowerCase()))
const verfuegbareVorschlaege = vorschlaege.filter(v => !vorhandeneNamen.has(v.name.toLowerCase()))
const font = { fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', ...font }}>
{/* ── Header ── */}
<div style={{ padding: '20px 24px 16px', borderBottom: '1px solid rgba(0,0,0,0.06)', flexShrink: 0 }}>
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', marginBottom: 14 }}>
<div>
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 700, color: 'var(--text-primary)' }}>{t('packing.title')}</h2>
<p style={{ margin: '2px 0 0', fontSize: 12.5, color: 'var(--text-faint)' }}>
{items.length === 0 ? t('packing.empty') : t('packing.progress', { packed: abgehakt, total: items.length, percent: fortschritt })}
</p>
</div>
<div style={{ display: 'flex', gap: 6 }}>
{abgehakt > 0 && (
<button onClick={handleClearChecked} style={{
fontSize: 11.5, padding: '5px 10px', borderRadius: 99, border: '1px solid rgba(239,68,68,0.3)',
background: 'rgba(239,68,68,0.1)', color: '#ef4444', cursor: 'pointer', fontFamily: 'inherit',
}}>
{t('packing.clearChecked', { count: abgehakt })}
</button>
)}
<button onClick={() => setZeigeVorschlaege(v => !v)} style={{
display: 'flex', alignItems: 'center', gap: 5, padding: '5px 11px', borderRadius: 99,
border: '1px solid', fontSize: 12, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit',
background: zeigeVorschlaege ? '#111827' : 'var(--bg-card)',
borderColor: zeigeVorschlaege ? '#111827' : 'var(--border-primary)',
color: zeigeVorschlaege ? 'white' : 'var(--text-muted)',
}}>
<Sparkles size={12} /> {t('packing.suggestions')}
</button>
</div>
</div>
{/* Fortschrittsbalken */}
{items.length > 0 && (
<div style={{ marginBottom: 14 }}>
<div style={{ height: 5, background: 'var(--bg-tertiary)', borderRadius: 99, overflow: 'hidden' }}>
<div style={{
height: '100%', borderRadius: 99, transition: 'width 0.4s ease',
background: fortschritt === 100 ? '#10b981' : 'linear-gradient(90deg, var(--text-primary) 0%, var(--text-muted) 100%)',
width: `${fortschritt}%`,
}} />
</div>
{fortschritt === 100 && (
<p style={{ fontSize: 11.5, color: '#10b981', marginTop: 4, fontWeight: 600, margin: '4px 0 0' }}>{t('packing.allPacked')}</p>
)}
</div>
)}
{/* Artikel hinzufügen */}
<form onSubmit={handleAdd} style={{ display: 'flex', gap: 6 }}>
<input
type="text" value={neuerName} onChange={e => setNeuerName(e.target.value)}
placeholder={t('packing.addPlaceholder')}
style={{ flex: 1, padding: '8px 12px', borderRadius: 10, border: '1px solid var(--border-primary)', fontSize: 13.5, fontFamily: 'inherit', outline: 'none', color: 'var(--text-primary)' }}
/>
{/* Kategorie-Auswahl */}
<div style={{ position: 'relative' }}>
<input
ref={katInputRef}
type="text" value={neueKategorie}
onChange={e => { setNeueKategorie(e.target.value); setShowKatDropdown(true) }}
onFocus={() => setShowKatDropdown(true)}
onBlur={() => setTimeout(() => setShowKatDropdown(false), 150)}
placeholder={allCategories[0] || t('packing.categoryPlaceholder')}
style={{ width: 120, padding: '8px 10px', borderRadius: 10, border: '1px solid var(--border-primary)', fontSize: 13, fontFamily: 'inherit', outline: 'none', color: 'var(--text-secondary)' }}
/>
{showKatDropdown && allCategories.length > 0 && (
<div style={{ position: 'absolute', top: '100%', left: 0, right: 0, background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 10, boxShadow: '0 4px 16px rgba(0,0,0,0.1)', zIndex: 50, padding: 4, marginTop: 2 }}>
{allCategories.filter(c => !neueKategorie || c.toLowerCase().includes(neueKategorie.toLowerCase())).map(cat => (
<button key={cat} type="button" onMouseDown={() => setNeueKategorie(cat)} style={{
display: 'flex', alignItems: 'center', gap: 6, width: '100%',
padding: '6px 10px', background: 'none', border: 'none', cursor: 'pointer',
fontSize: 12.5, fontFamily: 'inherit', color: 'var(--text-secondary)', borderRadius: 7, textAlign: 'left',
}}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-tertiary)'}
onMouseLeave={e => e.currentTarget.style.background = 'none'}
>
<span style={{ width: 8, height: 8, borderRadius: '50%', background: katDot(cat), flexShrink: 0 }} />
{cat}
</button>
))}
</div>
)}
</div>
<button type="submit" style={{ padding: '8px 12px', borderRadius: 10, border: 'none', background: '#111827', color: 'white', cursor: 'pointer', display: 'flex', alignItems: 'center' }}>
<Plus size={16} />
</button>
</form>
</div>
{/* ── Vorschläge ── */}
{zeigeVorschlaege && (
<div style={{ borderBottom: '1px solid rgba(0,0,0,0.06)', background: 'var(--bg-secondary)', padding: '10px 20px', flexShrink: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 8 }}>
<span style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-secondary)' }}>{t('packing.suggestionsTitle')}</span>
<button onClick={() => setZeigeVorschlaege(false)} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 2, display: 'flex' }}>
<X size={14} style={{ color: 'var(--text-faint)' }} />
</button>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 5, maxHeight: 110, overflowY: 'auto' }}>
{verfuegbareVorschlaege.map((v, i) => (
<button key={i} onClick={() => handleVorschlag(v)} style={{
fontSize: 12, padding: '4px 10px', borderRadius: 99, border: '1px solid var(--border-primary)',
background: 'var(--bg-card)', cursor: 'pointer', color: 'var(--text-secondary)', fontFamily: 'inherit', transition: 'all 0.1s',
}}
onMouseEnter={e => { e.currentTarget.style.background = 'var(--text-primary)'; e.currentTarget.style.color = 'white'; e.currentTarget.style.borderColor = 'var(--text-primary)' }}
onMouseLeave={e => { e.currentTarget.style.background = 'var(--bg-card)'; e.currentTarget.style.color = 'var(--text-secondary)'; e.currentTarget.style.borderColor = 'var(--border-primary)' }}
>
+ {v.name}
</button>
))}
{verfuegbareVorschlaege.length === 0 && <p style={{ fontSize: 12, color: 'var(--text-faint)', margin: 0 }}>{t('packing.allSuggested')}</p>}
</div>
</div>
)}
{/* ── Filter-Tabs ── */}
{items.length > 0 && (
<div style={{ display: 'flex', gap: 4, padding: '10px 16px 0', flexShrink: 0 }}>
{[['alle', t('packing.filterAll')], ['offen', t('packing.filterOpen')], ['erledigt', t('packing.filterDone')]].map(([id, label]) => (
<button key={id} onClick={() => setFilter(id)} style={{
padding: '4px 12px', borderRadius: 99, border: 'none', cursor: 'pointer',
fontSize: 12, fontFamily: 'inherit', fontWeight: filter === id ? 600 : 400,
background: filter === id ? '#111827' : 'transparent',
color: filter === id ? 'white' : 'var(--text-muted)',
}}>{label}</button>
))}
</div>
)}
{/* ── Liste ── */}
<div style={{ flex: 1, overflowY: 'auto', padding: '10px 12px 16px' }}>
{items.length === 0 ? (
<div style={{ textAlign: 'center', padding: '60px 20px' }}>
<Luggage size={40} style={{ color: 'var(--text-faint)', display: 'block', margin: '0 auto 10px' }} />
<p style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-secondary)', margin: '0 0 4px' }}>{t('packing.emptyTitle')}</p>
<p style={{ fontSize: 13, color: 'var(--text-faint)', margin: 0 }}>{t('packing.emptyHint')}</p>
</div>
) : Object.keys(gruppiert).length === 0 ? (
<div style={{ textAlign: 'center', padding: '40px 20px', color: 'var(--text-faint)' }}>
<p style={{ fontSize: 13, margin: 0 }}>{t('packing.emptyFiltered')}</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
{Object.entries(gruppiert).map(([kat, katItems]) => (
<KategorieGruppe
key={kat}
kategorie={kat}
items={katItems}
tripId={tripId}
allCategories={allCategories}
onRename={handleRenameCategory}
onDeleteAll={handleDeleteCategory}
/>
))}
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,197 @@
import React, { useState, useMemo } from 'react'
import { PhotoLightbox } from './PhotoLightbox'
import { PhotoUpload } from './PhotoUpload'
import { Upload, Camera } from 'lucide-react'
import Modal from '../shared/Modal'
export default function PhotoGallery({ photos, onUpload, onDelete, onUpdate, places, days, tripId }) {
const [lightboxIndex, setLightboxIndex] = useState(null)
const [showUpload, setShowUpload] = useState(false)
const [filterDayId, setFilterDayId] = useState('')
const filteredPhotos = useMemo(() => {
return photos.filter(photo => {
if (filterDayId && String(photo.day_id) !== String(filterDayId)) return false
return true
})
}, [photos, filterDayId])
const handlePhotoClick = (photo) => {
const idx = filteredPhotos.findIndex(p => p.id === photo.id)
setLightboxIndex(idx)
}
const handleDelete = async (photoId) => {
await onDelete(photoId)
if (lightboxIndex !== null) {
const newPhotos = filteredPhotos.filter(p => p.id !== photoId)
if (newPhotos.length === 0) {
setLightboxIndex(null)
} else if (lightboxIndex >= newPhotos.length) {
setLightboxIndex(newPhotos.length - 1)
}
}
}
return (
<div className="flex flex-col h-full" style={{ fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }}>
{/* Header */}
<div style={{ padding: '16px 24px', borderBottom: '1px solid rgba(0,0,0,0.06)', display: 'flex', alignItems: 'center', gap: 12, flexShrink: 0, flexWrap: 'wrap' }}>
<div style={{ marginRight: 'auto' }}>
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 700, color: '#111827' }}>Fotos</h2>
<p style={{ margin: '2px 0 0', fontSize: 12.5, color: '#9ca3af' }}>
{photos.length} Foto{photos.length !== 1 ? 's' : ''}
</p>
</div>
<select
value={filterDayId}
onChange={e => setFilterDayId(e.target.value)}
className="border border-gray-200 rounded-lg px-3 py-1.5 text-sm text-gray-600 focus:outline-none focus:ring-2 focus:ring-slate-900"
>
<option value="">Alle Tage</option>
{(days || []).map(day => (
<option key={day.id} value={day.id}>
Tag {day.day_number}{day.date ? ` · ${formatDate(day.date)}` : ''}
</option>
))}
</select>
{filterDayId && (
<button
onClick={() => setFilterDayId('')}
className="text-xs text-gray-500 hover:text-gray-700 underline"
>
Zurücksetzen
</button>
)}
<button
onClick={() => setShowUpload(true)}
className="flex items-center gap-2 bg-slate-900 text-white px-4 py-2 rounded-lg hover:bg-slate-700 text-sm font-medium whitespace-nowrap"
>
<Upload className="w-4 h-4" />
Fotos hochladen
</button>
</div>
{/* Gallery Grid */}
<div className="flex-1 overflow-y-auto p-4">
{filteredPhotos.length === 0 ? (
<div style={{ textAlign: 'center', padding: '60px 20px', color: '#9ca3af' }}>
<Camera size={40} style={{ color: '#d1d5db', display: 'block', margin: '0 auto 12px' }} />
<p style={{ fontSize: 14, fontWeight: 600, color: '#374151', margin: '0 0 4px' }}>Noch keine Fotos</p>
<p style={{ fontSize: 13, color: '#9ca3af', margin: '0 0 20px' }}>Lade deine Reisefotos hoch</p>
<button
onClick={() => setShowUpload(true)}
className="flex items-center gap-2 bg-slate-900 text-white px-6 py-3 rounded-xl hover:bg-slate-700 font-medium"
style={{ display: 'inline-flex', margin: '0 auto' }}
>
<Upload className="w-4 h-4" />
Fotos hochladen
</button>
</div>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-2">
{filteredPhotos.map(photo => (
<PhotoThumbnail
key={photo.id}
photo={photo}
days={days}
places={places}
onClick={() => handlePhotoClick(photo)}
/>
))}
{/* Upload tile */}
<button
onClick={() => setShowUpload(true)}
className="aspect-square rounded-xl border-2 border-dashed border-gray-200 hover:border-slate-400 flex flex-col items-center justify-center gap-2 text-gray-400 hover:text-slate-700 transition-colors"
>
<Upload className="w-6 h-6" />
<span className="text-xs">Hinzufügen</span>
</button>
</div>
)}
</div>
{/* Lightbox */}
{lightboxIndex !== null && (
<PhotoLightbox
photos={filteredPhotos}
initialIndex={lightboxIndex}
onClose={() => setLightboxIndex(null)}
onUpdate={onUpdate}
onDelete={handleDelete}
days={days}
places={places}
tripId={tripId}
/>
)}
{/* Upload Modal */}
<Modal
isOpen={showUpload}
onClose={() => setShowUpload(false)}
title="Fotos hochladen"
size="lg"
>
<PhotoUpload
tripId={tripId}
days={days}
places={places}
onUpload={async (formData) => {
await onUpload(formData)
setShowUpload(false)
}}
onClose={() => setShowUpload(false)}
/>
</Modal>
</div>
)
}
function PhotoThumbnail({ photo, days, places, onClick }) {
const day = days?.find(d => d.id === photo.day_id)
const place = places?.find(p => p.id === photo.place_id)
return (
<div
className="aspect-square rounded-xl overflow-hidden cursor-pointer relative group bg-gray-100"
onClick={onClick}
>
<img
src={photo.url}
alt={photo.caption || photo.original_name}
className="w-full h-full object-cover transition-transform duration-200 group-hover:scale-105"
loading="lazy"
onError={e => {
e.target.style.display = 'none'
e.target.nextSibling && (e.target.nextSibling.style.display = 'flex')
}}
/>
{/* Fallback */}
<div className="hidden absolute inset-0 items-center justify-center text-gray-400 text-2xl">
🖼
</div>
{/* Hover overlay */}
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/40 transition-all duration-200 flex flex-col justify-end p-2 opacity-0 group-hover:opacity-100">
{photo.caption && (
<p className="text-white text-xs font-medium truncate">{photo.caption}</p>
)}
{(day || place) && (
<p className="text-white/70 text-xs truncate">
{day ? `Tag ${day.day_number}` : ''}{day && place ? ' · ' : ''}{place?.name || ''}
</p>
)}
</div>
</div>
)
}
function formatDate(dateStr) {
if (!dateStr) return ''
return new Date(dateStr + 'T00:00:00').toLocaleDateString('de-DE', { day: 'numeric', month: 'short' })
}

View File

@@ -0,0 +1,227 @@
import React, { useState, useEffect, useCallback } from 'react'
import { X, ChevronLeft, ChevronRight, Edit2, Trash2, Check } from 'lucide-react'
export function PhotoLightbox({ photos, initialIndex, onClose, onUpdate, onDelete, days, places, tripId }) {
const [index, setIndex] = useState(initialIndex || 0)
const [editCaption, setEditCaption] = useState(false)
const [caption, setCaption] = useState('')
const [isSaving, setIsSaving] = useState(false)
const photo = photos[index]
useEffect(() => {
setIndex(initialIndex || 0)
}, [initialIndex])
useEffect(() => {
if (photo) setCaption(photo.caption || '')
}, [photo])
const prev = useCallback(() => {
setIndex(i => Math.max(0, i - 1))
setEditCaption(false)
}, [])
const next = useCallback(() => {
setIndex(i => Math.min(photos.length - 1, i + 1))
setEditCaption(false)
}, [photos.length])
useEffect(() => {
const handleKey = (e) => {
if (e.key === 'Escape') onClose()
if (e.key === 'ArrowLeft') prev()
if (e.key === 'ArrowRight') next()
}
window.addEventListener('keydown', handleKey)
return () => window.removeEventListener('keydown', handleKey)
}, [onClose, prev, next])
const handleSaveCaption = async () => {
setIsSaving(true)
try {
await onUpdate(photo.id, { caption })
setEditCaption(false)
} finally {
setIsSaving(false)
}
}
const handleDelete = async () => {
if (!confirm('Foto löschen?')) return
await onDelete(photo.id)
if (photos.length <= 1) {
onClose()
} else {
setIndex(i => Math.min(i, photos.length - 2))
}
}
if (!photo) return null
const day = days?.find(d => d.id === photo.day_id)
const place = places?.find(p => p.id === photo.place_id)
return (
<div
className="fixed inset-0 z-50 bg-black/95 flex items-center justify-center"
onClick={onClose}
>
{/* Main area */}
<div
className="relative flex flex-col w-full h-full max-w-5xl mx-auto"
onClick={e => e.stopPropagation()}
>
{/* Top bar */}
<div className="flex items-center justify-between p-4 flex-shrink-0">
<div className="text-white/60 text-sm">
{index + 1} / {photos.length}
</div>
<div className="flex items-center gap-2">
<button
onClick={handleDelete}
className="p-2 text-white/60 hover:text-red-400 hover:bg-white/10 rounded-lg transition-colors"
title="Löschen"
>
<Trash2 className="w-4 h-4" />
</button>
<button
onClick={onClose}
className="p-2 text-white/60 hover:text-white hover:bg-white/10 rounded-lg transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
</div>
{/* Image area */}
<div className="flex-1 flex items-center justify-center relative min-h-0 px-16">
{/* Prev button */}
{index > 0 && (
<button
onClick={prev}
className="absolute left-4 top-1/2 -translate-y-1/2 p-3 bg-white/10 hover:bg-white/20 text-white rounded-full transition-colors z-10"
>
<ChevronLeft className="w-6 h-6" />
</button>
)}
<img
src={photo.url}
alt={photo.caption || photo.original_name}
className="max-h-full max-w-full object-contain rounded-lg select-none"
draggable={false}
/>
{/* Next button */}
{index < photos.length - 1 && (
<button
onClick={next}
className="absolute right-4 top-1/2 -translate-y-1/2 p-3 bg-white/10 hover:bg-white/20 text-white rounded-full transition-colors z-10"
>
<ChevronRight className="w-6 h-6" />
</button>
)}
</div>
{/* Bottom info */}
<div className="flex-shrink-0 p-4">
{/* Caption */}
<div className="flex items-center gap-2 mb-2">
{editCaption ? (
<>
<input
type="text"
value={caption}
onChange={e => setCaption(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleSaveCaption()}
placeholder="Beschriftung hinzufügen..."
className="flex-1 bg-white/10 text-white border border-white/20 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:border-white/40"
autoFocus
/>
<button
onClick={handleSaveCaption}
disabled={isSaving}
className="p-1.5 bg-slate-900 text-white rounded-lg hover:bg-slate-700"
>
<Check className="w-4 h-4" />
</button>
<button
onClick={() => { setEditCaption(false); setCaption(photo.caption || '') }}
className="p-1.5 text-white/60 hover:text-white"
>
<X className="w-4 h-4" />
</button>
</>
) : (
<>
<p
className="text-white text-sm flex-1 cursor-pointer hover:text-white/80"
onClick={() => setEditCaption(true)}
>
{photo.caption || <span className="text-white/40 italic">Beschriftung hinzufügen...</span>}
</p>
<button
onClick={() => setEditCaption(true)}
className="p-1.5 text-white/40 hover:text-white/70"
>
<Edit2 className="w-3.5 h-3.5" />
</button>
</>
)}
</div>
{/* Metadata */}
<div className="flex items-center gap-4 text-white/40 text-xs">
<span>{photo.original_name}</span>
{photo.created_at && (
<span>{formatDate(photo.created_at)}</span>
)}
{day && <span>📅 Tag {day.day_number}</span>}
{place && <span>📍 {place.name}</span>}
{photo.file_size && <span>{formatSize(photo.file_size)}</span>}
</div>
</div>
{/* Thumbnail strip */}
{photos.length > 1 && (
<div className="flex-shrink-0 px-4 pb-4">
<div className="flex gap-1.5 overflow-x-auto pb-1">
{photos.map((p, i) => (
<button
key={p.id}
onClick={() => { setIndex(i); setEditCaption(false) }}
className={`flex-shrink-0 w-12 h-12 rounded-lg overflow-hidden transition-all ${
i === index
? 'ring-2 ring-white scale-105'
: 'opacity-50 hover:opacity-75'
}`}
>
<img
src={p.url}
alt=""
className="w-full h-full object-cover"
loading="lazy"
/>
</button>
))}
</div>
</div>
)}
</div>
</div>
)
}
function formatDate(dateStr) {
if (!dateStr) return ''
try {
return new Date(dateStr).toLocaleDateString('de-DE', { day: 'numeric', month: 'long', year: 'numeric' })
} catch { return '' }
}
function formatSize(bytes) {
if (!bytes) return ''
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
}

View File

@@ -0,0 +1,191 @@
import React, { useState, useCallback } from 'react'
import { useDropzone } from 'react-dropzone'
import { Upload, X, Image } from 'lucide-react'
export function PhotoUpload({ tripId, days, places, onUpload, onClose }) {
const [files, setFiles] = useState([])
const [dayId, setDayId] = useState('')
const [placeId, setPlaceId] = useState('')
const [caption, setCaption] = useState('')
const [uploading, setUploading] = useState(false)
const [progress, setProgress] = useState(0)
const onDrop = useCallback((acceptedFiles) => {
const withPreview = acceptedFiles.map(file =>
Object.assign(file, { preview: URL.createObjectURL(file) })
)
setFiles(prev => [...prev, ...withPreview])
}, [])
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
accept: { 'image/*': ['.jpeg', '.jpg', '.png', '.gif', '.webp', '.heic'] },
maxFiles: 30,
maxSize: 10 * 1024 * 1024,
})
const removeFile = (index) => {
setFiles(prev => {
URL.revokeObjectURL(prev[index].preview)
return prev.filter((_, i) => i !== index)
})
}
const handleUpload = async () => {
if (files.length === 0) return
setUploading(true)
setProgress(0)
try {
const formData = new FormData()
files.forEach(file => formData.append('photos', file))
if (dayId) formData.append('day_id', dayId)
if (placeId) formData.append('place_id', placeId)
if (caption) formData.append('caption', caption)
await onUpload(formData)
files.forEach(f => URL.revokeObjectURL(f.preview))
setFiles([])
} catch (err) {
console.error('Upload failed:', err)
} finally {
setUploading(false)
setProgress(0)
}
}
const formatSize = (bytes) => {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
}
return (
<div className="space-y-4">
{/* Dropzone */}
<div
{...getRootProps()}
className={`border-2 border-dashed rounded-xl p-8 text-center cursor-pointer transition-all ${
isDragActive
? 'border-slate-900 bg-slate-50'
: 'border-gray-300 hover:border-slate-400 hover:bg-gray-50'
}`}
>
<input {...getInputProps()} />
<Upload className={`w-10 h-10 mx-auto mb-3 ${isDragActive ? 'text-slate-900' : 'text-gray-400'}`} />
{isDragActive ? (
<p className="text-slate-700 font-medium">Fotos hier ablegen...</p>
) : (
<>
<p className="text-gray-600 font-medium">Fotos hier ablegen</p>
<p className="text-gray-400 text-sm mt-1">oder klicken zum Auswählen</p>
<p className="text-gray-400 text-xs mt-2">JPG, PNG, WebP · max. 10 MB · bis zu 30 Fotos</p>
</>
)}
</div>
{/* Preview grid */}
{files.length > 0 && (
<div>
<p className="text-sm font-medium text-gray-700 mb-2">{files.length} Foto{files.length !== 1 ? 's' : ''} ausgewählt</p>
<div className="grid grid-cols-4 sm:grid-cols-6 gap-2 max-h-48 overflow-y-auto">
{files.map((file, idx) => (
<div key={idx} className="relative aspect-square group">
<img
src={file.preview}
alt={file.name}
className="w-full h-full object-cover rounded-lg"
/>
<button
onClick={() => removeFile(idx)}
className="absolute top-1 right-1 bg-red-500 text-white rounded-full w-5 h-5 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
>
<X className="w-3 h-3" />
</button>
<div className="absolute bottom-0 left-0 right-0 bg-black/50 text-white text-xs p-1 rounded-b-lg opacity-0 group-hover:opacity-100 transition-opacity truncate">
{formatSize(file.size)}
</div>
</div>
))}
</div>
</div>
)}
{/* Options */}
{files.length > 0 && (
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Tag verknüpfen</label>
<select
value={dayId}
onChange={e => setDayId(e.target.value)}
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-slate-900"
>
<option value="">Kein Tag</option>
{(days || []).map(day => (
<option key={day.id} value={day.id}>Tag {day.day_number}</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Ort verknüpfen</label>
<select
value={placeId}
onChange={e => setPlaceId(e.target.value)}
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-slate-900"
>
<option value="">Kein Ort</option>
{(places || []).map(place => (
<option key={place.id} value={place.id}>{place.name}</option>
))}
</select>
</div>
<div className="col-span-2">
<label className="block text-xs font-medium text-gray-700 mb-1">Beschriftung (für alle)</label>
<input
type="text"
value={caption}
onChange={e => setCaption(e.target.value)}
placeholder="Optionale Beschriftung..."
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-slate-900"
/>
</div>
</div>
)}
{/* Upload progress */}
{uploading && (
<div className="bg-slate-50 rounded-lg p-3">
<div className="flex items-center gap-2 mb-2">
<div className="w-4 h-4 border-2 border-slate-900 border-t-transparent rounded-full animate-spin" />
<span className="text-sm text-slate-900">Wird hochgeladen...</span>
</div>
<div className="w-full bg-slate-200 rounded-full h-1.5">
<div
className="bg-slate-900 h-1.5 rounded-full transition-all duration-300"
style={{ width: `${progress}%` }}
/>
</div>
</div>
)}
{/* Actions */}
<div className="flex justify-end gap-3">
<button
onClick={onClose}
className="px-4 py-2 text-sm text-gray-600 border border-gray-200 rounded-lg hover:bg-gray-50"
>
Abbrechen
</button>
<button
onClick={handleUpload}
disabled={files.length === 0 || uploading}
className="flex items-center gap-2 px-6 py-2 bg-slate-900 text-white text-sm rounded-lg hover:bg-slate-700 disabled:opacity-60 font-medium"
>
<Upload className="w-4 h-4" />
{uploading ? 'Hochladen...' : `${files.length} Foto${files.length !== 1 ? 's' : ''} hochladen`}
</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,504 @@
import React, { useState, useEffect } from 'react'
import Modal from '../shared/Modal'
import { mapsApi, tagsApi, categoriesApi } from '../../api/client'
import { useToast } from '../shared/Toast'
import { useAuthStore } from '../../store/authStore'
import { Search, Plus, MapPin, Loader } from 'lucide-react'
const STATUSES = [
{ value: 'none', label: 'None' },
{ value: 'pending', label: 'Pending' },
{ value: 'confirmed', label: 'Confirmed' },
]
export default function PlaceFormModal({
isOpen,
onClose,
onSave,
place,
tripId,
categories: initialCategories = [],
tags: initialTags = [],
onCategoryCreated,
onTagCreated,
}) {
const isEditing = !!place
const { user } = useAuthStore()
const toast = useToast()
const [categories, setCategories] = useState(initialCategories)
const [tags, setTags] = useState(initialTags)
useEffect(() => { setCategories(initialCategories) }, [initialCategories])
useEffect(() => { setTags(initialTags) }, [initialTags])
const emptyForm = {
name: '',
description: '',
address: '',
lat: '',
lng: '',
category_id: '',
place_time: '',
reservation_status: 'none',
reservation_notes: '',
reservation_datetime: '',
google_place_id: '',
website: '',
tags: [],
}
const [formData, setFormData] = useState(emptyForm)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState('')
// Maps search state
const [mapQuery, setMapQuery] = useState('')
const [mapResults, setMapResults] = useState([])
const [mapSearching, setMapSearching] = useState(false)
// New category/tag
const [newCategoryName, setNewCategoryName] = useState('')
const [newCategoryColor, setNewCategoryColor] = useState('#374151')
const [showNewCategory, setShowNewCategory] = useState(false)
const [newTagName, setNewTagName] = useState('')
const [newTagColor, setNewTagColor] = useState('#374151')
const [showNewTag, setShowNewTag] = useState(false)
useEffect(() => {
if (place && isOpen) {
setFormData({
name: place.name || '',
description: place.description || '',
address: place.address || '',
lat: place.lat ?? '',
lng: place.lng ?? '',
category_id: place.category_id || '',
place_time: place.place_time || '',
reservation_status: place.reservation_status || 'none',
reservation_notes: place.reservation_notes || '',
reservation_datetime: place.reservation_datetime || '',
google_place_id: place.google_place_id || '',
website: place.website || '',
tags: (place.tags || []).map(t => t.id),
})
} else if (!place && isOpen) {
setFormData(emptyForm)
}
setError('')
setMapResults([])
setMapQuery('')
}, [place, isOpen])
const update = (field, value) => setFormData(prev => ({ ...prev, [field]: value }))
const toggleTag = (tagId) => {
setFormData(prev => ({
...prev,
tags: prev.tags.includes(tagId)
? prev.tags.filter(id => id !== tagId)
: [...prev.tags, tagId]
}))
}
const handleSubmit = async (e) => {
e.preventDefault()
if (!formData.name.trim()) {
setError('Place name is required')
return
}
setIsLoading(true)
setError('')
try {
await onSave({
...formData,
lat: formData.lat !== '' ? parseFloat(formData.lat) : null,
lng: formData.lng !== '' ? parseFloat(formData.lng) : null,
category_id: formData.category_id || null,
})
onClose()
} catch (err) {
setError(err.message || 'Failed to save place')
} finally {
setIsLoading(false)
}
}
const handleMapSearch = async () => {
if (!mapQuery.trim()) return
setMapSearching(true)
try {
const data = await mapsApi.search(mapQuery)
setMapResults(data.places || [])
} catch (err) {
toast.error(err.response?.data?.error || 'Maps search failed')
} finally {
setMapSearching(false)
}
}
const selectMapPlace = (p) => {
setFormData(prev => ({
...prev,
name: p.name || prev.name,
address: p.address || prev.address,
lat: p.lat ?? prev.lat,
lng: p.lng ?? prev.lng,
google_place_id: p.google_place_id || prev.google_place_id,
website: p.website || prev.website,
}))
setMapResults([])
setMapQuery('')
}
const handleCreateCategory = async () => {
if (!newCategoryName.trim()) return
try {
const data = await categoriesApi.create({ name: newCategoryName, color: newCategoryColor, icon: 'MapPin' })
setCategories(prev => [...prev, data.category])
if (onCategoryCreated) onCategoryCreated(data.category)
setFormData(prev => ({ ...prev, category_id: data.category.id }))
setNewCategoryName('')
setShowNewCategory(false)
toast.success('Category created')
} catch (err) {
toast.error('Failed to create category')
}
}
const handleCreateTag = async () => {
if (!newTagName.trim()) return
try {
const data = await tagsApi.create({ name: newTagName, color: newTagColor })
setTags(prev => [...prev, data.tag])
if (onTagCreated) onTagCreated(data.tag)
setFormData(prev => ({ ...prev, tags: [...prev.tags, data.tag.id] }))
setNewTagName('')
setShowNewTag(false)
toast.success('Tag created')
} catch (err) {
toast.error('Failed to create tag')
}
}
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={isEditing ? 'Edit Place' : 'Add Place'}
size="xl"
footer={
<div className="flex gap-3 justify-end">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm text-slate-600 border border-slate-200 rounded-lg hover:bg-slate-50"
>
Cancel
</button>
<button
onClick={handleSubmit}
disabled={isLoading}
className="px-4 py-2 text-sm bg-slate-900 hover:bg-slate-700 disabled:bg-slate-400 text-white rounded-lg flex items-center gap-2"
>
{isLoading ? (
<>
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin"></div>
Saving...
</>
) : isEditing ? 'Save Changes' : 'Add Place'}
</button>
</div>
}
>
<div 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>
)}
{/* Google Maps search — always visible when API key is set */}
{user?.maps_api_key && (
<div className="bg-slate-50 rounded-xl p-3 border border-slate-200">
<div className="flex gap-2">
<div className="relative flex-1">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<input
type="text"
value={mapQuery}
onChange={e => setMapQuery(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleMapSearch()}
placeholder="Google Maps suchen..."
className="w-full pl-8 pr-3 py-2 text-sm border border-slate-200 rounded-lg focus:ring-2 focus:ring-slate-400 focus:border-transparent bg-white"
/>
</div>
<button
onClick={handleMapSearch}
disabled={mapSearching}
className="px-3 py-2 bg-slate-900 text-white text-sm rounded-lg hover:bg-slate-700 disabled:opacity-50"
>
{mapSearching ? <Loader className="w-4 h-4 animate-spin" /> : 'Suchen'}
</button>
</div>
{mapResults.length > 0 && (
<div className="bg-white rounded-lg border border-slate-200 max-h-48 overflow-y-auto mt-2">
{mapResults.map((p, i) => (
<button
key={p.google_place_id || i}
onClick={() => selectMapPlace(p)}
className="w-full text-left px-3 py-2.5 hover:bg-slate-50 transition-colors border-b border-slate-100 last:border-0"
>
<p className="text-sm font-medium text-slate-900">{p.name}</p>
<p className="text-xs text-slate-500 truncate flex items-center gap-1 mt-0.5">
<MapPin className="w-3 h-3" />
{p.address}
</p>
{p.rating && (
<p className="text-xs text-amber-600 mt-0.5"> {p.rating}</p>
)}
</button>
))}
</div>
)}
</div>
)}
{/* Name */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">
Name <span className="text-red-500">*</span>
</label>
<input
type="text"
value={formData.name}
onChange={e => update('name', e.target.value)}
required
placeholder="e.g. Eiffel Tower"
className="w-full px-3 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"
/>
</div>
{/* Description */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">Description</label>
<textarea
value={formData.description}
onChange={e => update('description', e.target.value)}
placeholder="Notes about this place..."
rows={2}
className="w-full px-3 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 resize-none"
/>
</div>
{/* Address */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">Address</label>
<input
type="text"
value={formData.address}
onChange={e => update('address', e.target.value)}
placeholder="Street address"
className="w-full px-3 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"
/>
</div>
{/* Lat / Lng */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">Latitude</label>
<input
type="number"
step="any"
value={formData.lat}
onChange={e => update('lat', e.target.value)}
placeholder="e.g. 48.8584"
className="w-full px-3 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"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">Longitude</label>
<input
type="number"
step="any"
value={formData.lng}
onChange={e => update('lng', e.target.value)}
placeholder="e.g. 2.2945"
className="w-full px-3 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"
/>
</div>
</div>
{/* Category */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">Category</label>
<div className="flex gap-2">
<select
value={formData.category_id}
onChange={e => update('category_id', e.target.value)}
className="flex-1 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"
>
<option value="">No category</option>
{categories.map(cat => (
<option key={cat.id} value={cat.id}>{cat.name}</option>
))}
</select>
<button
type="button"
onClick={() => setShowNewCategory(!showNewCategory)}
className="px-3 py-2.5 border border-slate-300 rounded-lg text-slate-500 hover:text-slate-700 hover:border-slate-400 transition-colors"
title="Create new category"
>
<Plus className="w-4 h-4" />
</button>
</div>
{showNewCategory && (
<div className="mt-2 flex gap-2">
<input
type="text"
value={newCategoryName}
onChange={e => setNewCategoryName(e.target.value)}
placeholder="Category name"
className="flex-1 px-3 py-2 text-sm border border-slate-300 rounded-lg focus:ring-2 focus:ring-slate-400 focus:border-transparent"
/>
<input
type="color"
value={newCategoryColor}
onChange={e => setNewCategoryColor(e.target.value)}
className="w-10 h-10 border border-slate-300 rounded-lg cursor-pointer p-1"
title="Category color"
/>
<button
type="button"
onClick={handleCreateCategory}
className="px-3 py-2 bg-slate-900 text-white text-sm rounded-lg hover:bg-slate-700"
>
Add
</button>
</div>
)}
</div>
{/* Tags */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">Tags</label>
<div className="flex flex-wrap gap-1.5 mb-2">
{tags.map(tag => (
<button
key={tag.id}
type="button"
onClick={() => toggleTag(tag.id)}
className={`text-xs px-2.5 py-1 rounded-full font-medium transition-all ${
formData.tags.includes(tag.id)
? 'text-white shadow-sm ring-2 ring-offset-1'
: 'text-white opacity-50 hover:opacity-80'
}`}
style={{
backgroundColor: tag.color || '#374151',
ringColor: formData.tags.includes(tag.id) ? tag.color : 'transparent'
}}
>
{tag.name}
</button>
))}
<button
type="button"
onClick={() => setShowNewTag(!showNewTag)}
className="text-xs px-2.5 py-1 border border-dashed border-slate-300 rounded-full text-slate-500 hover:border-slate-400 hover:text-slate-700 transition-colors"
>
<Plus className="inline w-3 h-3 mr-0.5" />
New tag
</button>
</div>
{showNewTag && (
<div className="flex gap-2">
<input
type="text"
value={newTagName}
onChange={e => setNewTagName(e.target.value)}
placeholder="Tag name"
className="flex-1 px-3 py-2 text-sm border border-slate-300 rounded-lg focus:ring-2 focus:ring-slate-400 focus:border-transparent"
/>
<input
type="color"
value={newTagColor}
onChange={e => setNewTagColor(e.target.value)}
className="w-10 h-10 border border-slate-300 rounded-lg cursor-pointer p-1"
/>
<button
type="button"
onClick={handleCreateTag}
className="px-3 py-2 bg-slate-900 text-white text-sm rounded-lg hover:bg-slate-700"
>
Add
</button>
</div>
)}
</div>
{/* Time & Reservation */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">Visit Time</label>
<input
type="time"
value={formData.place_time}
onChange={e => update('place_time', 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"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">Reservation</label>
<select
value={formData.reservation_status}
onChange={e => update('reservation_status', 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"
>
{STATUSES.map(s => <option key={s.value} value={s.value}>{s.label}</option>)}
</select>
</div>
</div>
{/* Reservation details */}
{formData.reservation_status !== 'none' && (
<div className="space-y-3 p-3 bg-amber-50 rounded-lg border border-amber-200">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">Reservation Date & Time</label>
<input
type="datetime-local"
value={formData.reservation_datetime}
onChange={e => update('reservation_datetime', 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"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">Reservation Notes</label>
<textarea
value={formData.reservation_notes}
onChange={e => update('reservation_notes', e.target.value)}
placeholder="Confirmation number, special requests..."
rows={2}
className="w-full px-3 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 resize-none bg-white"
/>
</div>
</div>
)}
{/* Website */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">Website</label>
<input
type="url"
value={formData.website}
onChange={e => update('website', e.target.value)}
placeholder="https://..."
className="w-full px-3 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"
/>
</div>
</div>
</Modal>
)
}

View File

@@ -0,0 +1,149 @@
import React from 'react'
import { useSortable } from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { GripVertical, X, Edit2, Clock, DollarSign, CheckCircle, Clock3, MapPin } from 'lucide-react'
export default function AssignedPlaceItem({ assignment, dayId, onRemove, onEdit }) {
const { place } = assignment
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({
id: `assignment-${assignment.id}`,
data: {
type: 'assignment',
dayId: dayId,
assignment,
},
})
const style = {
transform: CSS.Transform.toString(transform),
transition,
}
const reservationIcon = () => {
if (place.reservation_status === 'confirmed') {
return <CheckCircle className="w-3.5 h-3.5 text-emerald-500" title="Confirmed" />
}
if (place.reservation_status === 'pending') {
return <Clock3 className="w-3.5 h-3.5 text-amber-500" title="Pending" />
}
return null
}
return (
<div
ref={setNodeRef}
style={style}
className={`
group bg-white border rounded-lg p-2.5 transition-all
${isDragging
? 'opacity-40 border-slate-300 shadow-lg'
: 'border-slate-200 hover:border-slate-300 hover:shadow-sm'
}
`}
>
<div className="flex items-start gap-2">
{/* Drag handle */}
<button
{...attributes}
{...listeners}
className="drag-handle mt-0.5 p-0.5 text-slate-300 hover:text-slate-500 flex-shrink-0 rounded touch-none"
tabIndex={-1}
>
<GripVertical className="w-4 h-4" />
</button>
{/* Content */}
<div className="flex-1 min-w-0">
{/* Name row */}
<div className="flex items-center gap-1.5 mb-1">
{place.category && (
<div
className="w-2 h-2 rounded-full flex-shrink-0"
style={{ backgroundColor: place.category.color || '#6366f1' }}
/>
)}
<span className="text-sm font-medium text-slate-800 truncate">{place.name}</span>
{reservationIcon()}
</div>
{/* Time & price row */}
<div className="flex items-center gap-2 mb-1">
{place.place_time && (
<span className="flex items-center gap-1 text-xs text-slate-600 bg-slate-50 px-1.5 py-0.5 rounded">
<Clock className="w-3 h-3" />
{place.place_time}
</span>
)}
{place.price != null && (
<span className="flex items-center gap-1 text-xs text-emerald-700 bg-emerald-50 px-1.5 py-0.5 rounded">
<DollarSign className="w-3 h-3" />
{Number(place.price).toLocaleString()} {place.currency || ''}
</span>
)}
</div>
{/* Address */}
{place.address && (
<p className="text-xs text-slate-400 truncate flex items-center gap-1">
<MapPin className="w-3 h-3 flex-shrink-0" />
{place.address}
</p>
)}
{/* Category badge */}
{place.category && (
<span
className="inline-block mt-1 text-xs px-1.5 py-0.5 rounded text-white text-[10px] font-medium"
style={{ backgroundColor: place.category.color || '#6366f1' }}
>
{place.category.name}
</span>
)}
{/* Tags */}
{place.tags && place.tags.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1">
{place.tags.map(tag => (
<span
key={tag.id}
className="text-[10px] px-1.5 py-0.5 rounded-full text-white font-medium"
style={{ backgroundColor: tag.color || '#6366f1' }}
>
{tag.name}
</span>
))}
</div>
)}
</div>
{/* Action buttons */}
<div className="flex flex-col gap-1 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0">
{onEdit && (
<button
onClick={() => onEdit(place)}
className="p-1 text-slate-400 hover:text-slate-700 hover:bg-slate-100 rounded transition-colors"
title="Edit place"
>
<Edit2 className="w-3.5 h-3.5" />
</button>
)}
<button
onClick={() => onRemove(assignment.id)}
className="p-1 text-slate-400 hover:text-red-500 hover:bg-red-50 rounded transition-colors"
title="Remove from day"
>
<X className="w-3.5 h-3.5" />
</button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,177 @@
import React, { useState } from 'react'
import { useDroppable } from '@dnd-kit/core'
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'
import AssignedPlaceItem from './AssignedPlaceItem'
import { ChevronDown, ChevronUp, Plus, FileText, Package, DollarSign } from 'lucide-react'
export default function DayColumn({
day,
assignments,
tripId,
onRemoveAssignment,
onEditPlace,
onQuickAdd,
}) {
const [isCollapsed, setIsCollapsed] = useState(false)
const [showNotes, setShowNotes] = useState(false)
const [notes, setNotes] = useState(day.notes || '')
const [notesEditing, setNotesEditing] = useState(false)
const { isOver, setNodeRef } = useDroppable({
id: `day-${day.id}`,
data: {
type: 'day',
dayId: day.id,
},
})
const sortableIds = (assignments || []).map(a => `assignment-${a.id}`)
const totalCost = (assignments || []).reduce((sum, a) => {
return sum + (a.place?.price ? Number(a.place.price) : 0)
}, 0)
const formatDate = (dateStr) => {
if (!dateStr) return null
const d = new Date(dateStr + 'T00:00:00')
return d.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' })
}
return (
<div
className={`
flex-shrink-0 w-72 flex flex-col rounded-xl border-2 transition-all duration-150
${isOver
? 'border-slate-400 bg-slate-50 shadow-lg shadow-slate-100'
: 'border-transparent bg-white shadow-sm'
}
`}
>
{/* Header */}
<div
className={`
px-3 py-2.5 border-b flex items-center gap-2 rounded-t-xl
${isOver ? 'border-slate-200 bg-slate-50' : 'border-slate-100 bg-slate-50'}
`}
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<span className="text-sm font-bold text-slate-900">Day {day.day_number}</span>
<span className="text-xs bg-slate-100 text-slate-600 px-1.5 py-0.5 rounded-full font-medium">
{assignments?.length || 0}
</span>
</div>
{day.date && (
<p className="text-xs text-slate-500 mt-0.5">{formatDate(day.date)}</p>
)}
</div>
<div className="flex items-center gap-1">
{totalCost > 0 && (
<span className="flex items-center gap-0.5 text-xs text-emerald-700 bg-emerald-50 px-1.5 py-0.5 rounded">
<DollarSign className="w-3 h-3" />
{totalCost.toLocaleString()}
</span>
)}
<button
onClick={() => setShowNotes(!showNotes)}
className={`p-1 rounded transition-colors ${showNotes ? 'text-slate-700 bg-slate-100' : 'text-slate-400 hover:text-slate-600 hover:bg-slate-100'}`}
title="Notes"
>
<FileText className="w-4 h-4" />
</button>
<button
onClick={() => setIsCollapsed(!isCollapsed)}
className="p-1 text-slate-400 hover:text-slate-600 hover:bg-slate-100 rounded transition-colors"
>
{isCollapsed ? <ChevronDown className="w-4 h-4" /> : <ChevronUp className="w-4 h-4" />}
</button>
</div>
</div>
{/* Notes area */}
{showNotes && (
<div className="px-3 py-2 border-b border-slate-100 bg-amber-50">
<textarea
value={notes}
onChange={e => setNotes(e.target.value)}
onBlur={() => setNotesEditing(false)}
onFocus={() => setNotesEditing(true)}
placeholder="Add notes for this day..."
rows={2}
className="w-full text-xs text-slate-600 bg-transparent resize-none focus:outline-none placeholder-amber-400"
/>
{notesEditing && (
<div className="flex gap-2 mt-1">
<button
onMouseDown={(e) => {
e.preventDefault()
// Parent will handle save via onUpdateNotes if passed
}}
className="text-xs text-slate-600 hover:text-slate-900"
>
Save
</button>
</div>
)}
</div>
)}
{/* Assignments list */}
{!isCollapsed && (
<div
ref={setNodeRef}
className={`
flex-1 p-2 flex flex-col gap-2 min-h-24 transition-colors duration-150
${isOver ? 'bg-slate-50' : 'bg-transparent'}
`}
>
{assignments && assignments.length > 0 ? (
<SortableContext items={sortableIds} strategy={verticalListSortingStrategy}>
{assignments.map(assignment => (
<AssignedPlaceItem
key={assignment.id}
assignment={assignment}
dayId={day.id}
onRemove={(id) => onRemoveAssignment(day.id, id)}
onEdit={onEditPlace}
/>
))}
</SortableContext>
) : (
<div className={`
flex-1 flex flex-col items-center justify-center py-6 rounded-lg border-2 border-dashed
text-xs text-center transition-colors
${isOver
? 'border-slate-400 bg-slate-100 text-slate-500'
: 'border-slate-200 text-slate-400'
}
`}>
<Package className="w-8 h-8 mb-2 opacity-50" />
<p className="font-medium">Drop places here</p>
<p className="text-[10px] mt-0.5 opacity-70">or drag from the left panel</p>
</div>
)}
{/* Quick add button */}
<button
onClick={() => onQuickAdd(day)}
className="flex items-center justify-center gap-1 py-1.5 text-xs text-slate-400 hover:text-slate-700 hover:bg-slate-50 rounded-lg border border-dashed border-slate-200 hover:border-slate-300 transition-all mt-1"
>
<Plus className="w-3.5 h-3.5" />
Add place
</button>
</div>
)}
{isCollapsed && (
<div
className="px-3 py-2 text-xs text-slate-400 cursor-pointer hover:bg-slate-50"
onClick={() => setIsCollapsed(false)}
>
{assignments?.length || 0} place{(assignments?.length || 0) !== 1 ? 's' : ''} click to expand
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,877 @@
import React, { useState, useEffect, useRef } from 'react'
import ReactDOM from 'react-dom'
import { ChevronDown, ChevronRight, ChevronUp, Navigation, RotateCcw, ExternalLink, Clock, AlertCircle, CheckCircle2, Pencil, GripVertical, Ticket, Plus, FileText, Check, Trash2, Info, MapPin, Star, Heart, Camera, Lightbulb, Flag, Bookmark, Train, Bus, Plane, Car, Ship, Coffee, ShoppingBag, AlertTriangle, FileDown } from 'lucide-react'
import { downloadTripPDF } from '../PDF/TripPDF'
import { calculateRoute, generateGoogleMapsUrl, optimizeRoute } from '../Map/RouteCalculator'
import PlaceAvatar from '../shared/PlaceAvatar'
import WeatherWidget from '../Weather/WeatherWidget'
import { useToast } from '../shared/Toast'
import { getCategoryIcon } from '../shared/categoryIcons'
import { useTripStore } from '../../store/tripStore'
import { useSettingsStore } from '../../store/settingsStore'
import { useTranslation } from '../../i18n'
function formatDate(dateStr, locale) {
if (!dateStr) return null
return new Date(dateStr + 'T00:00:00').toLocaleDateString(locale, {
weekday: 'short', day: 'numeric', month: 'short',
})
}
function formatTime(timeStr, locale, timeFormat) {
if (!timeStr) return ''
try {
const [h, m] = timeStr.split(':').map(Number)
if (timeFormat === '12h') {
const period = h >= 12 ? 'PM' : 'AM'
const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h
return `${h12}:${String(m).padStart(2, '0')} ${period}`
}
const str = `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`
return locale?.startsWith('de') ? `${str} Uhr` : str
} catch { return timeStr }
}
function dayTotalCost(dayId, assignments, currency) {
const da = assignments[String(dayId)] || []
const total = da.reduce((s, a) => s + (parseFloat(a.place?.price) || 0), 0)
return total > 0 ? `${total.toFixed(0)} ${currency}` : null
}
const NOTE_ICONS = [
{ id: 'FileText', Icon: FileText },
{ id: 'Info', Icon: Info },
{ id: 'Clock', Icon: Clock },
{ id: 'MapPin', Icon: MapPin },
{ id: 'Navigation', Icon: Navigation },
{ id: 'Train', Icon: Train },
{ id: 'Plane', Icon: Plane },
{ id: 'Bus', Icon: Bus },
{ id: 'Car', Icon: Car },
{ id: 'Ship', Icon: Ship },
{ id: 'Coffee', Icon: Coffee },
{ id: 'Ticket', Icon: Ticket },
{ id: 'Star', Icon: Star },
{ id: 'Heart', Icon: Heart },
{ id: 'Camera', Icon: Camera },
{ id: 'Flag', Icon: Flag },
{ id: 'Lightbulb', Icon: Lightbulb },
{ id: 'AlertTriangle', Icon: AlertTriangle },
{ id: 'ShoppingBag', Icon: ShoppingBag },
{ id: 'Bookmark', Icon: Bookmark },
]
const NOTE_ICON_MAP = Object.fromEntries(NOTE_ICONS.map(({ id, Icon }) => [id, Icon]))
function getNoteIcon(iconId) { return NOTE_ICON_MAP[iconId] || FileText }
const TYPE_ICONS = {
flight: '✈️', hotel: '🏨', restaurant: '🍽️', train: '🚆',
car: '🚗', cruise: '🚢', event: '🎫', other: '📋',
}
export default function DayPlanSidebar({
tripId,
trip, days, places, categories, assignments,
selectedDayId, selectedPlaceId,
onSelectDay, onPlaceClick,
onReorder, onUpdateDayTitle, onRouteCalculated,
onAssignToDay,
reservations = [],
onAddReservation,
}) {
const toast = useToast()
const { t, locale } = useTranslation()
const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h'
const tripStore = useTripStore()
const TRANSPORT_MODES = [
{ value: 'driving', label: t('dayplan.transport.car') },
{ value: 'walking', label: t('dayplan.transport.walk') },
{ value: 'cycling', label: t('dayplan.transport.bike') },
]
const dayNotes = tripStore.dayNotes || {}
const [expandedDays, setExpandedDays] = useState(() => new Set(days.map(d => d.id)))
const [editingDayId, setEditingDayId] = useState(null)
const [editTitle, setEditTitle] = useState('')
const [transportMode, setTransportMode] = useState('driving')
const [isCalculating, setIsCalculating] = useState(false)
const [routeInfo, setRouteInfo] = useState(null)
const [draggingId, setDraggingId] = useState(null)
const [dropTargetKey, setDropTargetKey] = useState(null)
const [dragOverDayId, setDragOverDayId] = useState(null)
const [hoveredId, setHoveredId] = useState(null)
const [noteUi, setNoteUi] = useState({}) // { [dayId]: { mode, text, time, noteId?, sortOrder? } }
const inputRef = useRef(null)
const noteInputRef = useRef(null)
const dragDataRef = useRef(null) // Speichert Drag-Daten als Backup (dataTransfer geht bei Re-Render verloren)
const currency = trip?.currency || 'EUR'
// Drag-Daten aus dataTransfer, Ref oder window lesen (dataTransfer geht bei Re-Render verloren)
const getDragData = (e) => {
const dt = e?.dataTransfer
// Interner Drag hat Vorrang (Ref wird nur bei assignmentId/noteId gesetzt)
if (dragDataRef.current) {
return {
placeId: '',
assignmentId: dragDataRef.current.assignmentId || '',
noteId: dragDataRef.current.noteId || '',
fromDayId: parseInt(dragDataRef.current.fromDayId) || 0,
}
}
// Externer Drag (aus PlacesSidebar)
const ext = window.__dragData || {}
const placeId = dt?.getData('placeId') || ext.placeId || ''
return { placeId, assignmentId: '', noteId: '', fromDayId: 0 }
}
useEffect(() => {
setExpandedDays(prev => new Set([...prev, ...days.map(d => d.id)]))
}, [days.length])
useEffect(() => {
if (editingDayId && inputRef.current) inputRef.current.focus()
}, [editingDayId])
// Globaler Aufräum-Listener: wenn ein Drag endet ohne Drop, alles zurücksetzen
useEffect(() => {
const cleanup = () => {
setDraggingId(null)
setDropTargetKey(null)
setDragOverDayId(null)
dragDataRef.current = null
window.__dragData = null
}
document.addEventListener('dragend', cleanup)
return () => document.removeEventListener('dragend', cleanup)
}, [])
const toggleDay = (dayId, e) => {
e.stopPropagation()
setExpandedDays(prev => {
const n = new Set(prev)
n.has(dayId) ? n.delete(dayId) : n.add(dayId)
return n
})
}
const getDayAssignments = (dayId) =>
(assignments[String(dayId)] || []).slice().sort((a, b) => a.order_index - b.order_index)
const getMergedItems = (dayId) => {
const da = getDayAssignments(dayId)
const dn = (dayNotes[String(dayId)] || []).slice().sort((a, b) => a.sort_order - b.sort_order)
return [
...da.map(a => ({ type: 'place', sortKey: a.order_index, data: a })),
...dn.map(n => ({ type: 'note', sortKey: n.sort_order, data: n })),
].sort((a, b) => a.sortKey - b.sortKey)
}
const openAddNote = (dayId, e) => {
e?.stopPropagation()
const merged = getMergedItems(dayId)
const maxKey = merged.length > 0 ? Math.max(...merged.map(i => i.sortKey)) : -1
setNoteUi(prev => ({ ...prev, [dayId]: { mode: 'add', text: '', time: '', icon: 'FileText', sortOrder: maxKey + 1 } }))
if (!expandedDays.has(dayId)) setExpandedDays(prev => new Set([...prev, dayId]))
setTimeout(() => noteInputRef.current?.focus(), 50)
}
const openEditNote = (dayId, note, e) => {
e?.stopPropagation()
setNoteUi(prev => ({ ...prev, [dayId]: { mode: 'edit', noteId: note.id, text: note.text, time: note.time || '', icon: note.icon || 'FileText' } }))
setTimeout(() => noteInputRef.current?.focus(), 50)
}
const cancelNote = (dayId) => {
setNoteUi(prev => { const n = { ...prev }; delete n[dayId]; return n })
}
const saveNote = async (dayId) => {
const ui = noteUi[dayId]
if (!ui?.text?.trim()) return
try {
if (ui.mode === 'add') {
await tripStore.addDayNote(tripId, dayId, { text: ui.text.trim(), time: ui.time || null, icon: ui.icon || 'FileText', sort_order: ui.sortOrder })
} else {
await tripStore.updateDayNote(tripId, dayId, ui.noteId, { text: ui.text.trim(), time: ui.time || null, icon: ui.icon || 'FileText' })
}
cancelNote(dayId)
} catch (err) { toast.error(err.message) }
}
const deleteNote = async (dayId, noteId, e) => {
e?.stopPropagation()
try { await tripStore.deleteDayNote(tripId, dayId, noteId) }
catch (err) { toast.error(err.message) }
}
const handleMergedDrop = async (dayId, fromType, fromId, toType, toId) => {
const m = getMergedItems(dayId)
const fromIdx = m.findIndex(i => i.type === fromType && i.data.id === fromId)
const toIdx = m.findIndex(i => i.type === toType && i.data.id === toId)
if (fromIdx === -1 || toIdx === -1 || fromIdx === toIdx) return
// Neue Reihenfolge erstellen — VOR dem Ziel einfügen (Standardkonvention)
const newOrder = [...m]
const [moved] = newOrder.splice(fromIdx, 1)
const adjustedTo = fromIdx < toIdx ? toIdx - 1 : toIdx
newOrder.splice(adjustedTo, 0, moved)
// Orte: neuer order_index über onReorder
const assignmentIds = newOrder.filter(i => i.type === 'place').map(i => i.data.id)
// Notizen: sort_order muss ZWISCHEN den umgebenden order_indices der Orte liegen, niemals gleich sein.
// Formel: Notiz zwischen placesBefore-1 und placesBefore ergibt (placesBefore - 1) + rank/(count+1)
// z.B. einzelne Notiz nach 2 Orten → (2-1) + 0.5 = 1.5 (zwischen order_index 1 und 2)
const groups = {}
let pc = 0
newOrder.forEach(item => {
if (item.type === 'place') { pc++ }
else { if (!groups[pc]) groups[pc] = []; groups[pc].push(item.data.id) }
})
const noteChanges = []
Object.entries(groups).forEach(([pb, ids]) => {
ids.forEach((id, i) => {
noteChanges.push({ id, sort_order: (Number(pb) - 1) + (i + 1) / (ids.length + 1) })
})
})
try {
if (assignmentIds.length) await onReorder(dayId, assignmentIds)
for (const n of noteChanges) {
await tripStore.updateDayNote(tripId, dayId, n.id, { sort_order: n.sort_order })
}
} catch (err) { toast.error(err.message) }
setDraggingId(null)
setDropTargetKey(null)
dragDataRef.current = null
}
const moveNote = async (dayId, noteId, direction) => {
const merged = getMergedItems(dayId)
const idx = merged.findIndex(i => i.type === 'note' && i.data.id === noteId)
if (idx === -1) return
let newSortOrder
if (direction === 'up') {
if (idx === 0) return
newSortOrder = idx >= 2 ? (merged[idx - 2].sortKey + merged[idx - 1].sortKey) / 2 : merged[idx - 1].sortKey - 1
} else {
if (idx >= merged.length - 1) return
newSortOrder = idx < merged.length - 2 ? (merged[idx + 1].sortKey + merged[idx + 2].sortKey) / 2 : merged[idx + 1].sortKey + 1
}
try { await tripStore.updateDayNote(tripId, dayId, noteId, { sort_order: newSortOrder }) }
catch (err) { toast.error(err.message) }
}
const startEditTitle = (day, e) => {
e.stopPropagation()
setEditTitle(day.title || '')
setEditingDayId(day.id)
}
const saveTitle = async (dayId) => {
setEditingDayId(null)
await onUpdateDayTitle?.(dayId, editTitle.trim())
}
const handleCalculateRoute = async () => {
if (!selectedDayId) return
const da = getDayAssignments(selectedDayId)
const waypoints = da.map(a => a.place).filter(p => p?.lat && p?.lng).map(p => ({ lat: p.lat, lng: p.lng }))
if (waypoints.length < 2) { toast.error(t('dayplan.toast.needTwoPlaces')); return }
setIsCalculating(true)
try {
const result = await calculateRoute(waypoints, transportMode)
// Luftlinien zwischen Wegpunkten anzeigen
const lineCoords = waypoints.map(p => [p.lat, p.lng])
setRouteInfo({ distance: result.distanceText, duration: result.durationText })
onRouteCalculated?.({ ...result, coordinates: lineCoords })
} catch { toast.error(t('dayplan.toast.routeError')) }
finally { setIsCalculating(false) }
}
const handleOptimize = async () => {
if (!selectedDayId) return
const da = getDayAssignments(selectedDayId)
if (da.length < 3) return
const withCoords = da.map(a => a.place).filter(p => p?.lat && p?.lng)
const optimized = optimizeRoute(withCoords)
const reorderedIds = optimized.map(p => da.find(a => a.place?.id === p.id)?.id).filter(Boolean)
for (const a of da) { if (!reorderedIds.includes(a.id)) reorderedIds.push(a.id) }
await onReorder(selectedDayId, reorderedIds)
toast.success(t('dayplan.toast.routeOptimized'))
}
const handleGoogleMaps = () => {
if (!selectedDayId) return
const da = getDayAssignments(selectedDayId)
const url = generateGoogleMapsUrl(da.map(a => a.place).filter(p => p?.lat && p?.lng))
if (url) window.open(url, '_blank')
else toast.error(t('dayplan.toast.noGeoPlaces'))
}
const handleDropOnDay = (e, dayId) => {
e.preventDefault()
setDragOverDayId(null)
const { placeId, assignmentId, noteId, fromDayId } = getDragData(e)
if (placeId) {
onAssignToDay?.(parseInt(placeId), dayId)
} else if (assignmentId && fromDayId !== dayId) {
tripStore.moveAssignment(tripId, Number(assignmentId), fromDayId, dayId).catch(err => toast.error(err.message))
} else if (noteId && fromDayId !== dayId) {
tripStore.moveDayNote(tripId, fromDayId, dayId, Number(noteId)).catch(err => toast.error(err.message))
}
setDraggingId(null)
setDropTargetKey(null)
dragDataRef.current = null
window.__dragData = null
}
const handleDropOnRow = (e, dayId, toIdx) => {
e.preventDefault()
e.stopPropagation()
setDragOverDayId(null)
const placeId = e.dataTransfer.getData('placeId')
const fromAssignmentId = e.dataTransfer.getData('assignmentId')
if (placeId) {
onAssignToDay?.(parseInt(placeId), dayId)
} else if (fromAssignmentId) {
const da = getDayAssignments(dayId)
const fromIdx = da.findIndex(a => String(a.id) === fromAssignmentId)
if (fromIdx === -1 || fromIdx === toIdx) { setDraggingId(null); dragDataRef.current = null; return }
const ids = da.map(a => a.id)
const [removed] = ids.splice(fromIdx, 1)
ids.splice(toIdx, 0, removed)
onReorder(dayId, ids)
}
setDraggingId(null)
}
const totalCost = days.reduce((s, d) => {
const da = assignments[String(d.id)] || []
return s + da.reduce((s2, a) => s2 + (parseFloat(a.place?.price) || 0), 0)
}, 0)
// Bester verfügbarer Standort für Wetter: zugewiesene Orte zuerst, dann beliebiger Reiseort
const anyGeoAssignment = Object.values(assignments).flatMap(da => da).find(a => a.place?.lat && a.place?.lng)
const anyGeoPlace = anyGeoAssignment || (places || []).find(p => p.lat && p.lng)
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', position: 'relative', fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }}>
{/* Reise-Titel */}
<div style={{ padding: '16px 16px 12px', borderBottom: '1px solid var(--border-faint)', flexShrink: 0 }}>
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 8 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 600, fontSize: 14, color: 'var(--text-primary)', lineHeight: '1.3' }}>{trip?.title}</div>
{(trip?.start_date || trip?.end_date) && (
<div style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: 3 }}>
{[trip.start_date, trip.end_date].filter(Boolean).map(d => new Date(d + 'T00:00:00').toLocaleDateString(locale, { day: 'numeric', month: 'short' })).join(' ')}
{days.length > 0 && ` · ${days.length} ${t('dayplan.days')}`}
</div>
)}
</div>
<button
onClick={async () => {
const flatNotes = Object.entries(dayNotes).flatMap(([dayId, notes]) =>
notes.map(n => ({ ...n, day_id: Number(dayId) }))
)
try {
await downloadTripPDF({ trip, days, places, assignments, categories, dayNotes: flatNotes, t, locale })
} catch (e) {
console.error('PDF error:', e)
toast.error(t('dayplan.pdfError') + ': ' + (e?.message || String(e)))
}
}}
title={t('dayplan.pdfTooltip')}
style={{
flexShrink: 0, display: 'flex', alignItems: 'center', gap: 5,
padding: '5px 10px', borderRadius: 8, border: 'none',
background: 'var(--accent)', color: 'var(--accent-text)', fontSize: 11, fontWeight: 500,
cursor: 'pointer', fontFamily: 'inherit',
}}
>
<FileDown size={13} strokeWidth={2} />
{t('dayplan.pdf')}
</button>
</div>
</div>
{/* Tagesliste */}
<div className="scroll-container" style={{ flex: 1, overflowY: 'auto', minHeight: 0, scrollbarWidth: 'thin', scrollbarColor: 'var(--scrollbar-thumb) transparent' }}>
{days.map((day, index) => {
const isSelected = selectedDayId === day.id
const isExpanded = expandedDays.has(day.id)
const da = getDayAssignments(day.id)
const cost = dayTotalCost(day.id, assignments, currency)
const formattedDate = formatDate(day.date, locale)
const loc = da.find(a => a.place?.lat && a.place?.lng)
const isDragTarget = dragOverDayId === day.id
const merged = getMergedItems(day.id)
const dayNoteUi = noteUi[day.id]
const placeItems = merged.filter(i => i.type === 'place')
return (
<div key={day.id} style={{ borderBottom: '1px solid var(--border-faint)' }}>
{/* Tages-Header — akzeptiert Drops aus der PlacesSidebar */}
<div
onClick={() => onSelectDay(isSelected ? null : day.id)}
onDragOver={e => { e.preventDefault(); setDragOverDayId(day.id) }}
onDragLeave={e => { if (!e.currentTarget.contains(e.relatedTarget)) setDragOverDayId(null) }}
onDrop={e => handleDropOnDay(e, day.id)}
style={{
display: 'flex', alignItems: 'center', gap: 10,
padding: '11px 14px 11px 16px',
cursor: 'pointer',
background: isDragTarget ? 'rgba(17,24,39,0.07)' : (isSelected ? 'var(--bg-hover)' : 'transparent'),
transition: 'background 0.12s',
userSelect: 'none',
outline: isDragTarget ? '2px dashed rgba(17,24,39,0.25)' : 'none',
outlineOffset: -2,
borderRadius: isDragTarget ? 8 : 0,
}}
onMouseEnter={e => { if (!isSelected && !isDragTarget) e.currentTarget.style.background = 'var(--bg-hover)' }}
onMouseLeave={e => { if (!isSelected) e.currentTarget.style.background = isDragTarget ? 'rgba(17,24,39,0.07)' : 'transparent' }}
>
{/* Tages-Badge */}
<div style={{
width: 26, height: 26, borderRadius: '50%', flexShrink: 0,
background: isSelected ? 'var(--accent)' : 'var(--bg-hover)',
color: isSelected ? 'var(--accent-text)' : 'var(--text-muted)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 11, fontWeight: 700,
}}>
{index + 1}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
{editingDayId === day.id ? (
<input
ref={inputRef}
value={editTitle}
onChange={e => setEditTitle(e.target.value)}
onBlur={() => saveTitle(day.id)}
onKeyDown={e => { if (e.key === 'Enter') saveTitle(day.id); if (e.key === 'Escape') setEditingDayId(null) }}
onClick={e => e.stopPropagation()}
style={{
width: '100%', border: 'none', outline: 'none',
fontSize: 13, fontWeight: 600, color: 'var(--text-primary)',
background: 'transparent', padding: 0, fontFamily: 'inherit',
borderBottom: '1.5px solid var(--text-primary)',
}}
/>
) : (
<div style={{ display: 'flex', alignItems: 'center', gap: 5 }}>
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{day.title || t('dayplan.dayN', { n: index + 1 })}
</span>
<button
onClick={e => startEditTitle(day, e)}
style={{ flexShrink: 0, background: 'none', border: 'none', padding: '2px', cursor: 'pointer', opacity: 0.35, display: 'flex', alignItems: 'center' }}
>
<Pencil size={10} strokeWidth={1.8} color="var(--text-secondary)" />
</button>
</div>
)}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 2, flexWrap: 'wrap' }}>
{formattedDate && <span style={{ fontSize: 11, color: 'var(--text-faint)' }}>{formattedDate}</span>}
{cost && <span style={{ fontSize: 11, color: '#059669' }}>{cost}</span>}
{day.date && anyGeoPlace && (() => {
const wLat = loc?.place.lat ?? anyGeoPlace?.place?.lat ?? anyGeoPlace?.lat
const wLng = loc?.place.lng ?? anyGeoPlace?.place?.lng ?? anyGeoPlace?.lng
return <WeatherWidget lat={wLat} lng={wLng} date={day.date} compact />
})()}
</div>
</div>
<button
onClick={e => openAddNote(day.id, e)}
title={t('dayplan.addNote')}
style={{ flexShrink: 0, background: 'none', border: 'none', padding: 4, cursor: 'pointer', display: 'flex', alignItems: 'center', color: 'var(--text-faint)' }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}
>
<FileText size={13} strokeWidth={2} />
</button>
<button
onClick={e => toggleDay(day.id, e)}
style={{ flexShrink: 0, background: 'none', border: 'none', padding: 4, cursor: 'pointer', display: 'flex', alignItems: 'center', color: 'var(--text-faint)' }}
>
{isExpanded ? <ChevronDown size={15} strokeWidth={2} /> : <ChevronRight size={15} strokeWidth={2} />}
</button>
</div>
{/* Aufgeklappte Orte + Notizen */}
{isExpanded && (
<div
style={{ background: 'var(--bg-hover)' }}
onDragOver={e => { e.preventDefault(); if (draggingId) setDropTargetKey(`end-${day.id}`) }}
onDrop={e => {
e.preventDefault()
const { assignmentId, noteId, fromDayId } = getDragData(e)
if (!assignmentId && !noteId) { dragDataRef.current = null; window.__dragData = null; return }
if (assignmentId && fromDayId !== day.id) {
tripStore.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch(err => toast.error(err.message))
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; return
}
if (noteId && fromDayId !== day.id) {
tripStore.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch(err => toast.error(err.message))
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; return
}
const m = getMergedItems(day.id)
if (m.length === 0) return
const lastItem = m[m.length - 1]
if (assignmentId && String(lastItem?.data?.id) !== assignmentId)
handleMergedDrop(day.id, 'place', Number(assignmentId), lastItem.type, lastItem.data.id)
else if (noteId && String(lastItem?.data?.id) !== noteId)
handleMergedDrop(day.id, 'note', Number(noteId), lastItem.type, lastItem.data.id)
}}
>
{merged.length === 0 && !dayNoteUi ? (
<div
onDragOver={e => { e.preventDefault(); setDragOverDayId(day.id) }}
onDrop={e => handleDropOnDay(e, day.id)}
style={{ padding: '16px', textAlign: 'center', borderRadius: 8,
background: dragOverDayId === day.id ? 'rgba(17,24,39,0.05)' : 'transparent',
border: dragOverDayId === day.id ? '2px dashed rgba(17,24,39,0.2)' : '2px dashed transparent',
}}
>
<span style={{ fontSize: 12, color: 'var(--text-faint)' }}>{t('dayplan.emptyDay')}</span>
</div>
) : (
merged.map((item, idx) => {
const itemKey = item.type === 'place' ? `place-${item.data.id}` : `note-${item.data.id}`
const showDropLine = (!!draggingId || !!dropTargetKey) && dropTargetKey === itemKey
if (item.type === 'place') {
const assignment = item.data
const place = assignment.place
if (!place) return null
const cat = categories.find(c => c.id === place.category_id)
const isPlaceSelected = place.id === selectedPlaceId
const hasReservation = place.reservation_status && place.reservation_status !== 'none'
const isConfirmed = place.reservation_status === 'confirmed'
const isDraggingThis = draggingId === assignment.id
const isHovered = hoveredId === assignment.id
const placeIdx = placeItems.findIndex(i => i.data.id === assignment.id)
const moveUp = (e) => {
e.stopPropagation()
if (placeIdx === 0) return
const ids = placeItems.map(i => i.data.id)
;[ids[placeIdx - 1], ids[placeIdx]] = [ids[placeIdx], ids[placeIdx - 1]]
onReorder(day.id, ids)
}
const moveDown = (e) => {
e.stopPropagation()
if (placeIdx === placeItems.length - 1) return
const ids = placeItems.map(i => i.data.id)
;[ids[placeIdx], ids[placeIdx + 1]] = [ids[placeIdx + 1], ids[placeIdx]]
onReorder(day.id, ids)
}
return (
<React.Fragment key={`place-${assignment.id}`}>
{showDropLine && <div style={{ height: 2, background: 'var(--text-primary)', borderRadius: 1, margin: '2px 8px' }} />}
<div
draggable
onDragStart={e => {
e.dataTransfer.setData('assignmentId', String(assignment.id))
e.dataTransfer.setData('fromDayId', String(day.id))
e.dataTransfer.effectAllowed = 'move'
dragDataRef.current = { assignmentId: String(assignment.id), fromDayId: String(day.id) }
setDraggingId(assignment.id)
}}
onDragOver={e => { e.preventDefault(); e.stopPropagation(); setDragOverDayId(null); setDropTargetKey(`place-${assignment.id}`) }}
onDrop={e => {
e.preventDefault(); e.stopPropagation()
const { placeId, assignmentId: fromAssignmentId, noteId, fromDayId } = getDragData(e)
if (placeId) {
const pos = placeItems.findIndex(i => i.data.id === assignment.id)
onAssignToDay?.(parseInt(placeId), day.id, pos >= 0 ? pos : undefined)
setDropTargetKey(null); window.__dragData = null
} else if (fromAssignmentId && fromDayId !== day.id) {
const toIdx = getDayAssignments(day.id).findIndex(a => a.id === assignment.id)
tripStore.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id, toIdx).catch(err => toast.error(err.message))
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null
} else if (fromAssignmentId) {
handleMergedDrop(day.id, 'place', Number(fromAssignmentId), 'place', assignment.id)
} else if (noteId && fromDayId !== day.id) {
const tm = getMergedItems(day.id)
const toIdx = tm.findIndex(i => i.type === 'place' && i.data.id === assignment.id)
const so = toIdx <= 0 ? (tm[0]?.sortKey ?? 0) - 1 : (tm[toIdx - 1].sortKey + tm[toIdx].sortKey) / 2
tripStore.moveDayNote(tripId, fromDayId, day.id, Number(noteId), so).catch(err => toast.error(err.message))
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null
} else if (noteId) {
handleMergedDrop(day.id, 'note', Number(noteId), 'place', assignment.id)
}
}}
onDragEnd={() => { setDraggingId(null); setDragOverDayId(null); setDropTargetKey(null); dragDataRef.current = null }}
onClick={() => { onPlaceClick(isPlaceSelected ? null : place.id); if (!isPlaceSelected) onSelectDay(day.id) }}
onMouseEnter={() => setHoveredId(assignment.id)}
onMouseLeave={() => setHoveredId(null)}
style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '7px 8px 7px 10px',
cursor: 'pointer',
background: isPlaceSelected ? 'var(--bg-hover)' : (isHovered ? 'var(--bg-hover)' : 'transparent'),
borderLeft: hasReservation
? `3px solid ${isConfirmed ? '#10b981' : '#f59e0b'}`
: '3px solid transparent',
transition: 'background 0.1s',
opacity: isDraggingThis ? 0.4 : 1,
}}
>
<div style={{ flexShrink: 0, color: 'var(--text-faint)', display: 'flex', alignItems: 'center', opacity: isHovered ? 1 : 0.3, transition: 'opacity 0.15s', cursor: 'grab' }}>
<GripVertical size={13} strokeWidth={1.8} />
</div>
<PlaceAvatar place={place} category={cat} size={28} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 4, overflow: 'hidden' }}>
{cat && (() => {
const CatIcon = getCategoryIcon(cat.icon)
return <CatIcon size={10} strokeWidth={2} color={cat.color || 'var(--text-muted)'} title={cat.name} style={{ flexShrink: 0 }} />
})()}
<span style={{ fontSize: 12.5, fontWeight: 500, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', lineHeight: 1.2 }}>
{place.name}
</span>
{place.place_time && (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 3, flexShrink: 0, fontSize: 10, color: 'var(--text-faint)', fontWeight: 400, marginLeft: 6 }}>
<Clock size={9} strokeWidth={2} />
{formatTime(place.place_time, locale, timeFormat)}
</span>
)}
</div>
{(place.description || place.address || cat?.name) && !hasReservation && (
<div style={{ marginTop: 2 }}>
<span style={{ fontSize: 10, color: 'var(--text-faint)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', display: 'block', lineHeight: 1.2 }}>
{place.description || place.address || cat?.name}
</span>
</div>
)}
{hasReservation && (
<div style={{ display: 'flex', alignItems: 'center', gap: 2, marginTop: 2 }}>
<span style={{ fontSize: 10, color: isConfirmed ? '#059669' : '#d97706', display: 'flex', alignItems: 'center', gap: 2, fontWeight: 600 }}>
{isConfirmed ? <><CheckCircle2 size={10} />
{place.reservation_datetime
? `Res. ${formatTime(new Date(place.reservation_datetime).toTimeString().slice(0,5), locale, timeFormat)}`
: place.place_time ? `Res. ${formatTime(place.place_time, locale, timeFormat)}` : t('dayplan.confirmed')}
</> : <><AlertCircle size={10} />{t('dayplan.pendingRes')}</>}
</span>
</div>
)}
</div>
<div style={{ flexShrink: 0, display: 'flex', flexDirection: 'column', gap: 1, opacity: isHovered ? 1 : 0, transition: 'opacity 0.15s' }}>
<button onClick={moveUp} disabled={placeIdx === 0} style={{ background: 'none', border: 'none', padding: '1px 2px', cursor: placeIdx === 0 ? 'default' : 'pointer', color: placeIdx === 0 ? 'var(--border-primary)' : 'var(--text-faint)', display: 'flex', lineHeight: 1 }}>
<ChevronUp size={12} strokeWidth={2} />
</button>
<button onClick={moveDown} disabled={placeIdx === placeItems.length - 1} style={{ background: 'none', border: 'none', padding: '1px 2px', cursor: placeIdx === placeItems.length - 1 ? 'default' : 'pointer', color: placeIdx === placeItems.length - 1 ? 'var(--border-primary)' : 'var(--text-faint)', display: 'flex', lineHeight: 1 }}>
<ChevronDown size={12} strokeWidth={2} />
</button>
</div>
</div>
</React.Fragment>
)
}
// Notizkarte
const note = item.data
const isNoteHovered = hoveredId === `note-${note.id}`
const NoteIcon = getNoteIcon(note.icon)
const noteIdx = idx
return (
<React.Fragment key={`note-${note.id}`}>
{showDropLine && <div style={{ height: 2, background: 'var(--text-primary)', borderRadius: 1, margin: '2px 8px' }} />}
<div
draggable
onDragStart={e => { e.dataTransfer.setData('noteId', String(note.id)); e.dataTransfer.setData('fromDayId', String(day.id)); e.dataTransfer.effectAllowed = 'move'; dragDataRef.current = { noteId: String(note.id), fromDayId: String(day.id) }; setDraggingId(`note-${note.id}`) }}
onDragEnd={() => { setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null }}
onDragOver={e => { e.preventDefault(); e.stopPropagation(); setDropTargetKey(`note-${note.id}`) }}
onDrop={e => {
e.preventDefault(); e.stopPropagation()
const { noteId: fromNoteId, assignmentId: fromAssignmentId, fromDayId } = getDragData(e)
if (fromNoteId && fromDayId !== day.id) {
const tm = getMergedItems(day.id)
const toIdx = tm.findIndex(i => i.type === 'note' && i.data.id === note.id)
const so = toIdx <= 0 ? (tm[0]?.sortKey ?? 0) - 1 : (tm[toIdx - 1].sortKey + tm[toIdx].sortKey) / 2
tripStore.moveDayNote(tripId, fromDayId, day.id, Number(fromNoteId), so).catch(err => toast.error(err.message))
setDraggingId(null); setDropTargetKey(null)
} else if (fromNoteId && fromNoteId !== String(note.id)) {
handleMergedDrop(day.id, 'note', Number(fromNoteId), 'note', note.id)
} else if (fromAssignmentId && fromDayId !== day.id) {
const tm = getMergedItems(day.id)
const noteIdx = tm.findIndex(i => i.type === 'note' && i.data.id === note.id)
const toIdx = tm.slice(0, noteIdx).filter(i => i.type === 'place').length
tripStore.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id, toIdx).catch(err => toast.error(err.message))
setDraggingId(null); setDropTargetKey(null)
} else if (fromAssignmentId) {
handleMergedDrop(day.id, 'place', Number(fromAssignmentId), 'note', note.id)
}
}}
onMouseEnter={() => setHoveredId(`note-${note.id}`)}
onMouseLeave={() => setHoveredId(null)}
style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '7px 8px 7px 2px',
margin: '1px 8px',
borderRadius: 6,
border: '1px solid var(--border-faint)',
background: isNoteHovered ? 'var(--bg-hover)' : 'var(--bg-hover)',
opacity: draggingId === `note-${note.id}` ? 0.4 : 1,
transition: 'background 0.1s', cursor: 'grab', userSelect: 'none',
}}
>
<div style={{ flexShrink: 0, color: 'var(--text-faint)', display: 'flex', alignItems: 'center', opacity: isNoteHovered ? 1 : 0.3, transition: 'opacity 0.15s', cursor: 'grab' }}>
<GripVertical size={13} strokeWidth={1.8} />
</div>
<div style={{ width: 28, height: 28, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', borderRadius: '50%', background: 'var(--bg-hover)', overflow: 'hidden' }}>
<NoteIcon size={13} strokeWidth={1.8} color="var(--text-muted)" />
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<span style={{ fontSize: 12.5, fontWeight: 500, color: 'var(--text-primary)',
display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
{note.text}
</span>
{note.time && (
<div style={{ fontSize: 10.5, fontWeight: 400, color: 'var(--text-faint)', lineHeight: '1.2', marginTop: 2 }}>{note.time}</div>
)}
</div>
<div style={{ flexShrink: 0, display: 'flex', flexDirection: 'column', gap: 1, opacity: isNoteHovered ? 1 : 0, transition: 'opacity 0.15s' }}>
<button onClick={e => { e.stopPropagation(); moveNote(day.id, note.id, 'up') }} disabled={noteIdx === 0} style={{ background: 'none', border: 'none', padding: '1px 2px', cursor: noteIdx === 0 ? 'default' : 'pointer', color: noteIdx === 0 ? 'var(--border-primary)' : 'var(--text-faint)', display: 'flex', lineHeight: 1 }}><ChevronUp size={12} strokeWidth={2} /></button>
<button onClick={e => { e.stopPropagation(); moveNote(day.id, note.id, 'down') }} disabled={noteIdx === merged.length - 1} style={{ background: 'none', border: 'none', padding: '1px 2px', cursor: noteIdx === merged.length - 1 ? 'default' : 'pointer', color: noteIdx === merged.length - 1 ? 'var(--border-primary)' : 'var(--text-faint)', display: 'flex', lineHeight: 1 }}><ChevronDown size={12} strokeWidth={2} /></button>
</div>
<div style={{ display: 'flex', gap: 1, flexShrink: 0, opacity: isNoteHovered ? 1 : 0, transition: 'opacity 0.15s' }}>
<button onClick={e => openEditNote(day.id, note, e)} style={{ background: 'none', border: 'none', padding: 2, cursor: 'pointer', color: 'var(--text-faint)', display: 'flex' }}><Pencil size={10} /></button>
<button onClick={e => deleteNote(day.id, note.id, e)} style={{ background: 'none', border: 'none', padding: 2, cursor: 'pointer', color: 'var(--text-faint)', display: 'flex' }}><Trash2 size={10} /></button>
</div>
</div>
</React.Fragment>
)
})
)}
{/* Drop-Indikator am Listenende */}
{!!draggingId && dropTargetKey === `end-${day.id}` && (
<div style={{ height: 2, background: 'var(--text-primary)', borderRadius: 1, margin: '2px 8px' }} />
)}
{/* Routen-Werkzeuge (ausgewählter Tag, 2+ Orte) */}
{isSelected && getDayAssignments(day.id).length >= 2 && (
<div style={{ padding: '10px 16px 12px', borderTop: '1px solid var(--border-faint)', display: 'flex', flexDirection: 'column', gap: 7 }}>
<div style={{ display: 'flex', background: 'var(--bg-hover)', borderRadius: 8, padding: 2, gap: 2 }}>
{TRANSPORT_MODES.map(m => (
<button key={m.value} onClick={() => setTransportMode(m.value)} style={{
flex: 1, padding: '4px 0', fontSize: 11, fontWeight: transportMode === m.value ? 600 : 400,
background: transportMode === m.value ? 'var(--bg-card)' : 'transparent',
border: 'none', borderRadius: 6, cursor: 'pointer', color: transportMode === m.value ? 'var(--text-primary)' : 'var(--text-muted)',
boxShadow: transportMode === m.value ? '0 1px 3px rgba(0,0,0,0.1)' : 'none',
fontFamily: 'inherit',
}}>{m.label}</button>
))}
</div>
{routeInfo && (
<div style={{ display: 'flex', justifyContent: 'center', gap: 12, fontSize: 12, color: 'var(--text-secondary)', background: 'var(--bg-hover)', borderRadius: 8, padding: '5px 10px' }}>
<span>{routeInfo.distance}</span>
<span style={{ color: 'var(--text-faint)' }}>·</span>
<span>{routeInfo.duration}</span>
</div>
)}
<div style={{ display: 'flex', gap: 6 }}>
<button onClick={handleCalculateRoute} disabled={isCalculating} style={{
flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
padding: '6px 0', fontSize: 11, fontWeight: 500, borderRadius: 8, border: 'none',
background: 'var(--accent)', color: 'var(--accent-text)', cursor: 'pointer', fontFamily: 'inherit',
opacity: isCalculating ? 0.6 : 1,
}}>
<Navigation size={12} strokeWidth={2} />
{isCalculating ? t('dayplan.calculating') : t('dayplan.route')}
</button>
<button onClick={handleOptimize} style={{
flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
padding: '6px 0', fontSize: 11, fontWeight: 500, borderRadius: 8, border: 'none',
background: 'var(--bg-hover)', color: 'var(--text-secondary)', cursor: 'pointer', fontFamily: 'inherit',
}}>
<RotateCcw size={12} strokeWidth={2} />
{t('dayplan.optimize')}
</button>
<button onClick={handleGoogleMaps} style={{
display: 'flex', alignItems: 'center', justifyContent: 'center',
padding: '6px 10px', fontSize: 11, fontWeight: 500, borderRadius: 8,
border: '1px solid var(--border-faint)', background: 'transparent', color: 'var(--text-secondary)', cursor: 'pointer', fontFamily: 'inherit',
}}>
<ExternalLink size={12} strokeWidth={2} />
</button>
</div>
</div>
)}
</div>
)}
</div>
)
})}
</div>
{/* Notiz-Popup-Modal — über Portal gerendert, um den backdropFilter-Stapelkontext zu umgehen */}
{Object.entries(noteUi).map(([dayId, ui]) => ui && ReactDOM.createPortal(
<div key={dayId} style={{
position: 'fixed', inset: 0, zIndex: 1000,
display: 'flex', alignItems: 'center', justifyContent: 'center',
background: 'rgba(0,0,0,0.3)', backdropFilter: 'blur(3px)',
}} onClick={() => cancelNote(Number(dayId))}>
<div style={{
width: 340, background: 'var(--bg-card)', borderRadius: 16,
boxShadow: '0 16px 48px rgba(0,0,0,0.22)', padding: '22px 22px 18px',
display: 'flex', flexDirection: 'column', gap: 12,
}} onClick={e => e.stopPropagation()}>
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-primary)' }}>
{ui.mode === 'add' ? t('dayplan.noteAdd') : t('dayplan.noteEdit')}
</div>
{/* Icon-Auswahl */}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 5 }}>
{NOTE_ICONS.map(({ id, Icon }) => (
<button key={id} onClick={() => setNoteUi(prev => ({ ...prev, [dayId]: { ...prev[dayId], icon: id } }))}
title={id}
style={{ width: 34, height: 34, borderRadius: 8, border: ui.icon === id ? '2px solid var(--text-primary)' : '2px solid var(--border-faint)', background: ui.icon === id ? 'var(--bg-hover)' : 'transparent', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 0 }}>
<Icon size={15} strokeWidth={1.8} color={ui.icon === id ? 'var(--text-primary)' : 'var(--text-muted)'} />
</button>
))}
</div>
<input
ref={noteInputRef}
type="text"
value={ui.text}
onChange={e => setNoteUi(prev => ({ ...prev, [dayId]: { ...prev[dayId], text: e.target.value } }))}
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); saveNote(Number(dayId)) } if (e.key === 'Escape') cancelNote(Number(dayId)) }}
placeholder={t('dayplan.noteTitle')}
style={{ fontSize: 13, fontWeight: 500, border: '1px solid var(--border-primary)', borderRadius: 8, padding: '8px 10px', fontFamily: 'inherit', outline: 'none', width: '100%', boxSizing: 'border-box', color: 'var(--text-primary)' }}
/>
<input
type="text"
value={ui.time}
onChange={e => setNoteUi(prev => ({ ...prev, [dayId]: { ...prev[dayId], time: e.target.value } }))}
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); saveNote(Number(dayId)) } if (e.key === 'Escape') cancelNote(Number(dayId)) }}
placeholder={t('dayplan.noteSubtitle')}
style={{ fontSize: 12, border: '1px solid var(--border-primary)', borderRadius: 8, padding: '7px 10px', fontFamily: 'inherit', outline: 'none', width: '100%', boxSizing: 'border-box', color: 'var(--text-primary)' }}
/>
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<button onClick={() => cancelNote(Number(dayId))} style={{ fontSize: 12, background: 'none', border: '1px solid var(--border-primary)', borderRadius: 8, padding: '6px 14px', cursor: 'pointer', color: 'var(--text-muted)', fontFamily: 'inherit' }}>{t('common.cancel')}</button>
<button onClick={() => saveNote(Number(dayId))} style={{ fontSize: 12, background: 'var(--accent)', color: 'var(--accent-text)', border: 'none', borderRadius: 8, padding: '6px 16px', cursor: 'pointer', fontWeight: 600, fontFamily: 'inherit' }}>
{ui.mode === 'add' ? t('common.add') : t('common.save')}
</button>
</div>
</div>
</div>,
document.body
))}
{/* Budget-Fußzeile */}
{totalCost > 0 && (
<div style={{ flexShrink: 0, padding: '10px 16px', borderTop: '1px solid var(--border-faint)', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ fontSize: 11, color: 'var(--text-faint)' }}>{t('dayplan.totalCost')}</span>
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>{totalCost.toFixed(2)} {currency}</span>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,136 @@
import React from 'react'
import { CalendarDays, MapPin, Plus } from 'lucide-react'
import WeatherWidget from '../Weather/WeatherWidget'
function formatDate(dateStr) {
if (!dateStr) return null
return new Date(dateStr + 'T00:00:00').toLocaleDateString('de-DE', {
weekday: 'short',
day: 'numeric',
month: 'short',
})
}
function dayTotal(dayId, assignments) {
const dayAssignments = assignments[String(dayId)] || []
return dayAssignments.reduce((sum, a) => {
const cost = parseFloat(a.place?.cost) || 0
return sum + cost
}, 0)
}
export function DaysList({ days, selectedDayId, onSelectDay, assignments, trip }) {
const totalCost = days.reduce((sum, d) => sum + dayTotal(d.id, assignments), 0)
const currency = trip?.currency || 'EUR'
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="px-4 py-3 border-b border-gray-100 flex-shrink-0">
<h2 className="text-sm font-semibold text-gray-700">Tagesplan</h2>
<p className="text-xs text-gray-400 mt-0.5">{days.length} Tage</p>
</div>
{/* All places overview option */}
<button
onClick={() => onSelectDay(null)}
className={`w-full text-left px-4 py-3 border-b border-gray-100 transition-colors flex items-center gap-2 flex-shrink-0 ${
selectedDayId === null
? 'bg-slate-50 border-l-2 border-l-slate-900'
: 'hover:bg-gray-50'
}`}
>
<MapPin className={`w-4 h-4 flex-shrink-0 ${selectedDayId === null ? 'text-slate-900' : 'text-gray-400'}`} />
<div>
<p className={`text-sm font-medium ${selectedDayId === null ? 'text-slate-900' : 'text-gray-700'}`}>
Alle Orte
</p>
<p className="text-xs text-gray-400">Gesamtübersicht</p>
</div>
</button>
{/* Day list */}
<div className="flex-1 overflow-y-auto">
{days.length === 0 ? (
<div className="px-4 py-6 text-center">
<CalendarDays className="w-8 h-8 text-gray-300 mx-auto mb-2" />
<p className="text-xs text-gray-400">Noch keine Tage</p>
<p className="text-xs text-gray-300 mt-1">Reise bearbeiten um Tage hinzuzufügen</p>
</div>
) : (
days.map((day, index) => {
const isSelected = selectedDayId === day.id
const dayAssignments = assignments[String(day.id)] || []
const cost = dayTotal(day.id, assignments)
const placeCount = dayAssignments.length
return (
<button
key={day.id}
onClick={() => onSelectDay(day.id)}
className={`w-full text-left px-4 py-3 border-b border-gray-50 transition-colors ${
isSelected
? 'bg-slate-50 border-l-2 border-l-slate-900'
: 'hover:bg-gray-50'
}`}
>
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<span className={`text-xs font-bold px-1.5 py-0.5 rounded ${
isSelected ? 'bg-slate-900 text-white' : 'bg-gray-200 text-gray-600'
}`}>
{index + 1}
</span>
<span className={`text-sm font-medium truncate ${isSelected ? 'text-slate-900' : 'text-gray-700'}`}>
{day.title || `Tag ${index + 1}`}
</span>
</div>
{day.date && (
<p className="text-xs text-gray-400 mt-1 ml-0.5">
{formatDate(day.date)}
</p>
)}
<div className="flex items-center gap-3 mt-1.5">
{placeCount > 0 && (
<span className="text-xs text-gray-400">
{placeCount} {placeCount === 1 ? 'Ort' : 'Orte'}
</span>
)}
{cost > 0 && (
<span className="text-xs text-emerald-600 font-medium">
{cost.toFixed(0)} {currency}
</span>
)}
</div>
</div>
</div>
{/* Weather for this day */}
{day.date && isSelected && (
<div className="mt-2">
<WeatherWidget date={day.date} compact />
</div>
)}
</button>
)
})
)}
</div>
{/* Budget summary footer */}
{totalCost > 0 && (
<div className="flex-shrink-0 border-t border-gray-100 px-4 py-3 bg-gray-50">
<div className="flex items-center justify-between">
<span className="text-xs text-gray-500">Gesamtkosten</span>
<span className="text-sm font-semibold text-gray-800">
{totalCost.toFixed(2)} {currency}
</span>
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,107 @@
import React from 'react'
import { useDraggable } from '@dnd-kit/core'
import { MapPin, DollarSign, Check } from 'lucide-react'
export default function DraggablePlaceCard({ place, isAssigned, onEdit }) {
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({
id: `place-${place.id}`,
data: {
type: 'place',
place,
},
})
const style = transform ? {
transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`,
zIndex: isDragging ? 999 : undefined,
} : undefined
return (
<div
ref={setNodeRef}
style={style}
{...listeners}
{...attributes}
className={`
group relative bg-white border rounded-lg p-3 cursor-grab active:cursor-grabbing
transition-all select-none
${isDragging
? 'opacity-50 shadow-2xl border-slate-400 scale-105'
: 'border-slate-200 hover:border-slate-300 hover:shadow-md place-card-hover'
}
`}
onClick={e => {
if (!isDragging && onEdit) {
e.stopPropagation()
onEdit(place)
}
}}
>
{/* Category left border accent */}
{place.category && (
<div
className="absolute left-0 top-3 bottom-3 w-0.5 rounded-r"
style={{ backgroundColor: place.category.color || '#6366f1' }}
/>
)}
<div className="pl-1">
{/* Header */}
<div className="flex items-start justify-between gap-1 mb-1">
<p className="text-sm font-medium text-slate-800 leading-tight line-clamp-2 flex-1">
{place.name}
</p>
{isAssigned && (
<span className="flex-shrink-0 w-5 h-5 bg-emerald-100 rounded-full flex items-center justify-center" title="Already assigned to a day">
<Check className="w-3 h-3 text-emerald-600" />
</span>
)}
</div>
{/* Address */}
{place.address && (
<p className="text-xs text-slate-400 truncate flex items-center gap-1 mb-1.5">
<MapPin className="w-3 h-3 flex-shrink-0" />
{place.address}
</p>
)}
{/* Category badge */}
{place.category && (
<span
className="inline-block text-[10px] px-1.5 py-0.5 rounded text-white font-medium mr-1"
style={{ backgroundColor: place.category.color || '#6366f1' }}
>
{place.category.name}
</span>
)}
{/* Price */}
{place.price != null && (
<span className="inline-flex items-center gap-0.5 text-[10px] text-emerald-700 bg-emerald-50 px-1.5 py-0.5 rounded">
<DollarSign className="w-2.5 h-2.5" />
{Number(place.price).toLocaleString()} {place.currency || ''}
</span>
)}
{/* Tags */}
{place.tags && place.tags.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1.5">
{place.tags.slice(0, 3).map(tag => (
<span
key={tag.id}
className="text-[10px] px-1.5 py-0.5 rounded-full text-white font-medium"
style={{ backgroundColor: tag.color || '#6366f1' }}
>
{tag.name}
</span>
))}
{place.tags.length > 3 && (
<span className="text-[10px] text-slate-400">+{place.tags.length - 3}</span>
)}
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,254 @@
import React, { useState, useEffect } from 'react'
import { X, ExternalLink, Phone, MapPin, Clock, Euro, Edit2, Trash2, Plus, Minus } from 'lucide-react'
import { mapsApi } from '../../api/client'
const RESERVATION_STATUS = {
none: { label: 'Keine Reservierung', color: 'gray' },
pending: { label: 'Res. ausstehend', color: 'yellow' },
confirmed: { label: 'Bestätigt', color: 'green' },
}
export function PlaceDetailPanel({
place, categories, tags, selectedDayId, dayAssignments,
onClose, onEdit, onDelete, onAssignToDay, onRemoveAssignment,
}) {
const [googlePhoto, setGooglePhoto] = useState(null)
const [photoAttribution, setPhotoAttribution] = useState(null)
useEffect(() => {
if (!place?.google_place_id || place?.image_url) {
setGooglePhoto(null)
return
}
mapsApi.placePhoto(place.google_place_id)
.then(data => {
setGooglePhoto(data.photoUrl || null)
setPhotoAttribution(data.attribution || null)
})
.catch(() => setGooglePhoto(null))
}, [place?.google_place_id, place?.image_url])
if (!place) return null
const displayPhoto = place.image_url || googlePhoto
const category = categories?.find(c => c.id === place.category_id)
const placeTags = (place.tags || []).map(t =>
tags?.find(tg => tg.id === (t.id || t)) || t
).filter(Boolean)
const assignmentInDay = selectedDayId
? dayAssignments?.find(a => a.place?.id === place.id)
: null
const status = RESERVATION_STATUS[place.reservation_status] || RESERVATION_STATUS.none
return (
<div className="bg-white">
{/* Image */}
{displayPhoto ? (
<div className="relative">
<img
src={displayPhoto}
alt={place.name}
className="w-full h-40 object-cover"
onError={e => { e.target.style.display = 'none' }}
/>
<button
onClick={onClose}
className="absolute top-2 right-2 bg-white/90 rounded-full p-1.5 shadow"
>
<X className="w-4 h-4 text-gray-600" />
</button>
{photoAttribution && !place.image_url && (
<div className="absolute bottom-1 right-2 text-[10px] text-white/70">
© {photoAttribution}
</div>
)}
</div>
) : (
<div
className="h-24 flex items-center justify-center relative"
style={{ backgroundColor: category?.color ? `${category.color}20` : '#f0f0ff' }}
>
<span className="text-4xl">{category?.icon || '📍'}</span>
<button
onClick={onClose}
className="absolute top-2 right-2 bg-white/90 rounded-full p-1.5 shadow"
>
<X className="w-4 h-4 text-gray-600" />
</button>
</div>
)}
{/* Content */}
<div className="p-4 space-y-3">
{/* Name + category */}
<div>
<h3 className="font-bold text-gray-900 text-base leading-snug">{place.name}</h3>
{category && (
<span
className="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-full mt-1"
style={{ backgroundColor: `${category.color}20`, color: category.color }}
>
{category.icon} {category.name}
</span>
)}
</div>
{/* Quick info row */}
<div className="flex flex-wrap gap-2">
{place.place_time && (
<div className="flex items-center gap-1 text-xs text-gray-600 bg-gray-50 px-2 py-1 rounded-lg">
<Clock className="w-3 h-3" />
{place.place_time}
</div>
)}
{place.price > 0 && (
<div className="flex items-center gap-1 text-xs text-emerald-700 bg-emerald-50 px-2 py-1 rounded-lg">
<Euro className="w-3 h-3" />
{place.price} {place.currency}
</div>
)}
</div>
{/* Address */}
{place.address && (
<div className="flex items-start gap-1.5 text-xs text-gray-600">
<MapPin className="w-3.5 h-3.5 flex-shrink-0 mt-0.5 text-gray-400" />
<span>{place.address}</span>
</div>
)}
{/* Coordinates */}
{place.lat && place.lng && (
<div className="text-xs text-gray-400">
{Number(place.lat).toFixed(6)}, {Number(place.lng).toFixed(6)}
</div>
)}
{/* Links */}
<div className="flex gap-2">
{place.website && (
<a
href={place.website}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-xs text-slate-700 hover:underline"
>
<ExternalLink className="w-3 h-3" />
Website
</a>
)}
{place.phone && (
<a
href={`tel:${place.phone}`}
className="flex items-center gap-1 text-xs text-slate-700 hover:underline"
>
<Phone className="w-3 h-3" />
{place.phone}
</a>
)}
</div>
{/* Description */}
{place.description && (
<p className="text-xs text-gray-600 leading-relaxed">{place.description}</p>
)}
{/* Notes */}
{place.notes && (
<div className="bg-amber-50 border border-amber-100 rounded-lg px-3 py-2">
<p className="text-xs text-amber-800 leading-relaxed">📝 {place.notes}</p>
</div>
)}
{/* Tags */}
{placeTags.length > 0 && (
<div className="flex flex-wrap gap-1">
{placeTags.map((tag, i) => (
<span
key={tag.id || i}
className="text-xs px-2 py-0.5 rounded-full"
style={{ backgroundColor: `${tag.color || '#6366f1'}20`, color: tag.color || '#6366f1' }}
>
{tag.name}
</span>
))}
</div>
)}
{/* Reservation status */}
{place.reservation_status && place.reservation_status !== 'none' && (
<div className={`rounded-lg px-3 py-2 border ${
place.reservation_status === 'confirmed'
? 'bg-emerald-50 border-emerald-200'
: 'bg-yellow-50 border-yellow-200'
}`}>
<div className={`text-xs font-semibold ${
place.reservation_status === 'confirmed' ? 'text-emerald-700' : 'text-yellow-700'
}`}>
{place.reservation_status === 'confirmed' ? '✅' : '⏳'} {status.label}
</div>
{place.reservation_datetime && (
<div className="text-xs text-gray-500 mt-0.5">
{formatDateTime(place.reservation_datetime)}
</div>
)}
{place.reservation_notes && (
<p className="text-xs text-gray-600 mt-1">{place.reservation_notes}</p>
)}
</div>
)}
{/* Day assignment actions */}
{selectedDayId && (
<div className="pt-1">
{assignmentInDay ? (
<button
onClick={() => onRemoveAssignment(selectedDayId, assignmentInDay.id)}
className="w-full flex items-center justify-center gap-2 py-2 text-sm text-red-600 border border-red-200 rounded-lg hover:bg-red-50"
>
<Minus className="w-4 h-4" />
Aus Tag entfernen
</button>
) : (
<button
onClick={() => onAssignToDay(place.id)}
className="w-full flex items-center justify-center gap-2 py-2 text-sm text-white bg-slate-900 rounded-lg hover:bg-slate-700"
>
<Plus className="w-4 h-4" />
Zum Tag hinzufügen
</button>
)}
</div>
)}
{/* Edit / Delete */}
<div className="flex gap-2 pt-1">
<button
onClick={onEdit}
className="flex-1 flex items-center justify-center gap-1.5 py-2 text-xs text-gray-700 border border-gray-200 rounded-lg hover:bg-gray-50"
>
<Edit2 className="w-3.5 h-3.5" />
Bearbeiten
</button>
<button
onClick={onDelete}
className="flex items-center justify-center gap-1.5 py-2 px-3 text-xs text-red-600 border border-red-200 rounded-lg hover:bg-red-50"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
</div>
)
}
function formatDateTime(dt) {
if (!dt) return ''
try {
return new Date(dt).toLocaleString('de-DE', { dateStyle: 'medium', timeStyle: 'short' })
} catch {
return dt
}
}

View File

@@ -0,0 +1,354 @@
import React, { useState, useEffect } from 'react'
import Modal from '../shared/Modal'
import CustomSelect from '../shared/CustomSelect'
import { mapsApi } from '../../api/client'
import { useToast } from '../shared/Toast'
import { Search } from 'lucide-react'
import { useTranslation } from '../../i18n'
import CustomTimePicker from '../shared/CustomTimePicker'
import { CustomDateTimePicker } from '../shared/CustomDateTimePicker'
const TRANSPORT_MODES = [
{ value: 'walking', labelKey: 'places.transport.walking' },
{ value: 'driving', labelKey: 'places.transport.driving' },
{ value: 'cycling', labelKey: 'places.transport.cycling' },
{ value: 'transit', labelKey: 'places.transport.transit' },
]
const DEFAULT_FORM = {
name: '',
description: '',
address: '',
lat: '',
lng: '',
category_id: '',
place_time: '',
notes: '',
transport_mode: 'walking',
reservation_status: 'none',
reservation_notes: '',
reservation_datetime: '',
website: '',
}
export default function PlaceFormModal({
isOpen, onClose, onSave, place, tripId, categories,
onCategoryCreated,
}) {
const [form, setForm] = useState(DEFAULT_FORM)
const [mapsSearch, setMapsSearch] = useState('')
const [mapsResults, setMapsResults] = useState([])
const [isSearchingMaps, setIsSearchingMaps] = useState(false)
const [newCategoryName, setNewCategoryName] = useState('')
const [showNewCategory, setShowNewCategory] = useState(false)
const [isSaving, setIsSaving] = useState(false)
const toast = useToast()
const { t, language } = useTranslation()
useEffect(() => {
if (place) {
setForm({
name: place.name || '',
description: place.description || '',
address: place.address || '',
lat: place.lat || '',
lng: place.lng || '',
category_id: place.category_id || '',
place_time: place.place_time || '',
notes: place.notes || '',
transport_mode: place.transport_mode || 'walking',
reservation_status: place.reservation_status || 'none',
reservation_notes: place.reservation_notes || '',
reservation_datetime: place.reservation_datetime || '',
website: place.website || '',
})
} else {
setForm(DEFAULT_FORM)
}
}, [place, isOpen])
const handleChange = (field, value) => {
setForm(prev => ({ ...prev, [field]: value }))
}
const handleMapsSearch = async () => {
if (!mapsSearch.trim()) return
setIsSearchingMaps(true)
try {
const result = await mapsApi.search(mapsSearch, language)
setMapsResults(result.places || [])
} catch (err) {
toast.error(t('places.mapsSearchError'))
} finally {
setIsSearchingMaps(false)
}
}
const handleSelectMapsResult = (result) => {
setForm(prev => ({
...prev,
name: result.name || prev.name,
address: result.address || prev.address,
lat: result.lat || prev.lat,
lng: result.lng || prev.lng,
google_place_id: result.google_place_id || prev.google_place_id,
}))
setMapsResults([])
setMapsSearch('')
}
const handleCreateCategory = async () => {
if (!newCategoryName.trim()) return
try {
const cat = await onCategoryCreated?.({ name: newCategoryName, color: '#6366f1', icon: 'MapPin' })
if (cat) setForm(prev => ({ ...prev, category_id: cat.id }))
setNewCategoryName('')
setShowNewCategory(false)
} catch (err) {
toast.error(t('places.categoryCreateError'))
}
}
const handleSubmit = async (e) => {
e.preventDefault()
if (!form.name.trim()) {
toast.error(t('places.nameRequired'))
return
}
setIsSaving(true)
try {
await onSave({
...form,
lat: form.lat ? parseFloat(form.lat) : null,
lng: form.lng ? parseFloat(form.lng) : null,
category_id: form.category_id || null,
})
onClose()
} catch (err) {
toast.error(err.message || t('places.saveError'))
} finally {
setIsSaving(false)
}
}
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={place ? t('places.editPlace') : t('places.addPlace')}
size="lg"
>
<form onSubmit={handleSubmit} className="space-y-4">
{/* Google Maps Search */}
<div className="bg-slate-50 rounded-xl p-3 border border-slate-200">
<div className="flex gap-2">
<input
type="text"
value={mapsSearch}
onChange={e => setMapsSearch(e.target.value)}
onKeyDown={e => e.key === 'Enter' && (e.preventDefault(), handleMapsSearch())}
placeholder={t('places.mapsSearchPlaceholder')}
className="flex-1 border border-slate-200 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-slate-400 bg-white"
/>
<button
type="button"
onClick={handleMapsSearch}
disabled={isSearchingMaps}
className="bg-slate-900 text-white px-3 py-1.5 rounded-lg text-sm hover:bg-slate-700 disabled:opacity-60"
>
{isSearchingMaps ? '...' : <Search className="w-4 h-4" />}
</button>
</div>
{mapsResults.length > 0 && (
<div className="bg-white rounded-lg border border-slate-200 overflow-hidden max-h-40 overflow-y-auto mt-2">
{mapsResults.map((result, idx) => (
<button
key={idx}
type="button"
onClick={() => handleSelectMapsResult(result)}
className="w-full text-left px-3 py-2 hover:bg-slate-50 border-b border-slate-100 last:border-0"
>
<div className="font-medium text-sm">{result.name}</div>
<div className="text-xs text-slate-500 truncate">{result.address}</div>
</button>
))}
</div>
)}
</div>
{/* Name */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">{t('places.formName')} *</label>
<input
type="text"
value={form.name}
onChange={e => handleChange('name', e.target.value)}
required
placeholder={t('places.formNamePlaceholder')}
className="form-input"
/>
</div>
{/* Description */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">{t('places.formDescription')}</label>
<textarea
value={form.description}
onChange={e => handleChange('description', e.target.value)}
rows={2}
placeholder={t('places.formDescriptionPlaceholder')}
className="form-input" style={{ resize: 'none' }}
/>
</div>
{/* Address + Coordinates */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">{t('places.formAddress')}</label>
<input
type="text"
value={form.address}
onChange={e => handleChange('address', e.target.value)}
placeholder={t('places.formAddressPlaceholder')}
className="form-input"
/>
<div className="grid grid-cols-2 gap-2 mt-2">
<input
type="number"
step="any"
value={form.lat}
onChange={e => handleChange('lat', e.target.value)}
placeholder={t('places.formLat')}
className="form-input"
/>
<input
type="number"
step="any"
value={form.lng}
onChange={e => handleChange('lng', e.target.value)}
placeholder={t('places.formLng')}
className="form-input"
/>
</div>
</div>
{/* Category */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">{t('places.formCategory')}</label>
{!showNewCategory ? (
<div className="flex gap-2">
<CustomSelect
value={form.category_id}
onChange={value => handleChange('category_id', value)}
placeholder={t('places.noCategory')}
options={[
{ value: '', label: t('places.noCategory') },
...(categories || []).map(c => ({
value: c.id,
label: c.name,
})),
]}
style={{ flex: 1 }}
size="sm"
/>
</div>
) : (
<div className="flex gap-2">
<input
type="text"
value={newCategoryName}
onChange={e => setNewCategoryName(e.target.value)}
placeholder={t('places.categoryNamePlaceholder')}
className="form-input" style={{ flex: 1 }}
/>
<button type="button" onClick={handleCreateCategory} className="bg-slate-900 text-white px-3 rounded-lg hover:bg-slate-700 text-sm">
OK
</button>
<button type="button" onClick={() => setShowNewCategory(false)} className="text-gray-500 px-2 text-sm">
{t('common.cancel')}
</button>
</div>
)}
</div>
{/* Time */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">{t('places.formTime')}</label>
<CustomTimePicker
value={form.place_time}
onChange={v => handleChange('place_time', v)}
/>
</div>
{/* Website */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">{t('places.formWebsite')}</label>
<input
type="url"
value={form.website}
onChange={e => handleChange('website', e.target.value)}
placeholder="https://..."
className="form-input"
/>
</div>
{/* Reservation */}
<div className="border border-gray-200 rounded-xl p-3 space-y-3">
<div className="flex items-center gap-3">
<label className="block text-sm font-medium text-gray-700">{t('places.formReservation')}</label>
<div className="flex gap-2">
{['none', 'pending', 'confirmed'].map(status => (
<button
key={status}
type="button"
onClick={() => handleChange('reservation_status', status)}
className={`text-xs px-2.5 py-1 rounded-full transition-colors ${
form.reservation_status === status
? status === 'confirmed' ? 'bg-emerald-600 text-white'
: status === 'pending' ? 'bg-yellow-500 text-white'
: 'bg-gray-600 text-white'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}
>
{status === 'none' ? t('common.none') : status === 'pending' ? t('reservations.pending') : t('reservations.confirmed')}
</button>
))}
</div>
</div>
{form.reservation_status !== 'none' && (
<>
<CustomDateTimePicker
value={form.reservation_datetime}
onChange={v => handleChange('reservation_datetime', v)}
/>
<textarea
value={form.reservation_notes}
onChange={e => handleChange('reservation_notes', e.target.value)}
rows={2}
placeholder={t('places.reservationNotesPlaceholder')}
className="form-input" style={{ resize: 'none' }}
/>
</>
)}
</div>
{/* Actions */}
<div className="flex justify-end gap-3 pt-2 border-t border-gray-100">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm text-gray-600 hover:text-gray-900 border border-gray-200 rounded-lg hover:bg-gray-50"
>
{t('common.cancel')}
</button>
<button
type="submit"
disabled={isSaving}
className="px-6 py-2 bg-slate-900 text-white text-sm rounded-lg hover:bg-slate-700 disabled:opacity-60 font-medium"
>
{isSaving ? t('common.saving') : place ? t('common.update') : t('common.add')}
</button>
</div>
</form>
</Modal>
)
}

View File

@@ -0,0 +1,455 @@
import React, { useState, useEffect, useRef, useCallback } from 'react'
import { X, Clock, MapPin, ExternalLink, Phone, Euro, Edit2, Trash2, Plus, Minus, CheckCircle2, AlertCircle, ChevronDown, ChevronUp, FileText, Upload, File, FileImage, Star, Navigation } from 'lucide-react'
import PlaceAvatar from '../shared/PlaceAvatar'
import { mapsApi } from '../../api/client'
import { useSettingsStore } from '../../store/settingsStore'
import { getCategoryIcon } from '../shared/categoryIcons'
import { useTranslation } from '../../i18n'
const detailsCache = new Map()
function useGoogleDetails(googlePlaceId, language) {
const [details, setDetails] = useState(null)
const cacheKey = `${googlePlaceId}_${language}`
useEffect(() => {
if (!googlePlaceId) { setDetails(null); return }
if (detailsCache.has(cacheKey)) { setDetails(detailsCache.get(cacheKey)); return }
mapsApi.details(googlePlaceId, language).then(data => {
detailsCache.set(cacheKey, data.place)
setDetails(data.place)
}).catch(() => {})
}, [googlePlaceId, language])
return details
}
function getWeekdayIndex(dateStr) {
// weekdayDescriptions[0] = Monday … [6] = Sunday
const d = dateStr ? new Date(dateStr + 'T12:00:00') : new Date()
const jsDay = d.getDay()
return jsDay === 0 ? 6 : jsDay - 1
}
function convertHoursLine(line, timeFormat) {
if (!line) return ''
const hasAmPm = /\d{1,2}:\d{2}\s*(AM|PM)/i.test(line)
if (timeFormat === '12h' && !hasAmPm) {
// 24h → 12h: "10:00" → "10:00 AM", "21:00" → "9:00 PM", "Uhr" entfernen
return line.replace(/\s*Uhr/g, '').replace(/(\d{1,2}):(\d{2})/g, (match, h, m) => {
const hour = parseInt(h)
if (isNaN(hour)) return match
const period = hour >= 12 ? 'PM' : 'AM'
const h12 = hour === 0 ? 12 : hour > 12 ? hour - 12 : hour
return `${h12}:${m} ${period}`
})
}
if (timeFormat !== '12h' && hasAmPm) {
// 12h → 24h: "10:00 AM" → "10:00", "9:00 PM" → "21:00"
return line.replace(/(\d{1,2}):(\d{2})\s*(AM|PM)/gi, (_, h, m, p) => {
let hour = parseInt(h)
if (p.toUpperCase() === 'PM' && hour !== 12) hour += 12
if (p.toUpperCase() === 'AM' && hour === 12) hour = 0
return `${String(hour).padStart(2, '0')}:${m}`
})
}
return line
}
function formatTime(timeStr, locale, timeFormat) {
if (!timeStr) return ''
try {
const [h, m] = timeStr.split(':').map(Number)
if (timeFormat === '12h') {
const period = h >= 12 ? 'PM' : 'AM'
const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h
return `${h12}:${String(m).padStart(2, '0')} ${period}`
}
const str = `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`
return locale?.startsWith('de') ? `${str} Uhr` : str
} catch { return timeStr }
}
function formatReservationDatetime(dt, locale, timeFormat) {
if (!dt) return null
try {
const d = new Date(dt)
if (isNaN(d)) return dt
const datePart = d.toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short' })
const timePart = formatTime(`${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}`, locale, timeFormat)
return `${datePart}, ${timePart}`
} catch { return dt }
}
function formatFileSize(bytes) {
if (!bytes) return ''
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
}
export default function PlaceInspector({
place, categories, days, selectedDayId, assignments,
onClose, onEdit, onDelete, onAssignToDay, onRemoveAssignment,
files, onFileUpload,
}) {
const { t, locale, language } = useTranslation()
const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h'
const [hoursExpanded, setHoursExpanded] = useState(false)
const [filesExpanded, setFilesExpanded] = useState(false)
const [isUploading, setIsUploading] = useState(false)
const fileInputRef = useRef(null)
const googleDetails = useGoogleDetails(place?.google_place_id, language)
if (!place) return null
const category = categories?.find(c => c.id === place.category_id)
const dayAssignments = selectedDayId ? (assignments[String(selectedDayId)] || []) : []
const assignmentInDay = selectedDayId ? dayAssignments.find(a => a.place?.id === place.id) : null
const openingHours = googleDetails?.opening_hours || null
const openNow = googleDetails?.open_now ?? null
const selectedDay = days?.find(d => d.id === selectedDayId)
const weekdayIndex = getWeekdayIndex(selectedDay?.date)
const placeFiles = (files || []).filter(f => String(f.place_id) === String(place.id))
const handleFileUpload = useCallback(async (e) => {
const selectedFiles = Array.from(e.target.files || [])
if (!selectedFiles.length || !onFileUpload) return
setIsUploading(true)
try {
for (const file of selectedFiles) {
const fd = new FormData()
fd.append('file', file)
fd.append('place_id', place.id)
await onFileUpload(fd)
}
setFilesExpanded(true)
} catch (err) {
console.error('Upload failed', err)
} finally {
setIsUploading(false)
if (fileInputRef.current) fileInputRef.current.value = ''
}
}, [onFileUpload, place.id])
return (
<div
style={{
position: 'absolute',
bottom: 20,
left: '50%',
transform: 'translateX(-50%)',
width: 'min(800px, calc(100vw - 32px))',
zIndex: 50,
fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif",
}}
>
<div style={{
background: 'var(--bg-elevated)',
backdropFilter: 'blur(40px) saturate(180%)',
WebkitBackdropFilter: 'blur(40px) saturate(180%)',
borderRadius: 20,
boxShadow: '0 8px 40px rgba(0,0,0,0.14), 0 0 0 1px rgba(0,0,0,0.06)',
overflow: 'hidden',
maxHeight: '60vh',
display: 'flex',
flexDirection: 'column',
}}>
{/* Header */}
<div style={{ display: 'flex', alignItems: 'center', gap: openNow !== null ? 26 : 14, padding: openNow !== null ? '18px 16px 14px 28px' : '18px 16px 14px', borderBottom: '1px solid var(--border-faint)' }}>
{/* Avatar with open/closed ring + tag */}
<div style={{ position: 'relative', flexShrink: 0, marginBottom: openNow !== null ? 8 : 0 }}>
<div style={{
borderRadius: '50%', padding: 2.5,
background: openNow === true ? '#22c55e' : openNow === false ? '#ef4444' : 'transparent',
}}>
<PlaceAvatar place={place} category={category} size={52} />
</div>
{openNow !== null && (
<span style={{
position: 'absolute', bottom: -7, left: '50%', transform: 'translateX(-50%)',
fontSize: 9, fontWeight: 500, letterSpacing: '0.02em',
color: 'white',
background: openNow ? '#16a34a' : '#dc2626',
padding: '1.5px 7px', borderRadius: 99,
whiteSpace: 'nowrap',
boxShadow: '0 1px 4px rgba(0,0,0,0.2)',
}}>
{openNow ? t('inspector.opened') : t('inspector.closed')}
</span>
)}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
<span style={{ fontWeight: 600, fontSize: 15, color: 'var(--text-primary)', lineHeight: '1.3' }}>{place.name}</span>
{category && (() => {
const CatIcon = getCategoryIcon(category.icon)
return (
<span style={{
display: 'inline-flex', alignItems: 'center', gap: 4,
fontSize: 11, fontWeight: 500,
color: category.color || '#6b7280',
background: category.color ? `${category.color}18` : 'rgba(0,0,0,0.06)',
border: `1px solid ${category.color ? `${category.color}30` : 'transparent'}`,
padding: '2px 8px', borderRadius: 99,
}}>
<CatIcon size={10} />
{category.name}
</span>
)
})()}
</div>
{place.address && (
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 4, marginTop: 6 }}>
<MapPin size={11} color="var(--text-faint)" style={{ flexShrink: 0, marginTop: 2 }} />
<span style={{ fontSize: 12, color: 'var(--text-muted)', lineHeight: '1.4' }}>{place.address}</span>
</div>
)}
{place.place_time && (
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginTop: 3 }}>
<Clock size={10} color="var(--text-faint)" style={{ flexShrink: 0 }} />
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>{formatTime(place.place_time, locale, timeFormat)}</span>
</div>
)}
{place.lat && place.lng && (
<div style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: 4, fontVariantNumeric: 'tabular-nums' }}>
{Number(place.lat).toFixed(6)}, {Number(place.lng).toFixed(6)}
</div>
)}
</div>
<button
onClick={onClose}
style={{ width: 28, height: 28, borderRadius: '50%', background: 'var(--bg-hover)', border: 'none', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', flexShrink: 0, alignSelf: 'flex-start', transition: 'background 0.15s' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-tertiary)'}
onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-hover)'}
>
<X size={14} strokeWidth={2} color="var(--text-secondary)" />
</button>
</div>
{/* Content — scrollable */}
<div style={{ overflowY: 'auto', padding: '12px 16px', display: 'flex', flexDirection: 'column', gap: 10 }}>
{/* Info-Chips */}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, alignItems: 'center' }}>
{googleDetails?.rating && (() => {
const shortReview = (googleDetails.reviews || []).find(r => r.text && r.text.length > 5)
return (
<Chip
icon={<Star size={12} fill="#facc15" color="#facc15" />}
text={<>
{googleDetails.rating.toFixed(1)}
{googleDetails.rating_count ? <span style={{ opacity: 0.5 }}> ({googleDetails.rating_count.toLocaleString('de-DE')})</span> : ''}
{shortReview && <span className="hidden md:inline" style={{ opacity: 0.6, fontWeight: 400, fontStyle: 'italic', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}> · {shortReview.text}"</span>}
</>}
color="var(--text-secondary)" bg="var(--bg-hover)"
/>
)
})()}
{place.price > 0 && (
<Chip icon={<Euro size={12} />} text={`${place.price} ${place.currency || '€'}`} color="#059669" bg="#ecfdf5" />
)}
</div>
{/* Telefon */}
{place.phone && (
<div style={{ display: 'flex', gap: 12 }}>
<a href={`tel:${place.phone}`}
style={{ display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: 12, color: 'var(--text-primary)', textDecoration: 'none' }}>
<Phone size={12} /> {place.phone}
</a>
</div>
)}
{/* Description + Reservation in one box */}
{(place.description || place.notes || (place.reservation_status && place.reservation_status !== 'none')) && (
<div style={{ background: 'var(--bg-hover)', borderRadius: 10, overflow: 'hidden' }}>
{(place.description || place.notes) && (
<p style={{ fontSize: 12, color: 'var(--text-muted)', margin: 0, lineHeight: '1.5', padding: '8px 12px',
borderBottom: (place.reservation_status && place.reservation_status !== 'none') ? '1px solid var(--border-faint)' : 'none'
}}>
{place.description || place.notes}
</p>
)}
{place.reservation_status && place.reservation_status !== 'none' && (
<div style={{ padding: '8px 12px', display: 'flex', flexDirection: 'column', gap: 3 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 5, flexWrap: 'wrap' }}>
{place.reservation_status === 'confirmed'
? <CheckCircle2 size={12} color="#059669" />
: <AlertCircle size={12} color="#d97706" />
}
<span style={{ fontSize: 12, fontWeight: 600, color: place.reservation_status === 'confirmed' ? '#059669' : '#d97706' }}>
{place.reservation_status === 'confirmed' ? t('inspector.confirmedRes') : t('inspector.pendingRes')}
</span>
{(place.reservation_datetime || place.place_time) && (
<>
<span style={{ fontSize: 11, color: '#d1d5db' }}>·</span>
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>
{place.reservation_datetime
? formatReservationDatetime(place.reservation_datetime, locale, timeFormat)
: formatTime(place.place_time, locale, timeFormat)}
</span>
</>
)}
</div>
{place.reservation_notes && (
<p style={{ fontSize: 12, color: 'var(--text-faint)', margin: 0, lineHeight: '1.5', paddingLeft: 17 }}>{place.reservation_notes}</p>
)}
</div>
)}
</div>
)}
{/* Opening hours */}
{openingHours && openingHours.length > 0 && (
<div style={{ background: 'var(--bg-hover)', borderRadius: 10, overflow: 'hidden' }}>
<button
onClick={() => setHoursExpanded(h => !h)}
style={{
width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '8px 12px', background: 'none', border: 'none', cursor: 'pointer',
fontFamily: 'inherit',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<Clock size={13} color="#9ca3af" />
<span style={{ fontSize: 12, color: 'var(--text-secondary)', fontWeight: 500 }}>
{hoursExpanded ? t('inspector.openingHours') : (convertHoursLine(openingHours[weekdayIndex] || '', timeFormat) || t('inspector.showHours'))}
</span>
</div>
{hoursExpanded ? <ChevronUp size={13} color="#9ca3af" /> : <ChevronDown size={13} color="#9ca3af" />}
</button>
{hoursExpanded && (
<div style={{ padding: '0 12px 10px' }}>
{openingHours.map((line, i) => (
<div key={i} style={{
fontSize: 12, color: i === weekdayIndex ? 'var(--text-primary)' : 'var(--text-muted)',
fontWeight: i === weekdayIndex ? 600 : 400,
padding: '2px 0',
}}>{convertHoursLine(line, timeFormat)}</div>
))}
</div>
)}
</div>
)}
{/* Files section */}
{(placeFiles.length > 0 || onFileUpload) && (
<div style={{ background: 'var(--bg-hover)', borderRadius: 10, overflow: 'hidden' }}>
<div style={{ display: 'flex', alignItems: 'center', padding: '8px 12px', gap: 6 }}>
<button
onClick={() => setFilesExpanded(f => !f)}
style={{ flex: 1, display: 'flex', alignItems: 'center', gap: 6, background: 'none', border: 'none', cursor: 'pointer', padding: 0, fontFamily: 'inherit', textAlign: 'left' }}
>
<FileText size={13} color="#9ca3af" />
<span style={{ fontSize: 12, color: 'var(--text-secondary)', fontWeight: 500 }}>
{placeFiles.length > 0 ? t('inspector.filesCount', { count: placeFiles.length }) : t('inspector.files')}
</span>
{filesExpanded ? <ChevronUp size={12} color="#9ca3af" /> : <ChevronDown size={12} color="#9ca3af" />}
</button>
{onFileUpload && (
<label style={{ cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 4, fontSize: 11, color: 'var(--text-muted)', padding: '2px 6px', borderRadius: 6, background: 'var(--bg-tertiary)' }}>
<input ref={fileInputRef} type="file" multiple style={{ display: 'none' }} onChange={handleFileUpload} />
{isUploading ? (
<span style={{ fontSize: 11 }}>…</span>
) : (
<><Upload size={11} strokeWidth={2} /> {t('common.upload')}</>
)}
</label>
)}
</div>
{filesExpanded && placeFiles.length > 0 && (
<div style={{ padding: '0 12px 10px', display: 'flex', flexDirection: 'column', gap: 4 }}>
{placeFiles.map(f => (
<div key={f.id} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
{(f.mime_type || '').startsWith('image/') ? <FileImage size={12} color="#6b7280" /> : <File size={12} color="#6b7280" />}
<span style={{ fontSize: 12, color: 'var(--text-secondary)', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.original_name}</span>
{f.file_size && <span style={{ fontSize: 11, color: 'var(--text-faint)', flexShrink: 0 }}>{formatFileSize(f.file_size)}</span>}
<a
href={`/uploads/files/${f.filename}`}
target="_blank"
rel="noopener noreferrer"
style={{ flexShrink: 0, color: 'var(--text-faint)', display: 'flex' }}
>
<ExternalLink size={11} />
</a>
</div>
))}
</div>
)}
</div>
)}
</div>
{/* Footer actions */}
<div style={{ padding: '10px 16px', borderTop: '1px solid var(--border-faint)', display: 'flex', gap: 6, alignItems: 'center', flexWrap: 'wrap' }}>
{selectedDayId && (
assignmentInDay ? (
<ActionButton onClick={() => onRemoveAssignment(selectedDayId, assignmentInDay.id)} variant="ghost" icon={<Minus size={13} />}
label={<><span className="hidden sm:inline">{t('inspector.removeFromDay')}</span><span className="sm:hidden">Remove</span></>} />
) : (
<ActionButton onClick={() => onAssignToDay(place.id)} variant="primary" icon={<Plus size={13} />} label={t('inspector.addToDay')} />
)
)}
{googleDetails?.google_maps_url && (
<ActionButton onClick={() => window.open(googleDetails.google_maps_url, '_blank')} variant="ghost" icon={<Navigation size={13} />}
label={<span className="hidden sm:inline">{t('inspector.google')}</span>} />
)}
{place.website && (
<ActionButton onClick={() => window.open(place.website, '_blank')} variant="ghost" icon={<ExternalLink size={13} />}
label={<span className="hidden sm:inline">{t('inspector.website')}</span>} />
)}
<div style={{ flex: 1 }} />
<ActionButton onClick={onEdit} variant="ghost" icon={<Edit2 size={13} />} label={<span className="hidden sm:inline">{t('common.edit')}</span>} />
<ActionButton onClick={onDelete} variant="danger" icon={<Trash2 size={13} />} label={<span className="hidden sm:inline">{t('common.delete')}</span>} />
</div>
</div>
</div>
)
}
function Chip({ icon, text, color = 'var(--text-secondary)', bg = 'var(--bg-hover)' }) {
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 4, padding: '3px 9px', borderRadius: 99, background: bg, color, fontSize: 12, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', minWidth: 0 }}>
<span style={{ flexShrink: 0, display: 'flex' }}>{icon}</span>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{text}</span>
</div>
)
}
function Row({ icon, children }) {
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div style={{ flexShrink: 0 }}>{icon}</div>
<div style={{ flex: 1, minWidth: 0 }}>{children}</div>
</div>
)
}
function ActionButton({ onClick, variant, icon, label }) {
const base = {
primary: { background: 'var(--accent)', color: 'var(--accent-text)', border: 'none', hoverBg: 'var(--text-secondary)' },
ghost: { background: 'var(--bg-hover)', color: 'var(--text-secondary)', border: 'none', hoverBg: 'var(--bg-tertiary)' },
danger: { background: 'rgba(239,68,68,0.08)', color: '#dc2626', border: 'none', hoverBg: 'rgba(239,68,68,0.16)' },
}
const s = base[variant] || base.ghost
return (
<button
onClick={onClick}
style={{
display: 'flex', alignItems: 'center', gap: 5,
padding: '6px 12px', borderRadius: 10, minHeight: 30,
fontSize: 12, fontWeight: 500, cursor: 'pointer',
fontFamily: 'inherit', transition: 'background 0.15s, opacity 0.15s',
background: s.background, color: s.color, border: s.border,
}}
onMouseEnter={e => e.currentTarget.style.background = s.hoverBg}
onMouseLeave={e => e.currentTarget.style.background = s.background}
>
{icon}{label}
</button>
)
}

View File

@@ -0,0 +1,223 @@
import React, { useState, useMemo } from 'react'
import DraggablePlaceCard from './DraggablePlaceCard'
import { Search, Plus, Filter, Map, X, SlidersHorizontal } from 'lucide-react'
export default function PlacesPanel({
places,
categories,
tags,
assignments,
tripId,
onAddPlace,
onEditPlace,
hasMapKey,
onSearchMaps,
}) {
const [search, setSearch] = useState('')
const [selectedCategory, setSelectedCategory] = useState('')
const [selectedTags, setSelectedTags] = useState([])
const [showFilters, setShowFilters] = useState(false)
// Get set of assigned place IDs (for any day)
const assignedPlaceIds = useMemo(() => {
const ids = new Set()
Object.values(assignments || {}).forEach(dayAssignments => {
dayAssignments.forEach(a => {
if (a.place?.id) ids.add(a.place.id)
})
})
return ids
}, [assignments])
const filteredPlaces = useMemo(() => {
return places.filter(place => {
if (search) {
const q = search.toLowerCase()
if (!place.name.toLowerCase().includes(q) &&
!place.address?.toLowerCase().includes(q) &&
!place.description?.toLowerCase().includes(q)) {
return false
}
}
if (selectedCategory && place.category_id !== parseInt(selectedCategory)) {
return false
}
if (selectedTags.length > 0) {
const placeTags = (place.tags || []).map(t => t.id)
if (!selectedTags.every(tagId => placeTags.includes(tagId))) {
return false
}
}
return true
})
}, [places, search, selectedCategory, selectedTags])
const toggleTag = (tagId) => {
setSelectedTags(prev =>
prev.includes(tagId) ? prev.filter(id => id !== tagId) : [...prev, tagId]
)
}
const clearFilters = () => {
setSearch('')
setSelectedCategory('')
setSelectedTags([])
}
const hasActiveFilters = search || selectedCategory || selectedTags.length > 0
return (
<div className="flex flex-col h-full bg-white border-r border-slate-200">
{/* Header */}
<div className="p-3 border-b border-slate-100">
<div className="flex items-center justify-between mb-2">
<h2 className="text-sm font-semibold text-slate-800">
Places
<span className="ml-1.5 text-xs font-normal text-slate-400">
({filteredPlaces.length}{filteredPlaces.length !== places.length ? `/${places.length}` : ''})
</span>
</h2>
<div className="flex gap-1">
{hasMapKey && (
<button
onClick={onSearchMaps}
className="p-1.5 text-slate-400 hover:text-slate-700 hover:bg-slate-50 rounded-lg transition-colors"
title="Search Google Maps"
>
<Map className="w-4 h-4" />
</button>
)}
<button
onClick={() => setShowFilters(!showFilters)}
className={`p-1.5 rounded-lg transition-colors ${
showFilters || hasActiveFilters
? 'text-slate-700 bg-slate-50'
: 'text-slate-400 hover:text-slate-600 hover:bg-slate-100'
}`}
title="Filters"
>
<SlidersHorizontal className="w-4 h-4" />
</button>
</div>
</div>
{/* Search */}
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<input
type="text"
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="Search places..."
className="w-full pl-8 pr-3 py-1.5 text-sm border border-slate-200 rounded-lg focus:ring-2 focus:ring-slate-900 focus:border-transparent transition-all"
/>
{search && (
<button
onClick={() => setSearch('')}
className="absolute right-2 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600"
>
<X className="w-3.5 h-3.5" />
</button>
)}
</div>
{/* Filters */}
{showFilters && (
<div className="mt-2 space-y-2">
{/* Category filter */}
{categories.length > 0 && (
<select
value={selectedCategory}
onChange={e => setSelectedCategory(e.target.value)}
className="w-full px-2.5 py-1.5 text-sm border border-slate-200 rounded-lg focus:ring-2 focus:ring-slate-900 focus:border-transparent bg-white"
>
<option value="">All categories</option>
{categories.map(cat => (
<option key={cat.id} value={cat.id}>{cat.name}</option>
))}
</select>
)}
{/* Tag filters */}
{tags.length > 0 && (
<div className="flex flex-wrap gap-1">
{tags.map(tag => (
<button
key={tag.id}
onClick={() => toggleTag(tag.id)}
className={`text-xs px-2 py-0.5 rounded-full font-medium transition-all ${
selectedTags.includes(tag.id)
? 'text-white shadow-sm'
: 'text-white opacity-50 hover:opacity-80'
}`}
style={{ backgroundColor: tag.color || '#6366f1' }}
>
{tag.name}
</button>
))}
</div>
)}
{hasActiveFilters && (
<button
onClick={clearFilters}
className="text-xs text-slate-500 hover:text-red-500 flex items-center gap-1"
>
<X className="w-3 h-3" />
Clear filters
</button>
)}
</div>
)}
</div>
{/* Add place button */}
<div className="px-3 py-2 border-b border-slate-100">
<button
onClick={onAddPlace}
className="w-full flex items-center justify-center gap-2 py-2 text-sm text-slate-700 hover:text-slate-900 bg-slate-50 hover:bg-slate-100 rounded-lg transition-colors font-medium"
>
<Plus className="w-4 h-4" />
Add Place
</button>
</div>
{/* Places list */}
<div className="flex-1 overflow-y-auto p-3 space-y-2 scroll-container">
{filteredPlaces.length === 0 ? (
<div className="text-center py-8">
<div className="w-12 h-12 bg-slate-100 rounded-full flex items-center justify-center mx-auto mb-3">
<Search className="w-6 h-6 text-slate-400" />
</div>
{places.length === 0 ? (
<>
<p className="text-sm font-medium text-slate-600">No places yet</p>
<p className="text-xs text-slate-400 mt-1">Add places and drag them to days</p>
<button
onClick={onAddPlace}
className="mt-3 text-sm text-slate-700 hover:text-slate-900 font-medium"
>
+ Add your first place
</button>
</>
) : (
<>
<p className="text-sm font-medium text-slate-600">No matches found</p>
<p className="text-xs text-slate-400 mt-1">Try adjusting your filters</p>
</>
)}
</div>
) : (
filteredPlaces.map(place => (
<DraggablePlaceCard
key={place.id}
place={place}
isAssigned={assignedPlaceIds.has(place.id)}
onEdit={onEditPlace}
/>
))
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,185 @@
import React, { useState } from 'react'
import { Search, Plus, X } from 'lucide-react'
import PlaceAvatar from '../shared/PlaceAvatar'
import { getCategoryIcon } from '../shared/categoryIcons'
import { useTranslation } from '../../i18n'
import CustomSelect from '../shared/CustomSelect'
export default function PlacesSidebar({
places, categories, assignments, selectedDayId, selectedPlaceId,
onPlaceClick, onAddPlace, onAssignToDay,
}) {
const { t } = useTranslation()
const [search, setSearch] = useState('')
const [filter, setFilter] = useState('all') // 'all' | 'ungeplant'
const [categoryFilter, setCategoryFilter] = useState('')
// Alle geplanten Ort-IDs abrufen (einem Tag zugewiesen)
const plannedIds = new Set(
Object.values(assignments).flatMap(da => da.map(a => a.place?.id).filter(Boolean))
)
const filtered = places.filter(p => {
if (filter === 'unplanned' && plannedIds.has(p.id)) return false
if (categoryFilter && String(p.category_id) !== String(categoryFilter)) return false
if (search && !p.name.toLowerCase().includes(search.toLowerCase()) &&
!(p.address || '').toLowerCase().includes(search.toLowerCase())) return false
return true
})
const isAssignedToSelectedDay = (placeId) =>
selectedDayId && (assignments[String(selectedDayId)] || []).some(a => a.place?.id === placeId)
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }}>
{/* Kopfbereich */}
<div style={{ padding: '14px 16px 10px', borderBottom: '1px solid var(--border-faint)', flexShrink: 0 }}>
<button
onClick={onAddPlace}
style={{
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
width: '100%', padding: '8px 12px', borderRadius: 12, border: 'none',
background: 'var(--accent)', color: 'var(--accent-text)', fontSize: 13, fontWeight: 500,
cursor: 'pointer', fontFamily: 'inherit', marginBottom: 10,
}}
>
<Plus size={14} strokeWidth={2} /> {t('places.addPlace')}
</button>
{/* Filter-Tabs */}
<div style={{ display: 'flex', gap: 4, marginBottom: 8 }}>
{[{ id: 'all', label: t('places.all') }, { id: 'unplanned', label: t('places.unplanned') }].map(f => (
<button key={f.id} onClick={() => setFilter(f.id)} style={{
padding: '4px 10px', borderRadius: 20, border: 'none', cursor: 'pointer',
fontSize: 11, fontWeight: 500, fontFamily: 'inherit',
background: filter === f.id ? 'var(--accent)' : 'var(--bg-tertiary)',
color: filter === f.id ? 'var(--accent-text)' : 'var(--text-muted)',
}}>{f.label}</button>
))}
</div>
{/* Suchfeld */}
<div style={{ position: 'relative' }}>
<Search size={13} strokeWidth={1.8} color="var(--text-faint)" style={{ position: 'absolute', left: 10, top: '50%', transform: 'translateY(-50%)', pointerEvents: 'none' }} />
<input
type="text"
value={search}
onChange={e => setSearch(e.target.value)}
placeholder={t('places.search')}
style={{
width: '100%', padding: '7px 30px 7px 30px', borderRadius: 10,
border: 'none', background: 'var(--bg-tertiary)', fontSize: 12, color: 'var(--text-primary)',
outline: 'none', fontFamily: 'inherit', boxSizing: 'border-box',
}}
/>
{search && (
<button onClick={() => setSearch('')} style={{ position: 'absolute', right: 8, top: '50%', transform: 'translateY(-50%)', background: 'none', border: 'none', cursor: 'pointer', padding: 0, display: 'flex' }}>
<X size={12} strokeWidth={2} color="var(--text-faint)" />
</button>
)}
</div>
{/* Kategoriefilter */}
{categories.length > 0 && (
<div style={{ marginTop: 6 }}>
<CustomSelect
value={categoryFilter}
onChange={setCategoryFilter}
placeholder={t('places.allCategories')}
size="sm"
options={[
{ value: '', label: t('places.allCategories') },
...categories.map(c => ({ value: String(c.id), label: c.name }))
]}
/>
</div>
)}
</div>
{/* Anzahl */}
<div style={{ padding: '6px 16px', flexShrink: 0 }}>
<span style={{ fontSize: 11, color: 'var(--text-faint)' }}>{filtered.length === 1 ? t('places.countSingular') : t('places.count', { count: filtered.length })}</span>
</div>
{/* Liste */}
<div style={{ flex: 1, overflowY: 'auto', minHeight: 0 }}>
{filtered.length === 0 ? (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', padding: '40px 16px', gap: 8 }}>
<span style={{ fontSize: 13, color: 'var(--text-faint)' }}>
{filter === 'unplanned' ? t('places.allPlanned') : t('places.noneFound')}
</span>
<button onClick={onAddPlace} style={{ fontSize: 12, color: 'var(--text-primary)', background: 'none', border: 'none', cursor: 'pointer', textDecoration: 'underline', fontFamily: 'inherit' }}>
{t('places.addPlace')}
</button>
</div>
) : (
filtered.map(place => {
const cat = categories.find(c => c.id === place.category_id)
const isSelected = place.id === selectedPlaceId
const inDay = isAssignedToSelectedDay(place.id)
const isPlanned = plannedIds.has(place.id)
return (
<div
key={place.id}
draggable
onDragStart={e => {
e.dataTransfer.setData('placeId', String(place.id))
e.dataTransfer.effectAllowed = 'copy'
// Backup in window für Cross-Component Drag (dataTransfer geht bei Re-Render verloren)
window.__dragData = { placeId: String(place.id) }
}}
onClick={() => onPlaceClick(isSelected ? null : place.id)}
style={{
display: 'flex', alignItems: 'center', gap: 10,
padding: '9px 14px 9px 16px',
cursor: 'grab',
background: isSelected ? 'var(--border-faint)' : 'transparent',
borderBottom: '1px solid var(--border-faint)',
transition: 'background 0.1s',
}}
onMouseEnter={e => { if (!isSelected) e.currentTarget.style.background = 'var(--bg-hover)' }}
onMouseLeave={e => { if (!isSelected) e.currentTarget.style.background = 'transparent' }}
>
<PlaceAvatar place={place} category={cat} size={34} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 5, overflow: 'hidden' }}>
{cat && (() => {
const CatIcon = getCategoryIcon(cat.icon)
return <CatIcon size={11} strokeWidth={2} color={cat.color || '#6366f1'} style={{ flexShrink: 0 }} title={cat.name} />
})()}
<span style={{ fontSize: 13, fontWeight: 500, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', lineHeight: 1.2 }}>
{place.name}
</span>
</div>
{(place.description || place.address || cat?.name) && (
<div style={{ marginTop: 2 }}>
<span style={{ fontSize: 11, color: 'var(--text-faint)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', display: 'block', lineHeight: 1.2 }}>
{place.description || place.address || cat?.name}
</span>
</div>
)}
</div>
<div style={{ flexShrink: 0, display: 'flex', alignItems: 'center' }}>
{!inDay && selectedDayId && (
<button
onClick={e => { e.stopPropagation(); onAssignToDay(place.id) }}
style={{
display: 'flex', alignItems: 'center', justifyContent: 'center',
width: 20, height: 20, borderRadius: 6,
background: 'var(--bg-hover)', border: 'none', cursor: 'pointer',
color: 'var(--text-faint)', padding: 0, transition: 'background 0.15s, color 0.15s',
}}
onMouseEnter={e => { e.currentTarget.style.background = 'var(--accent)'; e.currentTarget.style.color = 'var(--accent-text)' }}
onMouseLeave={e => { e.currentTarget.style.background = 'var(--bg-hover)'; e.currentTarget.style.color = 'var(--text-faint)' }}
><Plus size={12} strokeWidth={2.5} /></button>
)}
</div>
</div>
)
})
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,893 @@
import React, { useState, useCallback, useEffect, useRef } from 'react'
import {
Plus, Search, X, Navigation, RotateCcw, ExternalLink,
ChevronDown, ChevronRight, ChevronUp, Clock, MapPin,
CalendarDays, FileText, Check, Pencil, Trash2,
} from 'lucide-react'
import { calculateRoute, generateGoogleMapsUrl, optimizeRoute } from '../Map/RouteCalculator'
import PackingListPanel from '../Packing/PackingListPanel'
import FileManager from '../Files/FileManager'
import { ReservationModal } from './ReservationModal'
import { PlaceDetailPanel } from './PlaceDetailPanel'
import WeatherWidget from '../Weather/WeatherWidget'
import { useTripStore } from '../../store/tripStore'
import { useToast } from '../shared/Toast'
const SEGMENTS = [
{ id: 'plan', label: 'Plan' },
{ id: 'orte', label: 'Orte' },
{ id: 'reservierungen', label: 'Buchungen' },
{ id: 'packliste', label: 'Packliste' },
{ id: 'dokumente', label: 'Dokumente' },
]
const TRANSPORT_MODES = [
{ value: 'driving', label: 'Auto', icon: '🚗' },
{ value: 'walking', label: 'Fuß', icon: '🚶' },
{ value: 'cycling', label: 'Rad', icon: '🚲' },
]
function formatShortDate(dateStr) {
if (!dateStr) return ''
return new Date(dateStr + 'T00:00:00').toLocaleDateString('de-DE', {
day: 'numeric', month: 'short',
})
}
function formatDateTime(dt) {
if (!dt) return ''
try {
return new Date(dt).toLocaleString('de-DE', { dateStyle: 'medium', timeStyle: 'short' })
} catch { return dt }
}
export default function PlannerSidebar({
trip, days, places, categories, tags,
assignments, reservations, packingItems,
selectedDayId, selectedPlaceId,
onSelectDay, onPlaceClick, onPlaceEdit, onPlaceDelete,
onAssignToDay, onRemoveAssignment, onReorder,
onAddPlace, onEditTrip, onRouteCalculated, tripId,
}) {
const [activeSegment, setActiveSegment] = useState('plan')
const [search, setSearch] = useState('')
const [categoryFilter, setCategoryFilter] = useState('')
const [transportMode, setTransportMode] = useState('driving')
const [isCalculatingRoute, setIsCalculatingRoute] = useState(false)
const [showReservationModal, setShowReservationModal] = useState(false)
const [editingReservation, setEditingReservation] = useState(null)
const [routeInfo, setRouteInfo] = useState(null)
const [expandedDays, setExpandedDays] = useState(new Set())
// Day notes inline UI state: { [dayId]: { mode: 'add'|'edit', noteId?, text, time } }
const [noteUi, setNoteUi] = useState({})
const noteInputRef = useRef(null)
const tripStore = useTripStore()
const toast = useToast()
const dayNotes = tripStore.dayNotes || {}
// Auto-expand selected day
useEffect(() => {
if (selectedDayId) {
setExpandedDays(prev => new Set([...prev, selectedDayId]))
}
}, [selectedDayId])
const toggleDay = (dayId) => {
setExpandedDays(prev => {
const next = new Set(prev)
if (next.has(dayId)) next.delete(dayId)
else next.add(dayId)
return next
})
}
const getDayAssignments = (dayId) =>
(assignments[String(dayId)] || []).slice().sort((a, b) => a.order_index - b.order_index)
const selectedDayAssignments = selectedDayId ? getDayAssignments(selectedDayId) : []
const selectedDay = selectedDayId ? days.find(d => d.id === selectedDayId) : null
const filteredPlaces = places.filter(p => {
const matchSearch = !search || p.name.toLowerCase().includes(search.toLowerCase()) ||
(p.address || '').toLowerCase().includes(search.toLowerCase())
const matchCat = !categoryFilter || String(p.category_id) === String(categoryFilter)
return matchSearch && matchCat
})
const isAssignedToDay = (placeId) =>
selectedDayId && selectedDayAssignments.some(a => a.place?.id === placeId)
const totalCost = days.reduce((sum, d) => {
const da = assignments[String(d.id)] || []
return sum + da.reduce((s, a) => s + (parseFloat(a.place?.price) || 0), 0)
}, 0)
const currency = trip?.currency || 'EUR'
const filteredReservations = selectedDayId
? reservations.filter(r => String(r.day_id) === String(selectedDayId) || !r.day_id)
: reservations
// Get representative location for a day (first place with coords)
const getDayLocation = (dayId) => {
const da = getDayAssignments(dayId)
const p = da.find(a => a.place?.lat && a.place?.lng)
return p ? { lat: p.place.lat, lng: p.place.lng } : null
}
// Route handlers
const handleCalculateRoute = async () => {
if (!selectedDayId) return
const waypoints = selectedDayAssignments
.map(a => a.place)
.filter(p => p?.lat && p?.lng)
.map(p => ({ lat: p.lat, lng: p.lng }))
if (waypoints.length < 2) {
toast.error('Mindestens 2 Orte mit Koordinaten benötigt')
return
}
setIsCalculatingRoute(true)
try {
const result = await calculateRoute(waypoints, transportMode)
setRouteInfo({ distance: result.distanceText, duration: result.durationText })
onRouteCalculated?.(result)
toast.success('Route berechnet')
} catch {
toast.error('Route konnte nicht berechnet werden')
} finally {
setIsCalculatingRoute(false)
}
}
const handleOptimizeRoute = async () => {
if (!selectedDayId || selectedDayAssignments.length < 3) return
const withCoords = selectedDayAssignments.map(a => a.place).filter(p => p?.lat && p?.lng)
const optimized = optimizeRoute(withCoords)
const reorderedIds = optimized
.map(p => selectedDayAssignments.find(a => a.place?.id === p.id)?.id)
.filter(Boolean)
// Append assignments without coordinates at end
for (const a of selectedDayAssignments) {
if (!reorderedIds.includes(a.id)) reorderedIds.push(a.id)
}
await onReorder(selectedDayId, reorderedIds)
toast.success('Route optimiert')
}
const handleOpenGoogleMaps = () => {
const ps = selectedDayAssignments.map(a => a.place).filter(p => p?.lat && p?.lng)
const url = generateGoogleMapsUrl(ps)
if (url) window.open(url, '_blank')
else toast.error('Keine Orte mit Koordinaten vorhanden')
}
const handleMoveUp = async (dayId, idx) => {
const da = getDayAssignments(dayId)
if (idx === 0) return
const ids = da.map(a => a.id)
;[ids[idx - 1], ids[idx]] = [ids[idx], ids[idx - 1]]
await onReorder(dayId, ids)
}
const handleMoveDown = async (dayId, idx) => {
const da = getDayAssignments(dayId)
if (idx === da.length - 1) return
const ids = da.map(a => a.id)
;[ids[idx], ids[idx + 1]] = [ids[idx + 1], ids[idx]]
await onReorder(dayId, ids)
}
// Merge place assignments + day notes into a single sorted list
const getMergedDayItems = (dayId) => {
const da = getDayAssignments(dayId)
const dn = (dayNotes[String(dayId)] || []).slice().sort((a, b) => a.sort_order - b.sort_order)
return [
...da.map(a => ({ type: 'place', sortKey: a.order_index, data: a })),
...dn.map(n => ({ type: 'note', sortKey: n.sort_order, data: n })),
].sort((a, b) => a.sortKey - b.sortKey)
}
const openAddNote = (dayId) => {
const merged = getMergedDayItems(dayId)
const maxKey = merged.length > 0 ? Math.max(...merged.map(i => i.sortKey)) : -1
setNoteUi(prev => ({ ...prev, [dayId]: { mode: 'add', text: '', time: '', sortOrder: maxKey + 1 } }))
setTimeout(() => noteInputRef.current?.focus(), 50)
}
const openEditNote = (dayId, note) => {
setNoteUi(prev => ({ ...prev, [dayId]: { mode: 'edit', noteId: note.id, text: note.text, time: note.time || '' } }))
setTimeout(() => noteInputRef.current?.focus(), 50)
}
const cancelNote = (dayId) => {
setNoteUi(prev => { const n = { ...prev }; delete n[dayId]; return n })
}
const saveNote = async (dayId) => {
const ui = noteUi[dayId]
if (!ui?.text?.trim()) return
try {
if (ui.mode === 'add') {
await tripStore.addDayNote(tripId, dayId, { text: ui.text.trim(), time: ui.time || null, sort_order: ui.sortOrder })
} else {
await tripStore.updateDayNote(tripId, dayId, ui.noteId, { text: ui.text.trim(), time: ui.time || null })
}
cancelNote(dayId)
} catch (err) {
toast.error(err.message)
}
}
const handleDeleteNote = async (dayId, noteId) => {
try {
await tripStore.deleteDayNote(tripId, dayId, noteId)
} catch (err) {
toast.error(err.message)
}
}
const handleNoteMoveUp = async (dayId, noteId) => {
const merged = getMergedDayItems(dayId)
const idx = merged.findIndex(item => item.type === 'note' && item.data.id === noteId)
if (idx <= 0) return
const newSortOrder = idx >= 2
? (merged[idx - 2].sortKey + merged[idx - 1].sortKey) / 2
: merged[idx - 1].sortKey - 1
try {
await tripStore.updateDayNote(tripId, dayId, noteId, { sort_order: newSortOrder })
} catch (err) {
toast.error(err.message)
}
}
const handleNoteMoveDown = async (dayId, noteId) => {
const merged = getMergedDayItems(dayId)
const idx = merged.findIndex(item => item.type === 'note' && item.data.id === noteId)
if (idx === -1 || idx >= merged.length - 1) return
const newSortOrder = idx < merged.length - 2
? (merged[idx + 1].sortKey + merged[idx + 2].sortKey) / 2
: merged[idx + 1].sortKey + 1
try {
await tripStore.updateDayNote(tripId, dayId, noteId, { sort_order: newSortOrder })
} catch (err) {
toast.error(err.message)
}
}
const handleSaveReservation = async (data) => {
try {
if (editingReservation) {
await tripStore.updateReservation(tripId, editingReservation.id, data)
toast.success('Reservierung aktualisiert')
} else {
await tripStore.addReservation(tripId, { ...data, day_id: selectedDayId || null })
toast.success('Reservierung hinzugefügt')
}
setShowReservationModal(false)
} catch (err) {
toast.error(err.message)
}
}
const handleDeleteReservation = async (id) => {
if (!confirm('Reservierung löschen?')) return
try {
await tripStore.deleteReservation(tripId, id)
toast.success('Reservierung gelöscht')
} catch (err) {
toast.error(err.message)
}
}
// Inspector: show when a place is selected
const selectedPlace = selectedPlaceId ? places.find(p => p.id === selectedPlaceId) : null
return (
<div className="flex flex-col h-full bg-white relative overflow-hidden" style={{ fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', system-ui, sans-serif" }}>
{/* Trip header */}
<div className="px-4 pt-4 pb-3 flex-shrink-0 border-b border-gray-100">
<button onClick={onEditTrip} className="w-full text-left group">
<h1 className="font-semibold text-gray-900 text-[15px] leading-tight truncate group-hover:text-slate-600 transition-colors">
{trip?.title}
</h1>
{(trip?.start_date || trip?.end_date) && (
<p className="text-xs text-gray-400 mt-0.5">
{trip.start_date && formatShortDate(trip.start_date)}
{trip.start_date && trip.end_date && ' '}
{trip.end_date && formatShortDate(trip.end_date)}
{days.length > 0 && ` · ${days.length} Tage`}
</p>
)}
</button>
</div>
{/* Segmented control */}
<div className="px-3 py-2 flex-shrink-0 border-b border-gray-100">
<div className="flex bg-gray-100 rounded-[10px] p-0.5 gap-0.5">
{SEGMENTS.map(seg => (
<button
key={seg.id}
onClick={() => setActiveSegment(seg.id)}
className={`flex-1 py-[5px] text-[11px] font-medium rounded-[8px] transition-all duration-150 leading-none ${
activeSegment === seg.id
? 'bg-white shadow-sm text-gray-900'
: 'text-gray-500 hover:text-gray-700'
}`}
>
{seg.label}
</button>
))}
</div>
</div>
{/* Scrollable content */}
<div className="flex-1 overflow-y-auto min-h-0">
{/* ── PLAN ── */}
{activeSegment === 'plan' && (
<div className="pb-4">
{/* Alle Orte */}
<button
onClick={() => onSelectDay(null)}
className={`w-full text-left px-4 py-3 flex items-center gap-3 transition-colors border-b border-gray-50 ${
selectedDayId === null ? 'bg-slate-100/70' : 'hover:bg-gray-50/80'
}`}
>
<div className={`w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 ${
selectedDayId === null ? 'bg-slate-900' : 'bg-gray-100'
}`}>
<MapPin className={`w-4 h-4 ${selectedDayId === null ? 'text-white' : 'text-gray-400'}`} />
</div>
<div className="flex-1 min-w-0">
<p className={`text-sm font-medium ${selectedDayId === null ? 'text-slate-900' : 'text-gray-700'}`}>
Alle Orte
</p>
<p className="text-xs text-gray-400">{places.length} Orte gesamt</p>
</div>
</button>
{days.length === 0 ? (
<div className="px-4 py-10 text-center">
<CalendarDays className="w-10 h-10 text-gray-200 mx-auto mb-3" />
<p className="text-sm text-gray-400">Noch keine Tage geplant</p>
<button onClick={onEditTrip} className="mt-2 text-slate-700 text-sm">
Reise bearbeiten
</button>
</div>
) : (
days.map((day, index) => {
const isSelected = selectedDayId === day.id
const isExpanded = expandedDays.has(day.id)
const da = getDayAssignments(day.id)
const cost = da.reduce((s, a) => s + (parseFloat(a.place?.price) || 0), 0)
const loc = getDayLocation(day.id)
const merged = getMergedDayItems(day.id)
const dayNoteUi = noteUi[day.id]
const placeItems = merged.filter(i => i.type === 'place')
return (
<div key={day.id} className="border-b border-gray-50">
{/* Day header row */}
<div
className={`flex items-center gap-3 px-4 py-3 cursor-pointer select-none transition-colors ${
isSelected ? 'bg-slate-100/60' : 'hover:bg-gray-50/80'
}`}
onClick={() => {
onSelectDay(day.id)
if (!isExpanded) toggleDay(day.id)
}}
>
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0 ${
isSelected ? 'bg-slate-900 text-white' : 'bg-gray-100 text-gray-500'
}`}>
{index + 1}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className={`text-sm font-medium truncate ${isSelected ? 'text-slate-900' : 'text-gray-800'}`}>
{day.title || `Tag ${index + 1}`}
</p>
{da.length > 0 && (
<span className="text-xs text-gray-400 flex-shrink-0">
{da.length} {da.length === 1 ? 'Ort' : 'Orte'}
</span>
)}
</div>
<div className="flex items-center gap-2 mt-0.5 flex-wrap">
{day.date && <span className="text-xs text-gray-400">{formatShortDate(day.date)}</span>}
{cost > 0 && <span className="text-xs text-emerald-600">{cost.toFixed(0)} {currency}</span>}
{day.date && loc && (
<WeatherWidget lat={loc.lat} lng={loc.lng} date={day.date} compact />
)}
</div>
</div>
<button
onClick={e => { e.stopPropagation(); openAddNote(day.id); if (!isExpanded) toggleDay(day.id) }}
title="Notiz hinzufügen"
className="p-1 text-gray-300 hover:text-amber-500 flex-shrink-0 transition-colors"
>
<FileText className="w-4 h-4" />
</button>
<button
onClick={e => { e.stopPropagation(); toggleDay(day.id) }}
className="p-1 text-gray-300 hover:text-gray-500 flex-shrink-0"
>
{isExpanded
? <ChevronDown className="w-4 h-4" />
: <ChevronRight className="w-4 h-4" />
}
</button>
</div>
{/* Expanded items: places + notes interleaved */}
{isExpanded && (
<div className="bg-gray-50/40">
{merged.length === 0 && !dayNoteUi ? (
<div className="px-4 py-4 text-center">
<p className="text-xs text-gray-400">Keine Einträge für diesen Tag</p>
<button
onClick={() => { onSelectDay(day.id); setActiveSegment('orte') }}
className="mt-1 text-xs text-slate-700"
>
+ Ort hinzufügen
</button>
</div>
) : (
<div className="divide-y divide-gray-100/60">
{merged.map((item, idx) => {
if (item.type === 'place') {
const assignment = item.data
const place = assignment.place
if (!place) return null
const category = categories.find(c => c.id === place.category_id)
const isPlaceSelected = place.id === selectedPlaceId
const placeIdx = placeItems.findIndex(i => i.data.id === assignment.id)
return (
<div
key={`place-${assignment.id}`}
className={`group flex items-center gap-2.5 pl-4 pr-3 py-2.5 cursor-pointer transition-colors ${
isPlaceSelected ? 'bg-slate-50' : 'hover:bg-white/80'
}`}
onClick={() => onPlaceClick(isPlaceSelected ? null : place.id)}
>
<div
className="w-9 h-9 rounded-[10px] overflow-hidden flex items-center justify-center flex-shrink-0"
style={{ backgroundColor: (category?.color || '#6366f1') + '22' }}
>
{place.image_url ? (
<img src={place.image_url} alt={place.name} className="w-full h-full object-cover" />
) : (
<span className="text-lg">{category?.icon || '📍'}</span>
)}
</div>
<div className="flex-1 min-w-0">
<p className={`text-[13px] font-medium truncate leading-snug ${isPlaceSelected ? 'text-slate-900' : 'text-gray-800'}`}>
{place.name}
</p>
{(place.description || place.notes) && (
<p className="text-[11px] text-gray-400 mt-0.5 leading-snug line-clamp-2">
{place.description || place.notes}
</p>
)}
<div className="flex items-center gap-2 mt-0.5 flex-wrap">
{place.place_time && (
<span className="text-[11px] text-slate-600 font-medium">{place.place_time}</span>
)}
{place.price > 0 && (
<span className="text-[11px] text-gray-400">{place.price} {place.currency || currency}</span>
)}
{place.reservation_status && place.reservation_status !== 'none' && (
<span className={`text-[10px] px-1.5 py-0.5 rounded-full font-medium ${
place.reservation_status === 'confirmed'
? 'bg-emerald-50 text-emerald-600'
: 'bg-amber-50 text-amber-600'
}`}>
{place.reservation_status === 'confirmed' ? '✓ Bestätigt' : '⏳ Res. ausstehend'}
</span>
)}
</div>
</div>
<div className="flex flex-col gap-0 flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={e => { e.stopPropagation(); handleMoveUp(day.id, placeIdx) }}
disabled={placeIdx === 0}
className="p-0.5 text-gray-300 hover:text-gray-600 disabled:opacity-20"
>
<ChevronUp className="w-3.5 h-3.5" />
</button>
<button
onClick={e => { e.stopPropagation(); handleMoveDown(day.id, placeIdx) }}
disabled={placeIdx === placeItems.length - 1}
className="p-0.5 text-gray-300 hover:text-gray-600 disabled:opacity-20"
>
<ChevronDown className="w-3.5 h-3.5" />
</button>
</div>
</div>
)
}
// Note card
const note = item.data
const isEditingThis = dayNoteUi?.mode === 'edit' && dayNoteUi.noteId === note.id
if (isEditingThis) {
return (
<div key={`note-edit-${note.id}`} className="px-3 py-2 bg-amber-50/60">
<div className="flex gap-2 mb-1.5">
<input
type="text"
value={dayNoteUi.time}
onChange={e => setNoteUi(prev => ({ ...prev, [day.id]: { ...prev[day.id], time: e.target.value } }))}
placeholder="Zeit (optional)"
className="w-24 text-[11px] border border-amber-200 rounded-lg px-2 py-1 bg-white focus:outline-none focus:ring-1 focus:ring-amber-300"
/>
</div>
<textarea
ref={noteInputRef}
value={dayNoteUi.text}
onChange={e => setNoteUi(prev => ({ ...prev, [day.id]: { ...prev[day.id], text: e.target.value } }))}
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); saveNote(day.id) } if (e.key === 'Escape') cancelNote(day.id) }}
placeholder="Notiz…"
rows={2}
className="w-full text-[12px] border border-amber-200 rounded-lg px-2 py-1.5 bg-white focus:outline-none focus:ring-1 focus:ring-amber-300 resize-none"
/>
<div className="flex gap-1.5 mt-1.5">
<button onClick={() => saveNote(day.id)} className="flex items-center gap-1 text-[11px] bg-amber-500 text-white px-2.5 py-1 rounded-lg hover:bg-amber-600">
<Check className="w-3 h-3" /> Speichern
</button>
<button onClick={() => cancelNote(day.id)} className="text-[11px] text-gray-500 px-2.5 py-1 rounded-lg hover:bg-gray-100">
Abbrechen
</button>
</div>
</div>
)
}
return (
<div key={`note-${note.id}`} className="group flex items-start gap-2 pl-4 pr-3 py-2 bg-amber-50/40 hover:bg-amber-50/70 transition-colors">
<FileText className="w-3.5 h-3.5 text-amber-400 flex-shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
{note.time && (
<span className="text-[11px] font-semibold text-amber-600 mr-1.5">{note.time}</span>
)}
<span className="text-[12px] text-gray-700 leading-snug">{note.text}</span>
</div>
<div className="flex flex-col gap-0 flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
<button onClick={e => { e.stopPropagation(); handleNoteMoveUp(day.id, note.id) }} className="p-0.5 text-gray-300 hover:text-gray-600">
<ChevronUp className="w-3.5 h-3.5" />
</button>
<button onClick={e => { e.stopPropagation(); handleNoteMoveDown(day.id, note.id) }} className="p-0.5 text-gray-300 hover:text-gray-600">
<ChevronDown className="w-3.5 h-3.5" />
</button>
</div>
<div className="flex gap-0.5 flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
<button onClick={e => { e.stopPropagation(); openEditNote(day.id, note) }} className="p-1 text-gray-300 hover:text-amber-500 rounded">
<Pencil className="w-3 h-3" />
</button>
<button onClick={e => { e.stopPropagation(); handleDeleteNote(day.id, note.id) }} className="p-1 text-gray-300 hover:text-red-500 rounded">
<Trash2 className="w-3 h-3" />
</button>
</div>
</div>
)
})}
</div>
)}
{/* Inline "add note" form */}
{dayNoteUi?.mode === 'add' && (
<div className="px-3 py-2 border-t border-amber-100 bg-amber-50/60">
<div className="flex gap-2 mb-1.5">
<input
type="text"
value={dayNoteUi.time}
onChange={e => setNoteUi(prev => ({ ...prev, [day.id]: { ...prev[day.id], time: e.target.value } }))}
placeholder="Zeit (optional)"
className="w-24 text-[11px] border border-amber-200 rounded-lg px-2 py-1 bg-white focus:outline-none focus:ring-1 focus:ring-amber-300"
/>
</div>
<textarea
ref={noteInputRef}
value={dayNoteUi.text}
onChange={e => setNoteUi(prev => ({ ...prev, [day.id]: { ...prev[day.id], text: e.target.value } }))}
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); saveNote(day.id) } if (e.key === 'Escape') cancelNote(day.id) }}
placeholder="z.B. S3 um 14:30 ab Hauptbahnhof, Fähre ab Pier 7, Mittagspause…"
rows={2}
className="w-full text-[12px] border border-amber-200 rounded-lg px-2 py-1.5 bg-white focus:outline-none focus:ring-1 focus:ring-amber-300 resize-none"
/>
<div className="flex gap-1.5 mt-1.5">
<button onClick={() => saveNote(day.id)} className="flex items-center gap-1 text-[11px] bg-amber-500 text-white px-2.5 py-1 rounded-lg hover:bg-amber-600">
<Check className="w-3 h-3" /> Hinzufügen
</button>
<button onClick={() => cancelNote(day.id)} className="text-[11px] text-gray-500 px-2.5 py-1 rounded-lg hover:bg-gray-100">
Abbrechen
</button>
</div>
</div>
)}
{/* Add note button */}
{!dayNoteUi && (
<div className="px-4 py-2 border-t border-gray-100/60 flex gap-2">
<button
onClick={() => openAddNote(day.id)}
className="flex items-center gap-1 text-[11px] text-amber-600 hover:text-amber-700 py-1"
>
<FileText className="w-3 h-3" />
Notiz hinzufügen
</button>
</div>
)}
{/* Route tools — only for the selected day */}
{isSelected && da.length >= 2 && (
<div className="px-4 py-3 space-y-2 border-t border-gray-100/60">
<div className="flex bg-gray-100 rounded-[8px] p-0.5 gap-0.5">
{TRANSPORT_MODES.map(m => (
<button
key={m.value}
onClick={() => setTransportMode(m.value)}
className={`flex-1 py-1 text-[11px] rounded-[6px] transition-all ${
transportMode === m.value
? 'bg-white shadow-sm text-gray-900 font-medium'
: 'text-gray-500'
}`}
>
{m.icon} {m.label}
</button>
))}
</div>
{routeInfo && (
<div className="flex items-center justify-center gap-3 text-xs bg-slate-50 rounded-lg px-3 py-2">
<span className="text-slate-900">🛣 {routeInfo.distance}</span>
<span className="text-slate-300">·</span>
<span className="text-slate-900"> {routeInfo.duration}</span>
</div>
)}
<div className="grid grid-cols-2 gap-1.5">
<button
onClick={handleCalculateRoute}
disabled={isCalculatingRoute}
className="flex items-center justify-center gap-1.5 bg-slate-900 text-white text-xs py-2 rounded-lg hover:bg-slate-700 disabled:opacity-60 transition-colors"
>
<Navigation className="w-3.5 h-3.5" />
{isCalculatingRoute ? 'Berechne...' : 'Route'}
</button>
<button
onClick={handleOptimizeRoute}
className="flex items-center justify-center gap-1.5 bg-emerald-600 text-white text-xs py-2 rounded-lg hover:bg-emerald-700 transition-colors"
>
<RotateCcw className="w-3.5 h-3.5" />
Optimieren
</button>
</div>
<button
onClick={handleOpenGoogleMaps}
className="w-full flex items-center justify-center gap-1.5 border border-gray-200 text-gray-600 text-xs py-2 rounded-lg hover:bg-gray-50 transition-colors"
>
<ExternalLink className="w-3.5 h-3.5" />
In Google Maps öffnen
</button>
</div>
)}
</div>
)}
</div>
)
})
)}
{/* Budget footer */}
{totalCost > 0 && (
<div className="px-4 py-3 border-t border-gray-100 flex items-center justify-between">
<span className="text-xs text-gray-500">Gesamtkosten</span>
<span className="text-sm font-semibold text-gray-800">{totalCost.toFixed(2)} {currency}</span>
</div>
)}
</div>
)}
{/* ── ORTE ── */}
{activeSegment === 'orte' && (
<div>
<div className="p-3 space-y-2 border-b border-gray-100">
<div className="relative">
<Search className="absolute left-3 top-[9px] w-3.5 h-3.5 text-gray-400 pointer-events-none" />
<input
type="text"
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="Orte suchen…"
className="w-full pl-8 pr-8 py-2 bg-gray-100 rounded-[10px] text-sm focus:outline-none focus:bg-white focus:ring-2 focus:ring-slate-400 transition-colors"
/>
{search && (
<button onClick={() => setSearch('')} className="absolute right-3 top-[9px]">
<X className="w-3.5 h-3.5 text-gray-400" />
</button>
)}
</div>
<div className="flex items-center gap-2">
<select
value={categoryFilter}
onChange={e => setCategoryFilter(e.target.value)}
className="flex-1 bg-gray-100 rounded-lg text-xs py-2 px-2 focus:outline-none text-gray-600"
>
<option value="">Alle Kategorien</option>
{categories.map(c => (
<option key={c.id} value={c.id}>{c.icon} {c.name}</option>
))}
</select>
<button
onClick={onAddPlace}
className="flex items-center gap-1 bg-slate-900 text-white text-xs px-3 py-2 rounded-lg hover:bg-slate-700 whitespace-nowrap transition-colors"
>
<Plus className="w-3.5 h-3.5" />
Neu
</button>
</div>
</div>
{filteredPlaces.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-gray-400">
<span className="text-3xl mb-2">📍</span>
<p className="text-sm">Keine Orte gefunden</p>
<button onClick={onAddPlace} className="mt-3 text-slate-700 text-sm">
Ersten Ort hinzufügen
</button>
</div>
) : (
<div className="divide-y divide-gray-50">
{filteredPlaces.map(place => {
const category = categories.find(c => c.id === place.category_id)
const inDay = isAssignedToDay(place.id)
const isSelected = place.id === selectedPlaceId
return (
<div
key={place.id}
onClick={() => onPlaceClick(isSelected ? null : place.id)}
className={`flex items-center gap-3 px-4 py-3 cursor-pointer transition-colors ${
isSelected ? 'bg-slate-50' : 'hover:bg-gray-50'
}`}
>
<div
className="w-9 h-9 rounded-[10px] overflow-hidden flex items-center justify-center flex-shrink-0"
style={{ backgroundColor: (category?.color || '#6366f1') + '22' }}
>
{place.image_url ? (
<img src={place.image_url} alt={place.name} className="w-full h-full object-cover" />
) : (
<span className="text-lg">{category?.icon || '📍'}</span>
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-1">
<span className="font-medium text-[13px] text-gray-900 truncate">{place.name}</span>
<div className="flex items-center gap-1 flex-shrink-0">
{inDay
? <span className="text-[11px] text-emerald-600 bg-emerald-50 px-1.5 py-0.5 rounded-full"></span>
: selectedDayId && (
<button
onClick={e => { e.stopPropagation(); onAssignToDay(place.id) }}
className="text-[11px] text-slate-700 bg-slate-50 px-1.5 py-0.5 rounded hover:bg-slate-100 transition-colors"
>
+ Tag
</button>
)
}
</div>
</div>
{category && <p className="text-xs text-gray-500 mt-0.5">{category.icon} {category.name}</p>}
{place.address && <p className="text-xs text-gray-400 truncate">{place.address}</p>}
</div>
</div>
)
})}
</div>
)}
</div>
)}
{/* ── RESERVIERUNGEN ── */}
{activeSegment === 'reservierungen' && (
<div>
<div className="px-4 py-3 flex items-center justify-between border-b border-gray-100">
<h3 className="font-medium text-sm text-gray-900">
Reservierungen
{selectedDay && <span className="text-gray-400 font-normal"> · Tag {selectedDay.day_number}</span>}
</h3>
<button
onClick={() => { setEditingReservation(null); setShowReservationModal(true) }}
className="flex items-center gap-1 bg-slate-900 text-white text-xs px-2.5 py-1.5 rounded-lg hover:bg-slate-700 transition-colors"
>
<Plus className="w-3.5 h-3.5" />
Hinzufügen
</button>
</div>
{filteredReservations.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-gray-400">
<span className="text-3xl mb-2">🎫</span>
<p className="text-sm">Keine Reservierungen</p>
</div>
) : (
<div className="p-3 space-y-2.5">
{filteredReservations.map(r => (
<div key={r.id} className="bg-white border border-gray-100 rounded-2xl p-3.5 shadow-sm">
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="font-semibold text-[13px] text-gray-900">{r.title}</div>
{r.reservation_time && (
<div className="flex items-center gap-1 mt-1 text-xs text-slate-700">
<Clock className="w-3 h-3" />
{formatDateTime(r.reservation_time)}
</div>
)}
{r.location && <div className="text-xs text-gray-500 mt-0.5">📍 {r.location}</div>}
{r.confirmation_number && (
<div className="text-xs text-emerald-600 mt-1 bg-emerald-50 rounded-lg px-2 py-0.5 inline-block">
# {r.confirmation_number}
</div>
)}
{r.notes && <p className="text-xs text-gray-500 mt-1.5 leading-relaxed">{r.notes}</p>}
</div>
<div className="flex gap-1 flex-shrink-0">
<button
onClick={() => { setEditingReservation(r); setShowReservationModal(true) }}
className="p-1.5 text-gray-400 hover:text-slate-700 rounded-lg hover:bg-slate-50 transition-colors"
></button>
<button
onClick={() => handleDeleteReservation(r.id)}
className="p-1.5 text-gray-400 hover:text-red-600 rounded-lg hover:bg-red-50 transition-colors"
>🗑</button>
</div>
</div>
</div>
))}
</div>
)}
</div>
)}
{/* ── PACKLISTE ── */}
{activeSegment === 'packliste' && (
<PackingListPanel tripId={tripId} items={packingItems} />
)}
{/* ── DOKUMENTE ── */}
{activeSegment === 'dokumente' && (
<FileManager tripId={tripId} />
)}
</div>
{/* ── INSPECTOR OVERLAY ── */}
{selectedPlace && (
<div className="absolute inset-0 bg-white z-10 overflow-y-auto">
<PlaceDetailPanel
place={selectedPlace}
categories={categories}
tags={tags}
selectedDayId={selectedDayId}
dayAssignments={selectedDayAssignments}
onClose={() => onPlaceClick(null)}
onEdit={() => onPlaceEdit(selectedPlace)}
onDelete={() => onPlaceDelete(selectedPlace.id)}
onAssignToDay={onAssignToDay}
onRemoveAssignment={onRemoveAssignment}
/>
</div>
)}
{/* Reservation modal */}
<ReservationModal
isOpen={showReservationModal}
onClose={() => { setShowReservationModal(false); setEditingReservation(null) }}
onSave={handleSaveReservation}
reservation={editingReservation}
days={days}
places={places}
selectedDayId={selectedDayId}
/>
</div>
)
}

View File

@@ -0,0 +1,295 @@
import React, { useState, useEffect, useRef } from 'react'
import Modal from '../shared/Modal'
import CustomSelect from '../shared/CustomSelect'
import { Plane, Hotel, Utensils, Train, Car, Ship, Ticket, FileText, Users, Paperclip, X, ExternalLink } from 'lucide-react'
import { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n'
import { CustomDateTimePicker } from '../shared/CustomDateTimePicker'
const TYPE_OPTIONS = [
{ value: 'flight', labelKey: 'reservations.type.flight', Icon: Plane },
{ value: 'hotel', labelKey: 'reservations.type.hotel', Icon: Hotel },
{ value: 'restaurant', labelKey: 'reservations.type.restaurant', Icon: Utensils },
{ value: 'train', labelKey: 'reservations.type.train', Icon: Train },
{ value: 'car', labelKey: 'reservations.type.car', Icon: Car },
{ value: 'cruise', labelKey: 'reservations.type.cruise', Icon: Ship },
{ value: 'event', labelKey: 'reservations.type.event', Icon: Ticket },
{ value: 'tour', labelKey: 'reservations.type.tour', Icon: Users },
{ value: 'other', labelKey: 'reservations.type.other', Icon: FileText },
]
export function ReservationModal({ isOpen, onClose, onSave, reservation, days, places, selectedDayId, files = [], onFileUpload, onFileDelete }) {
const toast = useToast()
const { t } = useTranslation()
const fileInputRef = useRef(null)
const [form, setForm] = useState({
title: '', type: 'other', status: 'pending',
reservation_time: '', location: '', confirmation_number: '',
notes: '', day_id: '', place_id: '',
})
const [isSaving, setIsSaving] = useState(false)
const [uploadingFile, setUploadingFile] = useState(false)
const [pendingFiles, setPendingFiles] = useState([]) // for new reservations
useEffect(() => {
if (reservation) {
setForm({
title: reservation.title || '',
type: reservation.type || 'other',
status: reservation.status || 'pending',
reservation_time: reservation.reservation_time ? reservation.reservation_time.slice(0, 16) : '',
location: reservation.location || '',
confirmation_number: reservation.confirmation_number || '',
notes: reservation.notes || '',
day_id: reservation.day_id || '',
place_id: reservation.place_id || '',
})
} else {
setForm({
title: '', type: 'other', status: 'pending',
reservation_time: '', location: '', confirmation_number: '',
notes: '', day_id: selectedDayId || '', place_id: '',
})
setPendingFiles([])
}
}, [reservation, isOpen, selectedDayId])
const set = (field, value) => setForm(prev => ({ ...prev, [field]: value }))
const handleSubmit = async (e) => {
e.preventDefault()
if (!form.title.trim()) return
setIsSaving(true)
try {
const saved = await onSave({
...form,
day_id: form.day_id || null,
place_id: form.place_id || null,
})
// Upload pending files for newly created reservations
if (!reservation?.id && saved?.id && pendingFiles.length > 0) {
for (const file of pendingFiles) {
const fd = new FormData()
fd.append('file', file)
fd.append('reservation_id', saved.id)
fd.append('description', form.title)
await onFileUpload(fd)
}
}
} finally {
setIsSaving(false)
}
}
const handleFileChange = async (e) => {
const file = e.target.files?.[0]
if (!file) return
if (reservation?.id) {
// Existing reservation — upload immediately
setUploadingFile(true)
try {
const fd = new FormData()
fd.append('file', file)
fd.append('reservation_id', reservation.id)
fd.append('description', reservation.title)
await onFileUpload(fd)
toast.success(t('reservations.toast.fileUploaded'))
} catch {
toast.error(t('reservations.toast.uploadError'))
} finally {
setUploadingFile(false)
e.target.value = ''
}
} else {
// New reservation — stage locally
setPendingFiles(prev => [...prev, file])
e.target.value = ''
}
}
const attachedFiles = reservation?.id ? files.filter(f => f.reservation_id === reservation.id) : []
const inputStyle = {
width: '100%', border: '1px solid var(--border-primary)', borderRadius: 10,
padding: '8px 14px', fontSize: 13, fontFamily: 'inherit',
outline: 'none', boxSizing: 'border-box', color: 'var(--text-primary)', background: 'var(--bg-input)',
}
const labelStyle = { display: 'block', fontSize: 12, fontWeight: 600, color: '#374151', marginBottom: 5 }
return (
<Modal isOpen={isOpen} onClose={onClose} title={reservation ? t('reservations.editTitle') : t('reservations.newTitle')} size="md">
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{/* Type selector */}
<div>
<label style={labelStyle}>{t('reservations.bookingType')}</label>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{TYPE_OPTIONS.map(({ value, labelKey, Icon }) => (
<button key={value} type="button" onClick={() => set('type', value)} style={{
display: 'flex', alignItems: 'center', gap: 5,
padding: '6px 11px', borderRadius: 99, border: '1px solid',
fontSize: 12, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit', transition: 'all 0.12s',
background: form.type === value ? '#111827' : 'white',
borderColor: form.type === value ? '#111827' : '#e5e7eb',
color: form.type === value ? 'white' : '#6b7280',
}}>
<Icon size={12} /> {t(labelKey)}
</button>
))}
</div>
</div>
{/* Title */}
<div>
<label style={labelStyle}>{t('reservations.titleLabel')} *</label>
<input type="text" value={form.title} onChange={e => set('title', e.target.value)} required
placeholder={t('reservations.titlePlaceholder')} style={inputStyle} />
</div>
{/* Date/Time + Status */}
<div style={{ display: 'grid', gridTemplateColumns: '2fr 1fr', gap: 10 }}>
<div>
<label style={labelStyle}>{t('reservations.datetime')}</label>
<CustomDateTimePicker value={form.reservation_time} onChange={v => set('reservation_time', v)} />
</div>
<div>
<label style={labelStyle}>{t('reservations.status')}</label>
<CustomSelect
value={form.status}
onChange={value => set('status', value)}
options={[
{ value: 'pending', label: t('reservations.pending') },
{ value: 'confirmed', label: t('reservations.confirmed') },
]}
size="sm"
/>
</div>
</div>
{/* Location */}
<div>
<label style={labelStyle}>{t('reservations.locationAddress')}</label>
<input type="text" value={form.location} onChange={e => set('location', e.target.value)}
placeholder={t('reservations.locationPlaceholder')} style={inputStyle} />
</div>
{/* Confirmation number */}
<div>
<label style={labelStyle}>{t('reservations.confirmationCode')}</label>
<input type="text" value={form.confirmation_number} onChange={e => set('confirmation_number', e.target.value)}
placeholder={t('reservations.confirmationPlaceholder')} style={inputStyle} />
</div>
{/* Linked day + place */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
<div>
<label style={labelStyle}>{t('reservations.day')}</label>
<CustomSelect
value={form.day_id}
onChange={value => set('day_id', value)}
placeholder={t('reservations.noDay')}
options={[
{ value: '', label: t('reservations.noDay') },
...(days || []).map(day => ({
value: day.id,
label: `${t('reservations.day')} ${day.day_number}${day.date ? ` · ${formatDate(day.date)}` : ''}`,
})),
]}
size="sm"
/>
</div>
<div>
<label style={labelStyle}>{t('reservations.place')}</label>
<CustomSelect
value={form.place_id}
onChange={value => set('place_id', value)}
placeholder={t('reservations.noPlace')}
options={[
{ value: '', label: t('reservations.noPlace') },
...(places || []).map(place => ({
value: place.id,
label: place.name,
})),
]}
searchable
size="sm"
/>
</div>
</div>
{/* Notes */}
<div>
<label style={labelStyle}>{t('reservations.notes')}</label>
<textarea value={form.notes} onChange={e => set('notes', e.target.value)} rows={3}
placeholder={t('reservations.notesPlaceholder')}
style={{ ...inputStyle, resize: 'none', lineHeight: 1.5 }} />
</div>
{/* File upload — always visible */}
<div>
<label style={labelStyle}>{t('files.title')}</label>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{attachedFiles.map(f => (
<div key={f.id} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '6px 10px', background: '#f9fafb', borderRadius: 8, border: '1px solid #e5e7eb' }}>
<FileText size={13} style={{ color: '#6b7280', flexShrink: 0 }} />
<span style={{ flex: 1, fontSize: 12.5, color: '#374151', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.original_name}</span>
<a href={f.url} target="_blank" rel="noreferrer" style={{ color: '#9ca3af', display: 'flex', flexShrink: 0 }} title={t('common.open')}>
<ExternalLink size={12} />
</a>
{onFileDelete && (
<button type="button" onClick={() => onFileDelete(f.id)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#9ca3af', display: 'flex', padding: 0, flexShrink: 0 }}
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'}
onMouseLeave={e => e.currentTarget.style.color = '#9ca3af'}>
<X size={12} />
</button>
)}
</div>
))}
{pendingFiles.map((f, i) => (
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '6px 10px', background: '#f9fafb', borderRadius: 8, border: '1px solid #e5e7eb' }}>
<FileText size={13} style={{ color: '#6b7280', flexShrink: 0 }} />
<span style={{ flex: 1, fontSize: 12.5, color: '#374151', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.name}</span>
<span style={{ fontSize: 11, color: '#9ca3af', flexShrink: 0 }}>{t('reservations.pendingSave')}</span>
<button type="button" onClick={() => setPendingFiles(prev => prev.filter((_, j) => j !== i))}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#9ca3af', display: 'flex', padding: 0, flexShrink: 0 }}
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'}
onMouseLeave={e => e.currentTarget.style.color = '#9ca3af'}>
<X size={12} />
</button>
</div>
))}
<input ref={fileInputRef} type="file" accept=".pdf,.doc,.docx,.txt,image/*" style={{ display: 'none' }} onChange={handleFileChange} />
<button type="button" onClick={() => fileInputRef.current?.click()} disabled={uploadingFile} style={{
display: 'flex', alignItems: 'center', gap: 6, padding: '7px 12px',
border: '1px dashed #d1d5db', borderRadius: 8, background: 'white',
fontSize: 12.5, color: '#6b7280', cursor: uploadingFile ? 'default' : 'pointer',
fontFamily: 'inherit', transition: 'all 0.12s',
}}
onMouseEnter={e => { if (!uploadingFile) { e.currentTarget.style.borderColor = '#9ca3af'; e.currentTarget.style.color = '#374151' } }}
onMouseLeave={e => { e.currentTarget.style.borderColor = '#d1d5db'; e.currentTarget.style.color = '#6b7280' }}>
<Paperclip size={13} />
{uploadingFile ? t('reservations.uploading') : t('reservations.attachFile')}
</button>
</div>
</div>
{/* Actions */}
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, paddingTop: 4, borderTop: '1px solid #f3f4f6' }}>
<button type="button" onClick={onClose} style={{ padding: '8px 16px', borderRadius: 10, border: '1px solid #e5e7eb', background: 'white', fontSize: 13, cursor: 'pointer', fontFamily: 'inherit', color: '#374151' }}>
{t('common.cancel')}
</button>
<button type="submit" disabled={isSaving || !form.title.trim()} style={{ padding: '8px 20px', borderRadius: 10, border: 'none', background: '#111827', color: 'white', fontSize: 13, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', opacity: isSaving || !form.title.trim() ? 0.5 : 1 }}>
{isSaving ? t('common.saving') : reservation ? t('common.update') : t('common.add')}
</button>
</div>
</form>
</Modal>
)
}
function formatDate(dateStr) {
if (!dateStr) return ''
const d = new Date(dateStr + 'T00:00:00')
return d.toLocaleDateString('de-DE', { day: 'numeric', month: 'short' })
}

View File

@@ -0,0 +1,456 @@
import React, { useState, useMemo } from 'react'
import ReactDOM from 'react-dom'
import { useTripStore } from '../../store/tripStore'
import { useSettingsStore } from '../../store/settingsStore'
import { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n'
import { CustomDateTimePicker } from '../shared/CustomDateTimePicker'
import CustomSelect from '../shared/CustomSelect'
import {
Plane, Hotel, Utensils, Train, Car, Ship, Ticket, FileText, MapPin,
Calendar, Hash, CheckCircle2, Circle, Pencil, Trash2, Plus, ChevronDown, ChevronRight, MapPinned, X, Users,
ExternalLink, BookMarked, Lightbulb,
} from 'lucide-react'
const TYPE_OPTIONS = [
{ value: 'flight', labelKey: 'reservations.type.flight', Icon: Plane },
{ value: 'hotel', labelKey: 'reservations.type.hotel', Icon: Hotel },
{ value: 'restaurant', labelKey: 'reservations.type.restaurant', Icon: Utensils },
{ value: 'train', labelKey: 'reservations.type.train', Icon: Train },
{ value: 'car', labelKey: 'reservations.type.car', Icon: Car },
{ value: 'cruise', labelKey: 'reservations.type.cruise', Icon: Ship },
{ value: 'event', labelKey: 'reservations.type.event', Icon: Ticket },
{ value: 'tour', labelKey: 'reservations.type.tour', Icon: Users },
{ value: 'other', labelKey: 'reservations.type.other', Icon: FileText },
]
function typeIcon(type) {
return (TYPE_OPTIONS.find(t => t.value === type) || TYPE_OPTIONS[TYPE_OPTIONS.length - 1]).Icon
}
function typeLabelKey(type) {
return (TYPE_OPTIONS.find(t => t.value === type) || TYPE_OPTIONS[TYPE_OPTIONS.length - 1]).labelKey
}
function formatDateTimeWithLocale(str, locale, timeFormat) {
if (!str) return null
const d = new Date(str)
if (isNaN(d)) return str
const datePart = d.toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'long' })
const h = d.getHours(), m = d.getMinutes()
let timePart
if (timeFormat === '12h') {
const period = h >= 12 ? 'PM' : 'AM'
const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h
timePart = `${h12}:${String(m).padStart(2, '0')} ${period}`
} else {
timePart = `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`
if (locale?.startsWith('de')) timePart += ' Uhr'
}
return `${datePart} · ${timePart}`
}
const inputStyle = {
width: '100%', border: '1px solid var(--border-primary)', borderRadius: 10,
padding: '8px 12px', fontSize: 13.5, fontFamily: 'inherit',
outline: 'none', boxSizing: 'border-box', color: 'var(--text-primary)', background: 'var(--bg-card)',
}
const labelStyle = { display: 'block', fontSize: 12, fontWeight: 600, color: 'var(--text-secondary)', marginBottom: 5 }
// Inline modal for editing place reservation fields
function PlaceReservationEditModal({ item, tripId, onClose }) {
const { updatePlace } = useTripStore()
const toast = useToast()
const { t } = useTranslation()
const [form, setForm] = useState({
reservation_status: item.status === 'confirmed' ? 'confirmed' : 'pending',
reservation_datetime: item.reservation_time ? item.reservation_time.slice(0, 16) : '',
place_time: item.place_time || '',
reservation_notes: item.notes || '',
})
const [saving, setSaving] = useState(false)
const set = (f, v) => setForm(p => ({ ...p, [f]: v }))
const handleSave = async () => {
setSaving(true)
try {
await updatePlace(tripId, item.placeId, {
reservation_status: form.reservation_status,
reservation_datetime: form.reservation_datetime || null,
place_time: form.place_time || null,
reservation_notes: form.reservation_notes || null,
})
toast.success(t('reservations.toast.updated'))
onClose()
} catch {
toast.error(t('reservations.toast.saveError'))
} finally {
setSaving(false)
}
}
return ReactDOM.createPortal(
<div style={{
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.4)', zIndex: 9000,
display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16,
}} onClick={onClose}>
<div style={{
background: 'var(--bg-card)', borderRadius: 18, padding: 24, width: '100%', maxWidth: 420,
boxShadow: '0 20px 60px rgba(0,0,0,0.2)',
fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif",
}} onClick={e => e.stopPropagation()}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 20 }}>
<div>
<h3 style={{ margin: 0, fontSize: 16, fontWeight: 700, color: 'var(--text-primary)' }}>{t('reservations.editTitle')}</h3>
<p style={{ margin: '3px 0 0', fontSize: 12, color: 'var(--text-faint)' }}>{item.title}</p>
</div>
<button onClick={onClose} style={{ background: 'var(--bg-tertiary)', border: 'none', borderRadius: '50%', width: 30, height: 30, cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<X size={14} />
</button>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<div>
<label style={labelStyle}>{t('reservations.status')}</label>
<CustomSelect
value={form.reservation_status}
onChange={v => set('reservation_status', v)}
options={[
{ value: 'pending', label: t('reservations.pending') },
{ value: 'confirmed', label: t('reservations.confirmed') },
]}
/>
</div>
<div>
<label style={labelStyle}>{t('reservations.datetime')}</label>
<CustomDateTimePicker value={form.reservation_datetime} onChange={v => set('reservation_datetime', v)} />
</div>
<div>
<label style={labelStyle}>{t('reservations.notes')}</label>
<textarea value={form.reservation_notes} onChange={e => set('reservation_notes', e.target.value)} rows={3} placeholder={t('reservations.notesPlaceholder')} style={{ ...inputStyle, resize: 'none', lineHeight: 1.5 }} />
</div>
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, marginTop: 20, paddingTop: 16, borderTop: '1px solid var(--border-secondary)' }}>
<button onClick={onClose} style={{ padding: '8px 16px', borderRadius: 10, border: '1px solid var(--border-primary)', background: 'var(--bg-card)', fontSize: 13, cursor: 'pointer', fontFamily: 'inherit', color: 'var(--text-secondary)' }}>
{t('common.cancel')}
</button>
<button onClick={handleSave} disabled={saving} style={{ padding: '8px 20px', borderRadius: 10, border: 'none', background: 'var(--accent)', color: 'white', fontSize: 13, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', opacity: saving ? 0.6 : 1 }}>
{saving ? t('common.saving') : t('common.save')}
</button>
</div>
</div>
</div>,
document.body
)
}
// Card for real reservations (reservations table)
function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateToFiles }) {
const { toggleReservationStatus } = useTripStore()
const toast = useToast()
const { t, locale } = useTranslation()
const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h'
const TypeIcon = typeIcon(r.type)
const confirmed = r.status === 'confirmed'
const attachedFiles = files.filter(f => f.reservation_id === r.id)
const handleToggle = async () => {
try { await toggleReservationStatus(tripId, r.id) }
catch { toast.error(t('reservations.toast.updateError')) }
}
const handleDelete = async () => {
if (!confirm(t('reservations.confirm.delete', { name: r.title }))) return
try { await onDelete(r.id) } catch { toast.error(t('reservations.toast.deleteError')) }
}
return (
<div style={{ background: 'var(--bg-card)', borderRadius: 14, border: '1px solid var(--border-faint)', boxShadow: '0 1px 6px rgba(0,0,0,0.05)', overflow: 'hidden' }}>
<div style={{ display: 'flex', alignItems: 'stretch' }}>
<div style={{
width: 44, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
background: confirmed ? '#f0fdf4' : '#fefce8',
borderRight: `1px solid ${confirmed ? '#bbf7d0' : '#fef08a'}`,
}}>
<TypeIcon size={16} style={{ color: confirmed ? '#16a34a' : '#a16207' }} />
</div>
<div style={{ flex: 1, padding: '11px 13px', minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 8 }}>
<div style={{ minWidth: 0 }}>
<div style={{ fontWeight: 600, fontSize: 13.5, color: 'var(--text-primary)', lineHeight: 1.3 }}>{r.title}</div>
<div style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: 1 }}>{t(typeLabelKey(r.type))}</div>
</div>
<div style={{ display: 'flex', gap: 3, flexShrink: 0 }}>
<button onClick={handleToggle} style={{
display: 'flex', alignItems: 'center', gap: 3, padding: '3px 8px', borderRadius: 99,
border: 'none', cursor: 'pointer', fontSize: 11, fontWeight: 500,
background: confirmed ? '#dcfce7' : '#fef9c3',
color: confirmed ? '#16a34a' : '#a16207',
}}>
{confirmed ? <><CheckCircle2 size={11} /> {t('reservations.confirmed')}</> : <><Circle size={11} /> {t('reservations.pending')}</>}
</button>
<button onClick={() => onEdit(r)} style={{ padding: 5, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}><Pencil size={12} /></button>
<button onClick={handleDelete} style={{ padding: 5, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}><Trash2 size={12} /></button>
</div>
</div>
<div style={{ marginTop: 7, display: 'flex', flexWrap: 'wrap', gap: '3px 10px' }}>
{r.reservation_time && (
<div style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 11.5, color: 'var(--text-secondary)' }}>
<Calendar size={10} style={{ color: 'var(--text-faint)' }} />{formatDateTimeWithLocale(r.reservation_time, locale, timeFormat)}
</div>
)}
{r.location && (
<div style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 11.5, color: 'var(--text-secondary)' }}>
<MapPin size={10} style={{ color: 'var(--text-faint)' }} />
<span style={{ maxWidth: 200, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.location}</span>
</div>
)}
</div>
<div style={{ marginTop: 5, display: 'flex', flexWrap: 'wrap', gap: 4 }}>
{r.confirmation_number && (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 3, fontSize: 10.5, color: '#15803d', background: '#f0fdf4', border: '1px solid #bbf7d0', borderRadius: 99, padding: '1px 7px', fontWeight: 600 }}>
<Hash size={8} />{r.confirmation_number}
</span>
)}
{r.day_number != null && <span style={{ fontSize: 10.5, color: 'var(--text-muted)', background: 'var(--bg-tertiary)', borderRadius: 99, padding: '1px 7px' }}>{t('dayplan.dayN', { n: r.day_number })}</span>}
{r.place_name && <span style={{ fontSize: 10.5, color: 'var(--text-muted)', background: 'var(--bg-tertiary)', borderRadius: 99, padding: '1px 7px' }}>{r.place_name}</span>}
</div>
{r.notes && <p style={{ margin: '7px 0 0', fontSize: 11.5, color: 'var(--text-muted)', lineHeight: 1.5, borderTop: '1px solid var(--border-secondary)', paddingTop: 7 }}>{r.notes}</p>}
{/* Attached files — read-only, upload only via edit modal */}
{attachedFiles.length > 0 && (
<div style={{ marginTop: 8, borderTop: '1px solid var(--border-secondary)', paddingTop: 8, display: 'flex', flexDirection: 'column', gap: 4 }}>
{attachedFiles.map(f => (
<div key={f.id} style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<FileText size={11} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
<span style={{ fontSize: 11.5, color: 'var(--text-secondary)', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.original_name}</span>
<a href={f.url} target="_blank" rel="noreferrer" style={{ display: 'flex', color: 'var(--text-faint)', flexShrink: 0 }} title={t('common.open')}>
<ExternalLink size={11} />
</a>
</div>
))}
<button onClick={onNavigateToFiles} style={{ alignSelf: 'flex-start', fontSize: 11, color: 'var(--text-muted)', background: 'none', border: 'none', cursor: 'pointer', padding: 0, textDecoration: 'underline', fontFamily: 'inherit' }}>
{t('reservations.showFiles')}
</button>
</div>
)}
</div>
</div>
</div>
)
}
// Card for place-level reservations (from day plan)
function PlaceReservationCard({ item, tripId }) {
const { updatePlace } = useTripStore()
const toast = useToast()
const { t, locale } = useTranslation()
const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h'
const [editing, setEditing] = useState(false)
const confirmed = item.status === 'confirmed'
const handleDelete = async () => {
if (!confirm(t('reservations.confirm.remove', { name: item.title }))) return
try {
await updatePlace(tripId, item.placeId, {
reservation_status: 'none',
reservation_datetime: null,
place_time: null,
reservation_notes: null,
})
toast.success(t('reservations.toast.removed'))
} catch { toast.error(t('reservations.toast.deleteError')) }
}
return (
<>
{editing && <PlaceReservationEditModal item={item} tripId={tripId} onClose={() => setEditing(false)} />}
<div style={{ background: 'var(--bg-card)', borderRadius: 14, border: '1px solid var(--border-faint)', boxShadow: '0 1px 6px rgba(0,0,0,0.05)', overflow: 'hidden' }}>
<div style={{ display: 'flex', alignItems: 'stretch' }}>
<div style={{
width: 44, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
background: confirmed ? '#f0fdf4' : '#fefce8',
borderRight: `1px solid ${confirmed ? '#bbf7d0' : '#fef08a'}`,
}}>
<MapPinned size={16} style={{ color: confirmed ? '#16a34a' : '#a16207' }} />
</div>
<div style={{ flex: 1, padding: '11px 13px', minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 8 }}>
<div style={{ minWidth: 0 }}>
<div style={{ fontWeight: 600, fontSize: 13.5, color: 'var(--text-primary)', lineHeight: 1.3 }}>{item.title}</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginTop: 2, flexWrap: 'nowrap', overflow: 'hidden' }}>
<span className="hidden sm:inline" style={{ fontSize: 10.5, color: 'var(--text-faint)', flexShrink: 0 }}>{t('reservations.fromPlan')}</span>
{item.dayLabel && <span style={{ fontSize: 10.5, color: 'var(--text-muted)', background: 'var(--bg-tertiary)', borderRadius: 99, padding: '0 6px', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{item.dayLabel}</span>}
</div>
</div>
<div style={{ display: 'flex', gap: 3, flexShrink: 0 }}>
<span style={{
display: 'flex', alignItems: 'center', gap: 3, padding: '3px 8px', borderRadius: 99,
fontSize: 11, fontWeight: 500,
background: confirmed ? '#dcfce7' : '#fef9c3',
color: confirmed ? '#16a34a' : '#a16207',
}}>
{confirmed ? <><CheckCircle2 size={11} /> {t('reservations.confirmed')}</> : <><Circle size={11} /> {t('reservations.pending')}</>}
</span>
<button onClick={() => setEditing(true)} style={{ padding: 5, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}><Pencil size={12} /></button>
<button onClick={handleDelete} style={{ padding: 5, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}><Trash2 size={12} /></button>
</div>
</div>
<div style={{ marginTop: 7, display: 'flex', flexWrap: 'wrap', gap: '3px 10px' }}>
{item.reservation_time && (
<div style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 11.5, color: 'var(--text-secondary)' }}>
<Calendar size={10} style={{ color: 'var(--text-faint)' }} />{formatDateTimeWithLocale(item.reservation_time, locale, timeFormat)}
</div>
)}
{item.place_time && !item.reservation_time && (
<div style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 11.5, color: 'var(--text-secondary)' }}>
<Calendar size={10} style={{ color: 'var(--text-faint)' }} />{item.place_time}
</div>
)}
{item.location && (
<div style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 11.5, color: 'var(--text-secondary)' }}>
<MapPin size={10} style={{ color: 'var(--text-faint)' }} />
<span style={{ maxWidth: 200, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{item.location}</span>
</div>
)}
</div>
{item.notes && <p style={{ margin: '7px 0 0', fontSize: 11.5, color: 'var(--text-muted)', lineHeight: 1.5, borderTop: '1px solid var(--border-secondary)', paddingTop: 7 }}>{item.notes}</p>}
</div>
</div>
</div>
</>
)
}
function Section({ title, count, children, defaultOpen = true, accent }) {
const [open, setOpen] = useState(defaultOpen)
return (
<div style={{ marginBottom: 24 }}>
<button onClick={() => setOpen(o => !o)} style={{
display: 'flex', alignItems: 'center', gap: 8, width: '100%',
background: 'none', border: 'none', cursor: 'pointer', padding: '4px 0', marginBottom: 10,
}}>
{open ? <ChevronDown size={15} style={{ color: 'var(--text-faint)' }} /> : <ChevronRight size={15} style={{ color: 'var(--text-faint)' }} />}
<span style={{ fontWeight: 600, fontSize: 13, color: 'var(--text-primary)' }}>{title}</span>
<span style={{
fontSize: 11, fontWeight: 600, padding: '1px 8px', borderRadius: 99,
background: accent === 'green' ? '#dcfce7' : 'var(--bg-tertiary)',
color: accent === 'green' ? '#16a34a' : 'var(--text-muted)',
}}>{count}</span>
</button>
{open && <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>{children}</div>}
</div>
)
}
export default function ReservationsPanel({ tripId, reservations, days, assignments, files = [], onAdd, onEdit, onDelete, onNavigateToFiles }) {
const { t, locale } = useTranslation()
const [showHint, setShowHint] = useState(() => !localStorage.getItem('hideReservationHint'))
const placeReservations = useMemo(() => {
const result = []
for (const day of (days || [])) {
const da = (assignments?.[String(day.id)] || []).slice().sort((a, b) => a.order_index - b.order_index)
for (const assignment of da) {
const place = assignment.place
if (!place || !place.reservation_status || place.reservation_status === 'none') continue
const dayLabel = day.title
? day.title
: day.date
? `${t('dayplan.dayN', { n: day.day_number })} · ${new Date(day.date + 'T00:00:00').toLocaleDateString(locale, { day: 'numeric', month: 'short' })}`
: t('dayplan.dayN', { n: day.day_number })
result.push({
_placeRes: true,
id: `place_${day.id}_${place.id}`,
placeId: place.id,
title: place.name,
status: place.reservation_status === 'confirmed' ? 'confirmed' : 'pending',
reservation_time: place.reservation_datetime || null,
place_time: place.place_time || null,
location: place.address || null,
notes: place.reservation_notes || null,
dayLabel,
})
}
}
return result
}, [days, assignments, locale])
const allPending = [...reservations.filter(r => r.status !== 'confirmed'), ...placeReservations.filter(r => r.status !== 'confirmed')]
const allConfirmed = [...reservations.filter(r => r.status === 'confirmed'), ...placeReservations.filter(r => r.status === 'confirmed')]
const total = allPending.length + allConfirmed.length
function renderCard(r) {
if (r._placeRes) return <PlaceReservationCard key={r.id} item={r} tripId={tripId} />
return <ReservationCard key={r.id} r={r} tripId={tripId} onEdit={onEdit} onDelete={onDelete} files={files} onNavigateToFiles={onNavigateToFiles} />
}
return (
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }}>
<div style={{ padding: '20px 24px 16px', borderBottom: '1px solid rgba(0,0,0,0.06)', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 700, color: 'var(--text-primary)' }}>{t('reservations.title')}</h2>
<p style={{ margin: '2px 0 0', fontSize: 12.5, color: 'var(--text-faint)' }}>
{total === 0 ? t('reservations.empty') : t('reservations.summary', { confirmed: allConfirmed.length, pending: allPending.length })}
</p>
</div>
<button onClick={onAdd} style={{
display: 'flex', alignItems: 'center', gap: 6, padding: '7px 14px', borderRadius: 99,
border: 'none', background: 'var(--accent)', color: 'var(--accent-text)',
fontSize: 12.5, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit',
}}>
<Plus size={13} /> {t('reservations.addManual')}
</button>
</div>
{/* Hinweis — einmalig wegklickbar */}
{showHint && (
<div style={{ margin: '12px 24px 8px', padding: '8px 12px', borderRadius: 10, background: 'var(--bg-hover)', display: 'flex', alignItems: 'flex-start', gap: 8 }}>
<Lightbulb size={13} style={{ flexShrink: 0, marginTop: 1, color: 'var(--text-faint)' }} />
<p style={{ fontSize: 11.5, color: 'var(--text-muted)', margin: 0, lineHeight: 1.5, flex: 1 }}>
{t('reservations.placeHint')}
</p>
<button
onClick={() => { setShowHint(false); localStorage.setItem('hideReservationHint', '1') }}
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '0 4px', color: 'var(--text-faint)', fontSize: 16, lineHeight: 1, flexShrink: 0 }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}
>×</button>
</div>
)}
<div style={{ flex: 1, overflowY: 'auto', padding: '20px 24px' }}>
{total === 0 ? (
<div style={{ textAlign: 'center', padding: '60px 20px' }}>
<BookMarked size={40} style={{ marginBottom: 12, color: 'var(--text-faint)', display: 'block', margin: '0 auto 12px' }} />
<p style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-secondary)', margin: '0 0 4px' }}>{t('reservations.empty')}</p>
<p style={{ fontSize: 13, color: 'var(--text-faint)', margin: 0 }}>{t('reservations.emptyHint')}</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{allPending.length > 0 && (
<Section title={t('reservations.pending')} count={allPending.length} defaultOpen={true} accent="gray">
{allPending.map(renderCard)}
</Section>
)}
{allConfirmed.length > 0 && (
<Section title={t('reservations.confirmed')} count={allConfirmed.length} defaultOpen={true} accent="green">
{allConfirmed.map(renderCard)}
</Section>
)}
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,612 @@
import React, { useState, useCallback } from 'react'
import { Plus, Search, ChevronUp, ChevronDown, X, Map, ExternalLink, Navigation, RotateCcw, Clock, Euro, FileText, Package } from 'lucide-react'
import { calculateRoute, generateGoogleMapsUrl, optimizeRoute } from '../Map/RouteCalculator'
import PackingListPanel from '../Packing/PackingListPanel'
import { ReservationModal } from './ReservationModal'
import { PlaceDetailPanel } from './PlaceDetailPanel'
import { useTripStore } from '../../store/tripStore'
import { useToast } from '../shared/Toast'
const TABS = [
{ id: 'orte', label: 'Orte', icon: '📍' },
{ id: 'tagesplan', label: 'Tagesplan', icon: '📅' },
{ id: 'reservierungen', label: 'Reservierungen', icon: '🎫' },
{ id: 'packliste', label: 'Packliste', icon: '🎒' },
]
const TRANSPORT_MODES = [
{ value: 'driving', label: 'Auto', icon: '🚗' },
{ value: 'walking', label: 'Fuß', icon: '🚶' },
{ value: 'cycling', label: 'Rad', icon: '🚲' },
]
export function RightPanel({
trip, days, places, categories, tags,
assignments, reservations, packingItems,
selectedDay, selectedDayId, selectedPlaceId,
onPlaceClick, onPlaceEdit, onPlaceDelete,
onAssignToDay, onRemoveAssignment, onReorder,
onAddPlace, onEditTrip, onRouteCalculated, tripId,
}) {
const [activeTab, setActiveTab] = useState('orte')
const [search, setSearch] = useState('')
const [categoryFilter, setCategoryFilter] = useState('')
const [transportMode, setTransportMode] = useState('driving')
const [isCalculatingRoute, setIsCalculatingRoute] = useState(false)
const [showReservationModal, setShowReservationModal] = useState(false)
const [editingReservation, setEditingReservation] = useState(null)
const [routeInfo, setRouteInfo] = useState(null)
const tripStore = useTripStore()
const toast = useToast()
// Filtered places for Orte tab
const filteredPlaces = places.filter(p => {
const matchesSearch = !search || p.name.toLowerCase().includes(search.toLowerCase()) ||
(p.address || '').toLowerCase().includes(search.toLowerCase())
const matchesCategory = !categoryFilter || String(p.category_id) === String(categoryFilter)
return matchesSearch && matchesCategory
})
// Ordered assignments for selected day
const dayAssignments = selectedDayId
? (assignments[String(selectedDayId)] || []).slice().sort((a, b) => a.order_index - b.order_index)
: []
const isAssignedToSelectedDay = (placeId) =>
selectedDayId && dayAssignments.some(a => a.place?.id === placeId)
// Calculate schedule with times
const getSchedule = () => {
if (!dayAssignments.length) return []
let currentTime = null
return dayAssignments.map((assignment, idx) => {
const place = assignment.place
const startTime = place?.place_time || (currentTime ? currentTime : null)
const duration = place?.duration_minutes || 60
if (startTime) {
const [h, m] = startTime.split(':').map(Number)
const endMinutes = h * 60 + m + duration
const endH = Math.floor(endMinutes / 60) % 24
const endM = endMinutes % 60
currentTime = `${String(endH).padStart(2, '0')}:${String(endM).padStart(2, '0')}`
}
return { assignment, startTime, endTime: currentTime }
})
}
const handleCalculateRoute = async () => {
if (!selectedDayId) return
const waypoints = dayAssignments
.map(a => a.place)
.filter(p => p?.lat && p?.lng)
.map(p => ({ lat: p.lat, lng: p.lng }))
if (waypoints.length < 2) {
toast.error('Mindestens 2 Orte mit Koordinaten benötigt')
return
}
setIsCalculatingRoute(true)
try {
const result = await calculateRoute(waypoints, transportMode)
if (result) {
setRouteInfo({ distance: result.distanceText, duration: result.durationText })
onRouteCalculated?.(result)
toast.success('Route berechnet')
} else {
toast.error('Route konnte nicht berechnet werden')
}
} catch (err) {
toast.error('Fehler bei der Routenberechnung')
} finally {
setIsCalculatingRoute(false)
}
}
const handleOptimizeRoute = async () => {
if (!selectedDayId || dayAssignments.length < 3) return
const places = dayAssignments.map(a => a.place).filter(p => p?.lat && p?.lng)
const optimized = optimizeRoute(places)
const optimizedIds = optimized.map(p => {
const a = dayAssignments.find(a => a.place?.id === p.id)
return a?.id
}).filter(Boolean)
await onReorder(selectedDayId, optimizedIds)
toast.success('Route optimiert')
}
const handleOpenGoogleMaps = () => {
const places = dayAssignments.map(a => a.place).filter(p => p?.lat && p?.lng)
const url = generateGoogleMapsUrl(places)
if (url) window.open(url, '_blank')
else toast.error('Keine Orte mit Koordinaten vorhanden')
}
const handleMoveUp = async (idx) => {
if (idx === 0) return
const ids = dayAssignments.map(a => a.id)
;[ids[idx - 1], ids[idx]] = [ids[idx], ids[idx - 1]]
await onReorder(selectedDayId, ids)
}
const handleMoveDown = async (idx) => {
if (idx === dayAssignments.length - 1) return
const ids = dayAssignments.map(a => a.id)
;[ids[idx], ids[idx + 1]] = [ids[idx + 1], ids[idx]]
await onReorder(selectedDayId, ids)
}
const handleAddReservation = () => {
setEditingReservation(null)
setShowReservationModal(true)
}
const handleSaveReservation = async (data) => {
try {
if (editingReservation) {
await tripStore.updateReservation(tripId, editingReservation.id, data)
toast.success('Reservierung aktualisiert')
} else {
await tripStore.addReservation(tripId, { ...data, day_id: selectedDayId || null })
toast.success('Reservierung hinzugefügt')
}
setShowReservationModal(false)
} catch (err) {
toast.error(err.message)
}
}
const handleDeleteReservation = async (id) => {
if (!confirm('Reservierung löschen?')) return
try {
await tripStore.deleteReservation(tripId, id)
toast.success('Reservierung gelöscht')
} catch (err) {
toast.error(err.message)
}
}
// Reservations for selected day (or all if no day selected)
const filteredReservations = selectedDayId
? reservations.filter(r => String(r.day_id) === String(selectedDayId) || !r.day_id)
: reservations
const selectedPlace = selectedPlaceId ? places.find(p => p.id === selectedPlaceId) : null
return (
<div className="flex flex-col h-full bg-white">
{/* Tabs */}
<div className="flex border-b border-gray-200 flex-shrink-0">
{TABS.map(tab => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`flex-1 py-2.5 text-xs font-medium transition-colors flex flex-col items-center gap-0.5 ${
activeTab === tab.id
? 'text-slate-700 border-b-2 border-slate-700'
: 'text-gray-500 hover:text-gray-700'
}`}
>
<span className="text-base leading-none">{tab.icon}</span>
<span>{tab.label}</span>
</button>
))}
</div>
{/* Tab Content */}
<div className="flex-1 overflow-y-auto">
{/* ORTE TAB */}
{activeTab === 'orte' && (
<div className="flex flex-col h-full">
{/* Place detail (when selected) */}
{selectedPlace && (
<div className="border-b border-gray-100">
<PlaceDetailPanel
place={selectedPlace}
categories={categories}
tags={tags}
selectedDayId={selectedDayId}
dayAssignments={dayAssignments}
onClose={() => onPlaceClick(null)}
onEdit={() => onPlaceEdit(selectedPlace)}
onDelete={() => onPlaceDelete(selectedPlace.id)}
onAssignToDay={onAssignToDay}
onRemoveAssignment={onRemoveAssignment}
/>
</div>
)}
{/* Search & filter */}
<div className="p-3 space-y-2 border-b border-gray-100 flex-shrink-0">
<div className="relative">
<Search className="absolute left-2.5 top-2.5 w-4 h-4 text-gray-400" />
<input
type="text"
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="Orte suchen..."
className="w-full pl-8 pr-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-slate-900"
/>
{search && (
<button onClick={() => setSearch('')} className="absolute right-2.5 top-2.5">
<X className="w-4 h-4 text-gray-400" />
</button>
)}
</div>
<div className="flex items-center gap-2">
<select
value={categoryFilter}
onChange={e => setCategoryFilter(e.target.value)}
className="flex-1 border border-gray-200 rounded-lg text-xs py-1.5 px-2 focus:outline-none focus:ring-1 focus:ring-slate-900 text-gray-600"
>
<option value="">Alle Kategorien</option>
{categories.map(c => (
<option key={c.id} value={c.id}>{c.icon} {c.name}</option>
))}
</select>
<button
onClick={onAddPlace}
className="flex items-center gap-1 bg-slate-700 text-white text-xs px-3 py-1.5 rounded-lg hover:bg-slate-900 whitespace-nowrap"
>
<Plus className="w-3.5 h-3.5" />
Ort hinzufügen
</button>
</div>
</div>
{/* Places list */}
<div className="flex-1 overflow-y-auto">
{filteredPlaces.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-gray-400">
<span className="text-3xl mb-2">📍</span>
<p className="text-sm">Keine Orte gefunden</p>
<button onClick={onAddPlace} className="mt-3 text-slate-700 text-sm hover:underline">
Ersten Ort hinzufügen
</button>
</div>
) : (
<div className="divide-y divide-gray-50">
{filteredPlaces.map(place => {
const category = categories.find(c => c.id === place.category_id)
const isInDay = isAssignedToSelectedDay(place.id)
const isSelected = place.id === selectedPlaceId
return (
<div
key={place.id}
onClick={() => onPlaceClick(isSelected ? null : place.id)}
className={`px-3 py-2.5 cursor-pointer transition-colors ${
isSelected ? 'bg-slate-50' : 'hover:bg-gray-50'
}`}
>
<div className="flex items-start gap-2">
{/* Category color bar */}
<div
className="w-1 rounded-full flex-shrink-0 mt-1 self-stretch"
style={{ backgroundColor: category?.color || '#6366f1', minHeight: 16 }}
/>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-1">
<span className="font-medium text-sm text-gray-900 truncate">{place.name}</span>
<div className="flex items-center gap-1 flex-shrink-0">
{isInDay && (
<span className="text-xs text-emerald-600 bg-emerald-50 px-1.5 py-0.5 rounded"></span>
)}
{!isInDay && selectedDayId && (
<button
onClick={e => { e.stopPropagation(); onAssignToDay(place.id) }}
className="text-xs text-slate-700 bg-slate-50 px-1.5 py-0.5 rounded hover:bg-slate-100"
>
+ Tag
</button>
)}
</div>
</div>
{category && (
<span className="text-xs text-gray-500">{category.icon} {category.name}</span>
)}
{place.address && (
<p className="text-xs text-gray-400 truncate mt-0.5">{place.address}</p>
)}
<div className="flex items-center gap-2 mt-1">
{place.place_time && (
<span className="text-xs text-gray-500">🕐 {place.place_time}</span>
)}
{place.price > 0 && (
<span className="text-xs text-gray-500">
{place.price} {place.currency || trip?.currency}
</span>
)}
</div>
</div>
</div>
</div>
)
})}
</div>
)}
</div>
</div>
)}
{/* TAGESPLAN TAB */}
{activeTab === 'tagesplan' && (
<div className="flex flex-col h-full">
{!selectedDayId ? (
<div className="flex flex-col items-center justify-center py-16 text-gray-400 px-6">
<span className="text-4xl mb-3">📅</span>
<p className="text-sm text-center">Wähle einen Tag aus der linken Liste um den Tagesplan zu sehen</p>
</div>
) : (
<>
{/* Day header */}
<div className="px-4 py-3 bg-slate-50 border-b border-slate-100 flex-shrink-0">
<h3 className="font-semibold text-slate-900 text-sm">
Tag {selectedDay?.day_number}
{selectedDay?.date && (
<span className="font-normal text-slate-700 ml-2">
{formatGermanDate(selectedDay.date)}
</span>
)}
</h3>
<p className="text-xs text-slate-700 mt-0.5">
{dayAssignments.length} Ort{dayAssignments.length !== 1 ? 'e' : ''}
{dayAssignments.length > 0 && ` · ${dayAssignments.reduce((s, a) => s + (a.place?.duration_minutes || 60), 0)} Min. gesamt`}
</p>
</div>
{/* Transport mode */}
<div className="px-3 py-2 border-b border-gray-100 flex items-center gap-1 flex-shrink-0">
{TRANSPORT_MODES.map(m => (
<button
key={m.value}
onClick={() => setTransportMode(m.value)}
className={`flex-1 py-1.5 text-xs rounded-lg flex items-center justify-center gap-1 transition-colors ${
transportMode === m.value
? 'bg-slate-100 text-slate-900 font-medium'
: 'text-gray-500 hover:bg-gray-100'
}`}
>
{m.icon} {m.label}
</button>
))}
</div>
{/* Places list with order */}
<div className="flex-1 overflow-y-auto">
{dayAssignments.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-gray-400">
<span className="text-3xl mb-2">🗺</span>
<p className="text-sm">Noch keine Orte für diesen Tag</p>
<button
onClick={() => setActiveTab('orte')}
className="mt-3 text-slate-700 text-sm hover:underline"
>
Orte hinzufügen
</button>
</div>
) : (
<div className="divide-y divide-gray-50">
{getSchedule().map(({ assignment, startTime, endTime }, idx) => {
const place = assignment.place
if (!place) return null
const category = categories.find(c => c.id === place.category_id)
return (
<div key={assignment.id} className="px-3 py-3 flex items-start gap-2">
{/* Order number */}
<div
className="w-7 h-7 rounded-full flex items-center justify-center text-white text-xs font-bold flex-shrink-0 mt-0.5"
style={{ backgroundColor: category?.color || '#6366f1' }}
>
{idx + 1}
</div>
{/* Place info */}
<div className="flex-1 min-w-0">
<div className="font-medium text-sm text-gray-900 truncate">{place.name}</div>
<div className="flex items-center gap-2 mt-0.5">
{startTime && (
<span className="text-xs text-slate-700">🕐 {startTime}</span>
)}
<span className="text-xs text-gray-400">
{place.duration_minutes || 60} Min.
</span>
{place.price > 0 && (
<span className="text-xs text-gray-400">
{place.price} {place.currency || trip?.currency}
</span>
)}
</div>
{place.address && (
<p className="text-xs text-gray-400 mt-0.5 truncate">{place.address}</p>
)}
{assignment.notes && (
<p className="text-xs text-gray-500 mt-1 bg-gray-50 rounded px-2 py-1">{assignment.notes}</p>
)}
</div>
{/* Actions */}
<div className="flex flex-col items-center gap-0.5 flex-shrink-0">
<button
onClick={() => handleMoveUp(idx)}
disabled={idx === 0}
className="p-1 text-gray-400 hover:text-gray-600 disabled:opacity-30"
>
<ChevronUp className="w-3.5 h-3.5" />
</button>
<button
onClick={() => handleMoveDown(idx)}
disabled={idx === dayAssignments.length - 1}
className="p-1 text-gray-400 hover:text-gray-600 disabled:opacity-30"
>
<ChevronDown className="w-3.5 h-3.5" />
</button>
<button
onClick={() => onRemoveAssignment(selectedDayId, assignment.id)}
className="p-1 text-red-400 hover:text-red-600"
>
<X className="w-3.5 h-3.5" />
</button>
</div>
</div>
)
})}
</div>
)}
</div>
{/* Route buttons */}
{dayAssignments.length >= 2 && (
<div className="p-3 border-t border-gray-100 flex-shrink-0 space-y-2">
{routeInfo && (
<div className="flex items-center justify-center gap-3 text-sm bg-slate-50 rounded-lg px-3 py-2">
<span className="text-slate-900">🛣 {routeInfo.distance}</span>
<span className="text-slate-400">·</span>
<span className="text-slate-900"> {routeInfo.duration}</span>
</div>
)}
<div className="grid grid-cols-2 gap-2">
<button
onClick={handleCalculateRoute}
disabled={isCalculatingRoute}
className="flex items-center justify-center gap-1.5 bg-slate-700 text-white text-xs py-2 rounded-lg hover:bg-slate-900 disabled:opacity-60"
>
<Navigation className="w-3.5 h-3.5" />
{isCalculatingRoute ? 'Berechne...' : 'Route berechnen'}
</button>
<button
onClick={handleOptimizeRoute}
className="flex items-center justify-center gap-1.5 bg-emerald-600 text-white text-xs py-2 rounded-lg hover:bg-emerald-700"
>
<RotateCcw className="w-3.5 h-3.5" />
Optimieren
</button>
</div>
<button
onClick={handleOpenGoogleMaps}
className="w-full flex items-center justify-center gap-1.5 bg-white border border-gray-200 text-gray-700 text-xs py-2 rounded-lg hover:bg-gray-50"
>
<ExternalLink className="w-3.5 h-3.5" />
In Google Maps öffnen
</button>
</div>
)}
</>
)}
</div>
)}
{/* RESERVIERUNGEN TAB */}
{activeTab === 'reservierungen' && (
<div className="flex flex-col h-full">
<div className="p-3 flex items-center justify-between border-b border-gray-100 flex-shrink-0">
<h3 className="font-medium text-sm text-gray-900">
Reservierungen
{selectedDay && <span className="text-gray-500 font-normal"> · Tag {selectedDay.day_number}</span>}
</h3>
<button
onClick={handleAddReservation}
className="flex items-center gap-1 bg-slate-700 text-white text-xs px-2.5 py-1.5 rounded-lg hover:bg-slate-900"
>
<Plus className="w-3.5 h-3.5" />
Hinzufügen
</button>
</div>
<div className="flex-1 overflow-y-auto">
{filteredReservations.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-gray-400">
<span className="text-3xl mb-2">🎫</span>
<p className="text-sm">Keine Reservierungen</p>
<button onClick={handleAddReservation} className="mt-3 text-slate-700 text-sm hover:underline">
Erste Reservierung hinzufügen
</button>
</div>
) : (
<div className="p-3 space-y-3">
{filteredReservations.map(reservation => (
<div key={reservation.id} className="bg-white border border-gray-200 rounded-xl p-3 shadow-sm">
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="font-semibold text-sm text-gray-900">{reservation.title}</div>
{reservation.reservation_time && (
<div className="flex items-center gap-1 mt-1 text-xs text-slate-700">
<Clock className="w-3 h-3" />
{formatDateTime(reservation.reservation_time)}
</div>
)}
{reservation.location && (
<div className="text-xs text-gray-500 mt-0.5">📍 {reservation.location}</div>
)}
{reservation.confirmation_number && (
<div className="text-xs text-emerald-600 mt-1 bg-emerald-50 rounded px-2 py-0.5 inline-block">
# {reservation.confirmation_number}
</div>
)}
{reservation.notes && (
<p className="text-xs text-gray-500 mt-1.5 leading-relaxed">{reservation.notes}</p>
)}
</div>
<div className="flex gap-1 flex-shrink-0">
<button
onClick={() => { setEditingReservation(reservation); setShowReservationModal(true) }}
className="p-1.5 text-gray-400 hover:text-slate-700 hover:bg-slate-50 rounded-lg"
>
</button>
<button
onClick={() => handleDeleteReservation(reservation.id)}
className="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg"
>
🗑
</button>
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
)}
{/* PACKLISTE TAB */}
{activeTab === 'packliste' && (
<PackingListPanel
tripId={tripId}
items={packingItems}
/>
)}
</div>
{/* Reservation Modal */}
<ReservationModal
isOpen={showReservationModal}
onClose={() => { setShowReservationModal(false); setEditingReservation(null) }}
onSave={handleSaveReservation}
reservation={editingReservation}
days={days}
places={places}
selectedDayId={selectedDayId}
/>
</div>
)
}
function formatGermanDate(dateStr) {
if (!dateStr) return ''
const date = new Date(dateStr + 'T00:00:00')
return date.toLocaleDateString('de-DE', { weekday: 'long', day: 'numeric', month: 'long' })
}
function formatDateTime(dt) {
if (!dt) return ''
try {
return new Date(dt).toLocaleString('de-DE', { dateStyle: 'medium', timeStyle: 'short' })
} catch {
return dt
}
}

View File

@@ -0,0 +1,193 @@
import React, { useState, useEffect, useRef } from 'react'
import Modal from '../shared/Modal'
import { Calendar, Camera, X } from 'lucide-react'
import { tripsApi } from '../../api/client'
import { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n'
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUpdate }) {
const isEditing = !!trip
const fileRef = useRef(null)
const toast = useToast()
const { t } = useTranslation()
const [formData, setFormData] = useState({
title: '',
description: '',
start_date: '',
end_date: '',
})
const [error, setError] = useState('')
const [isLoading, setIsLoading] = useState(false)
const [coverPreview, setCoverPreview] = useState(null)
const [uploadingCover, setUploadingCover] = useState(false)
useEffect(() => {
if (trip) {
setFormData({
title: trip.title || '',
description: trip.description || '',
start_date: trip.start_date || '',
end_date: trip.end_date || '',
})
setCoverPreview(trip.cover_image || null)
} else {
setFormData({ title: '', description: '', start_date: '', end_date: '' })
setCoverPreview(null)
}
setError('')
}, [trip, isOpen])
const handleSubmit = async (e) => {
e.preventDefault()
setError('')
if (!formData.title.trim()) { setError(t('dashboard.titleRequired')); return }
if (formData.start_date && formData.end_date && new Date(formData.end_date) < new Date(formData.start_date)) {
setError(t('dashboard.endDateError')); return
}
setIsLoading(true)
try {
await onSave({
title: formData.title.trim(),
description: formData.description.trim() || null,
start_date: formData.start_date || null,
end_date: formData.end_date || null,
})
onClose()
} catch (err) {
setError(err.message || t('places.saveError'))
} finally {
setIsLoading(false)
}
}
const handleCoverChange = async (e) => {
const file = e.target.files?.[0]
if (!file || !trip?.id) return
setUploadingCover(true)
try {
const fd = new FormData()
fd.append('cover', file)
const data = await tripsApi.uploadCover(trip.id, fd)
setCoverPreview(data.cover_image)
onCoverUpdate?.(trip.id, data.cover_image)
toast.success(t('dashboard.coverSaved'))
} catch {
toast.error(t('dashboard.coverUploadError'))
} finally {
setUploadingCover(false)
e.target.value = ''
}
}
const handleRemoveCover = async () => {
if (!trip?.id) return
try {
await tripsApi.update(trip.id, { cover_image: null })
setCoverPreview(null)
onCoverUpdate?.(trip.id, null)
} catch {
toast.error(t('dashboard.coverRemoveError'))
}
}
const update = (field, value) => setFormData(prev => ({ ...prev, [field]: value }))
const inputCls = "w-full px-3 py-2.5 border border-slate-200 rounded-lg text-slate-900 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-slate-300 focus:border-transparent text-sm"
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={isEditing ? t('dashboard.editTrip') : t('dashboard.createTrip')}
size="md"
footer={
<div className="flex gap-3 justify-end">
<button type="button" onClick={onClose}
className="px-4 py-2 text-sm text-slate-600 hover:text-slate-800 border border-slate-200 rounded-lg hover:bg-slate-50 transition-colors">
{t('common.cancel')}
</button>
<button onClick={handleSubmit} disabled={isLoading}
className="px-4 py-2 text-sm bg-slate-900 hover:bg-slate-700 disabled:bg-slate-400 text-white rounded-lg transition-colors flex items-center gap-2">
{isLoading
? <><div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />{t('common.saving')}</>
: isEditing ? t('common.update') : t('dashboard.createTrip')}
</button>
</div>
}
>
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-600">{error}</div>
)}
{/* Cover image — only for existing trips */}
{isEditing && (
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('dashboard.coverImage')}</label>
<input ref={fileRef} type="file" accept="image/*" style={{ display: 'none' }} onChange={handleCoverChange} />
{coverPreview ? (
<div style={{ position: 'relative', borderRadius: 10, overflow: 'hidden', height: 130 }}>
<img src={coverPreview} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
<div style={{ position: 'absolute', bottom: 8, right: 8, display: 'flex', gap: 6 }}>
<button type="button" onClick={() => fileRef.current?.click()} disabled={uploadingCover}
style={{ display: 'flex', alignItems: 'center', gap: 4, padding: '5px 10px', borderRadius: 8, background: 'rgba(0,0,0,0.55)', border: 'none', color: 'white', fontSize: 11.5, fontWeight: 600, cursor: 'pointer', backdropFilter: 'blur(4px)' }}>
<Camera size={12} /> {uploadingCover ? t('common.uploading') : t('common.change')}
</button>
<button type="button" onClick={handleRemoveCover}
style={{ display: 'flex', alignItems: 'center', padding: '5px 8px', borderRadius: 8, background: 'rgba(0,0,0,0.55)', border: 'none', color: 'white', cursor: 'pointer', backdropFilter: 'blur(4px)' }}>
<X size={12} />
</button>
</div>
</div>
) : (
<button type="button" onClick={() => fileRef.current?.click()} disabled={uploadingCover}
style={{ width: '100%', padding: '18px', border: '2px dashed #e5e7eb', borderRadius: 10, background: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6, fontSize: 13, color: '#9ca3af', fontFamily: 'inherit' }}
onMouseEnter={e => { e.currentTarget.style.borderColor = '#d1d5db'; e.currentTarget.style.color = '#6b7280' }}
onMouseLeave={e => { e.currentTarget.style.borderColor = '#e5e7eb'; e.currentTarget.style.color = '#9ca3af' }}>
<Camera size={15} /> {uploadingCover ? t('common.uploading') : t('dashboard.addCoverImage')}
</button>
)}
</div>
)}
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">
{t('dashboard.tripTitle')} <span className="text-red-500">*</span>
</label>
<input type="text" value={formData.title} onChange={e => update('title', e.target.value)}
required placeholder={t('dashboard.tripTitlePlaceholder')} className={inputCls} />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('dashboard.tripDescription')}</label>
<textarea value={formData.description} onChange={e => update('description', e.target.value)}
placeholder={t('dashboard.tripDescriptionPlaceholder')} rows={3}
className={`${inputCls} resize-none`} />
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">
<Calendar className="inline w-4 h-4 mr-1" />{t('dashboard.startDate')}
</label>
<CustomDatePicker value={formData.start_date} onChange={v => update('start_date', v)} placeholder="Start" />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">
<Calendar className="inline w-4 h-4 mr-1" />{t('dashboard.endDate')}
</label>
<CustomDatePicker value={formData.end_date} onChange={v => update('end_date', v)} placeholder="End" />
</div>
</div>
{!formData.start_date && !formData.end_date && (
<p className="text-xs text-slate-400 bg-slate-50 rounded-lg p-3">
{t('dashboard.noDateHint')}
</p>
)}
</form>
</Modal>
)
}

View File

@@ -0,0 +1,221 @@
import React, { useState, useEffect } from 'react'
import Modal from '../shared/Modal'
import { tripsApi, authApi } from '../../api/client'
import { useToast } from '../shared/Toast'
import { useAuthStore } from '../../store/authStore'
import { Crown, UserMinus, UserPlus, Users, LogOut } from 'lucide-react'
import { useTranslation } from '../../i18n'
import CustomSelect from '../shared/CustomSelect'
function Avatar({ username, avatarUrl, size = 32 }) {
if (avatarUrl) {
return <img src={avatarUrl} alt="" style={{ width: size, height: size, borderRadius: '50%', objectFit: 'cover', flexShrink: 0 }} />
}
const letter = (username || '?')[0].toUpperCase()
const colors = ['#3b82f6', '#8b5cf6', '#ec4899', '#10b981', '#f59e0b', '#ef4444', '#06b6d4']
const color = colors[letter.charCodeAt(0) % colors.length]
return (
<div style={{
width: size, height: size, borderRadius: '50%', background: color,
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: size * 0.4, fontWeight: 700, color: 'white', flexShrink: 0,
}}>
{letter}
</div>
)
}
export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle }) {
const [data, setData] = useState(null)
const [allUsers, setAllUsers] = useState([])
const [loading, setLoading] = useState(false)
const [selectedUserId, setSelectedUserId] = useState('')
const [adding, setAdding] = useState(false)
const [removingId, setRemovingId] = useState(null)
const toast = useToast()
const { user } = useAuthStore()
const { t } = useTranslation()
useEffect(() => {
if (isOpen && tripId) {
loadMembers()
loadAllUsers()
}
}, [isOpen, tripId])
const loadMembers = async () => {
setLoading(true)
try {
const d = await tripsApi.getMembers(tripId)
setData(d)
} catch {
toast.error(t('members.loadError'))
} finally {
setLoading(false)
}
}
const loadAllUsers = async () => {
try {
const d = await authApi.listUsers()
setAllUsers(d.users)
} catch {}
}
const handleAdd = async () => {
if (!selectedUserId) return
setAdding(true)
try {
const target = allUsers.find(u => String(u.id) === String(selectedUserId))
await tripsApi.addMember(tripId, target.username)
setSelectedUserId('')
await loadMembers()
toast.success(`${target.username} ${t('members.added')}`)
} catch (err) {
toast.error(err.response?.data?.error || t('members.addError'))
} finally {
setAdding(false)
}
}
const handleRemove = async (userId, isSelf) => {
const msg = isSelf
? t('members.confirmLeave')
: t('members.confirmRemove')
if (!confirm(msg)) return
setRemovingId(userId)
try {
await tripsApi.removeMember(tripId, userId)
if (isSelf) { onClose(); window.location.reload() }
else { await loadMembers(); toast.success(t('members.removed')) }
} catch {
toast.error(t('members.removeError'))
} finally {
setRemovingId(null)
}
}
// Users not yet in the trip
const existingIds = new Set([
data?.owner?.id,
...(data?.members?.map(m => m.id) || []),
])
const availableUsers = allUsers.filter(u => !existingIds.has(u.id))
const isCurrentOwner = data?.owner?.id === user?.id
const allMembers = data ? [
{ ...data.owner, role: 'owner' },
...data.members,
] : []
return (
<Modal isOpen={isOpen} onClose={onClose} title={t('members.shareTrip')} size="sm">
<div style={{ display: 'flex', flexDirection: 'column', gap: 20, fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }}>
{/* Trip name */}
<div style={{ padding: '10px 14px', background: 'var(--bg-secondary)', borderRadius: 10, border: '1px solid var(--border-secondary)' }}>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 2 }}>{t('nav.trip')}</div>
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-primary)' }}>{tripTitle}</div>
</div>
{/* Add member dropdown */}
<div>
<label style={{ display: 'block', fontSize: 12, fontWeight: 600, color: 'var(--text-secondary)', marginBottom: 8 }}>
{t('members.inviteUser')}
</label>
<div style={{ display: 'flex', gap: 8 }}>
<CustomSelect
value={selectedUserId}
onChange={value => setSelectedUserId(value)}
placeholder={t('members.selectUser')}
options={[
{ value: '', label: t('members.selectUser') },
...availableUsers.map(u => ({
value: u.id,
label: u.username,
})),
]}
searchable
style={{ flex: 1 }}
size="sm"
/>
<button
onClick={handleAdd}
disabled={adding || !selectedUserId}
style={{
display: 'flex', alignItems: 'center', gap: 5, padding: '8px 14px',
background: 'var(--accent)', color: 'white', border: 'none', borderRadius: 10,
fontSize: 13, fontWeight: 600, cursor: adding || !selectedUserId ? 'default' : 'pointer',
fontFamily: 'inherit', opacity: adding || !selectedUserId ? 0.4 : 1, flexShrink: 0,
}}
>
<UserPlus size={13} /> {adding ? '…' : t('members.invite')}
</button>
</div>
{availableUsers.length === 0 && allUsers.length > 0 && (
<p style={{ fontSize: 11.5, color: 'var(--text-faint)', margin: '6px 0 0' }}>{t('members.allHaveAccess')}</p>
)}
</div>
{/* Members list */}
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 10 }}>
<Users size={13} style={{ color: 'var(--text-faint)' }} />
<span style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-secondary)' }}>
{t('members.access')} ({allMembers.length} {allMembers.length === 1 ? t('members.person') : t('members.persons')})
</span>
</div>
{loading ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{[1, 2].map(i => (
<div key={i} style={{ height: 48, background: 'var(--bg-tertiary)', borderRadius: 10, animation: 'pulse 1.5s ease-in-out infinite' }} />
))}
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{allMembers.map(member => {
const isSelf = member.id === user?.id
const canRemove = isCurrentOwner ? member.role !== 'owner' : isSelf
return (
<div key={member.id} style={{
display: 'flex', alignItems: 'center', gap: 10,
padding: '8px 12px', borderRadius: 10, background: 'var(--bg-secondary)',
border: '1px solid var(--border-secondary)',
}}>
<Avatar username={member.username} avatarUrl={member.avatar_url} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>{member.username}</span>
{isSelf && <span style={{ fontSize: 10, color: 'var(--text-faint)' }}>({t('members.you')})</span>}
{member.role === 'owner' && (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 3, fontSize: 10, fontWeight: 700, color: '#d97706', background: '#fef9c3', padding: '1px 6px', borderRadius: 99 }}>
<Crown size={9} /> {t('members.owner')}
</span>
)}
</div>
</div>
{canRemove && (
<button
onClick={() => handleRemove(member.id, isSelf)}
disabled={removingId === member.id}
title={isSelf ? t('members.leaveTrip') : t('members.removeAccess')}
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '4px', borderRadius: 6, display: 'flex', color: 'var(--text-faint)', opacity: removingId === member.id ? 0.4 : 1 }}
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'}
onMouseLeave={e => e.currentTarget.style.color = '#9ca3af'}
>
{isSelf ? <LogOut size={14} /> : <UserMinus size={14} />}
</button>
)}
</div>
)
})}
</div>
)}
</div>
<style>{`@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.5} }`}</style>
</div>
</Modal>
)
}

View File

@@ -0,0 +1,90 @@
import React, { useState, useEffect } from 'react'
import { Sun, Cloud, CloudRain, CloudSnow, CloudDrizzle, CloudLightning, Wind } from 'lucide-react'
import { weatherApi } from '../../api/client'
import { useSettingsStore } from '../../store/settingsStore'
const WEATHER_ICON_MAP = {
Clear: Sun,
Clouds: Cloud,
Rain: CloudRain,
Drizzle: CloudDrizzle,
Thunderstorm: CloudLightning,
Snow: CloudSnow,
Mist: Wind,
Fog: Wind,
Haze: Wind,
}
function WeatherIcon({ main, size = 13 }) {
const Icon = WEATHER_ICON_MAP[main] || Cloud
return <Icon size={size} strokeWidth={1.8} />
}
const weatherCache = {}
export default function WeatherWidget({ lat, lng, date, compact = false }) {
const [weather, setWeather] = useState(null)
const [loading, setLoading] = useState(false)
const [failed, setFailed] = useState(false)
const isFahrenheit = useSettingsStore(s => s.settings.temperature_unit) === 'fahrenheit'
useEffect(() => {
if (!lat || !lng || !date) return
const cacheKey = `${lat},${lng},${date}`
if (weatherCache[cacheKey] !== undefined) {
if (weatherCache[cacheKey] === null) setFailed(true)
else setWeather(weatherCache[cacheKey])
return
}
setLoading(true)
weatherApi.get(lat, lng, date)
.then(data => {
if (data.error || data.temp === undefined) {
weatherCache[cacheKey] = null
setFailed(true)
} else {
weatherCache[cacheKey] = data
setWeather(data)
}
})
.catch(() => { weatherCache[cacheKey] = null; setFailed(true) })
.finally(() => setLoading(false))
}, [lat, lng, date])
if (!lat || !lng) return null
const fontStyle = { fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }
if (loading) {
return (
<span style={{ fontSize: 11, color: '#d1d5db', ...fontStyle }}></span>
)
}
if (failed || !weather) {
return (
<span style={{ fontSize: 11, color: '#9ca3af', ...fontStyle }}></span>
)
}
const rawTemp = weather.temp
const temp = rawTemp !== undefined ? Math.round(isFahrenheit ? rawTemp * 9/5 + 32 : rawTemp) : null
const unit = isFahrenheit ? '°F' : '°C'
if (compact) {
return (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 3, fontSize: 11, color: '#6b7280', ...fontStyle }}>
<WeatherIcon main={weather.main} size={12} />
{temp !== null && <span>{temp}{unit}</span>}
</span>
)
}
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 13, color: '#374151', background: 'rgba(0,0,0,0.04)', borderRadius: 8, padding: '5px 10px', ...fontStyle }}>
<WeatherIcon main={weather.main} size={15} />
{temp !== null && <span style={{ fontWeight: 500 }}>{temp}{unit}</span>}
{weather.description && <span style={{ fontSize: 11, color: '#9ca3af', textTransform: 'capitalize' }}>{weather.description}</span>}
</div>
)
}

View File

@@ -0,0 +1,174 @@
import React, { useState, useRef, useEffect } from 'react'
import ReactDOM from 'react-dom'
import { Calendar, Clock, ChevronLeft, ChevronRight, ChevronUp, ChevronDown } from 'lucide-react'
import { useTranslation } from '../../i18n'
function daysInMonth(year, month) { return new Date(year, month + 1, 0).getDate() }
function getWeekday(year, month, day) { return new Date(year, month, day).getDay() }
// ── Datum-Only Picker ────────────────────────────────────────────────────────
export function CustomDatePicker({ value, onChange, placeholder, style = {} }) {
const { locale } = useTranslation()
const [open, setOpen] = useState(false)
const ref = useRef(null)
const dropRef = useRef(null)
const parsed = value ? new Date(value + 'T00:00:00') : null
const [viewYear, setViewYear] = useState(parsed?.getFullYear() || new Date().getFullYear())
const [viewMonth, setViewMonth] = useState(parsed?.getMonth() ?? new Date().getMonth())
useEffect(() => {
const handler = (e) => {
if (ref.current?.contains(e.target)) return
if (dropRef.current?.contains(e.target)) return
setOpen(false)
}
if (open) document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [open])
useEffect(() => {
if (open && parsed) { setViewYear(parsed.getFullYear()); setViewMonth(parsed.getMonth()) }
}, [open])
const prevMonth = () => { if (viewMonth === 0) { setViewMonth(11); setViewYear(y => y - 1) } else setViewMonth(m => m - 1) }
const nextMonth = () => { if (viewMonth === 11) { setViewMonth(0); setViewYear(y => y + 1) } else setViewMonth(m => m + 1) }
const monthLabel = new Date(viewYear, viewMonth).toLocaleDateString(locale, { month: 'long', year: 'numeric' })
const days = daysInMonth(viewYear, viewMonth)
const startDay = (getWeekday(viewYear, viewMonth, 1) + 6) % 7 // Mo=0
const weekdays = Array.from({ length: 7 }, (_, i) => new Date(2024, 0, i + 1).toLocaleDateString(locale, { weekday: 'narrow' }))
const displayValue = parsed ? parsed.toLocaleDateString(locale, { day: 'numeric', month: 'short', year: 'numeric' }) : null
const selectDay = (day) => {
const y = String(viewYear)
const m = String(viewMonth + 1).padStart(2, '0')
const d = String(day).padStart(2, '0')
onChange(`${y}-${m}-${d}`)
setOpen(false)
}
const selectedDay = parsed && parsed.getFullYear() === viewYear && parsed.getMonth() === viewMonth ? parsed.getDate() : null
const today = new Date()
const isToday = (d) => today.getFullYear() === viewYear && today.getMonth() === viewMonth && today.getDate() === d
return (
<div ref={ref} style={{ position: 'relative', ...style }}>
<button type="button" onClick={() => setOpen(o => !o)}
style={{
width: '100%', display: 'flex', alignItems: 'center', gap: 8,
padding: '8px 14px', borderRadius: 10,
border: '1px solid var(--border-primary)',
background: 'var(--bg-input)', color: displayValue ? 'var(--text-primary)' : 'var(--text-faint)',
fontSize: 13, fontFamily: 'inherit', cursor: 'pointer', outline: 'none',
transition: 'border-color 0.15s',
}}
onMouseEnter={e => e.currentTarget.style.borderColor = 'var(--text-faint)'}
onMouseLeave={e => { if (!open) e.currentTarget.style.borderColor = 'var(--border-primary)' }}>
<Calendar size={14} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
<span>{displayValue || placeholder || 'Datum'}</span>
</button>
{open && ReactDOM.createPortal(
<div ref={dropRef} style={{
position: 'fixed',
top: (() => { const r = ref.current?.getBoundingClientRect(); return r ? r.bottom + 4 : 0 })(),
left: (() => { const r = ref.current?.getBoundingClientRect(); return r ? r.left : 0 })(),
zIndex: 99999,
background: 'var(--bg-card)', border: '1px solid var(--border-primary)',
borderRadius: 14, boxShadow: '0 8px 32px rgba(0,0,0,0.12)', padding: 12, width: 268,
animation: 'selectIn 0.15s ease-out',
backdropFilter: 'blur(24px)', WebkitBackdropFilter: 'blur(24px)',
}}>
{/* Month nav */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 10 }}>
<button type="button" onClick={prevMonth} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 4, borderRadius: 6, display: 'flex', color: 'var(--text-faint)' }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<ChevronLeft size={16} />
</button>
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>{monthLabel}</span>
<button type="button" onClick={nextMonth} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 4, borderRadius: 6, display: 'flex', color: 'var(--text-faint)' }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<ChevronRight size={16} />
</button>
</div>
{/* Weekday headers */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: 2, marginBottom: 4 }}>
{weekdays.map((d, i) => (
<div key={i} style={{ textAlign: 'center', fontSize: 10, fontWeight: 600, color: 'var(--text-faint)', padding: '2px 0' }}>{d}</div>
))}
</div>
{/* Days grid */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: 2 }}>
{Array.from({ length: startDay }, (_, i) => <div key={`e-${i}`} />)}
{Array.from({ length: days }, (_, i) => {
const d = i + 1
const sel = d === selectedDay
const td = isToday(d)
return (
<button key={d} type="button" onClick={() => selectDay(d)}
style={{
width: 32, height: 32, borderRadius: 8, border: 'none',
background: sel ? 'var(--accent)' : 'transparent',
color: sel ? 'var(--accent-text)' : 'var(--text-primary)',
fontSize: 12, fontWeight: sel ? 700 : td ? 600 : 400,
cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
outline: td && !sel ? '2px solid var(--border-primary)' : 'none', outlineOffset: -2,
transition: 'background 0.1s',
}}
onMouseEnter={e => { if (!sel) e.currentTarget.style.background = 'var(--bg-hover)' }}
onMouseLeave={e => { if (!sel) e.currentTarget.style.background = 'transparent' }}>
{d}
</button>
)
})}
</div>
{/* Clear */}
{value && (
<div style={{ marginTop: 8, display: 'flex', justifyContent: 'center' }}>
<button type="button" onClick={() => { onChange(''); setOpen(false) }}
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 11, color: 'var(--text-faint)', padding: '3px 8px', borderRadius: 6 }}
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
</button>
</div>
)}
</div>,
document.body
)}
<style>{`@keyframes selectIn { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: translateY(0); } }`}</style>
</div>
)
}
// ── DateTime Picker (Datum + Uhrzeit kombiniert) ─────────────────────────────
export function CustomDateTimePicker({ value, onChange, placeholder, style = {} }) {
const { locale } = useTranslation()
// value = "2024-03-15T14:30" oder ""
const [datePart, timePart] = (value || '').split('T')
const handleDateChange = (d) => {
onChange(d ? `${d}T${timePart || '12:00'}` : '')
}
const handleTimeChange = (t) => {
const d = datePart || new Date().toISOString().split('T')[0]
onChange(t ? `${d}T${t}` : `${d}T00:00`)
}
return (
<div style={{ display: 'flex', gap: 8, ...style }}>
<CustomDatePicker value={datePart || ''} onChange={handleDateChange} style={{ flex: 1, minWidth: 0 }} />
<div style={{ width: 110, flexShrink: 0 }}>
<CustomTimePicker value={timePart || ''} onChange={handleTimeChange} />
</div>
</div>
)
}
// Inline re-export for convenience
import CustomTimePicker from './CustomTimePicker'

View File

@@ -0,0 +1,144 @@
import React, { useState, useRef, useEffect } from 'react'
import ReactDOM from 'react-dom'
import { ChevronDown, Check } from 'lucide-react'
export default function CustomSelect({
value,
onChange,
options = [], // [{ value, label, icon? }]
placeholder = '',
searchable = false,
style = {},
size = 'md', // 'sm' | 'md'
}) {
const [open, setOpen] = useState(false)
const [search, setSearch] = useState('')
const ref = useRef(null)
const dropRef = useRef(null)
const searchRef = useRef(null)
useEffect(() => {
if (open && searchable && searchRef.current) searchRef.current.focus()
}, [open, searchable])
useEffect(() => {
const handleClick = (e) => {
if (ref.current?.contains(e.target)) return
if (dropRef.current?.contains(e.target)) return
setOpen(false)
}
if (open) document.addEventListener('mousedown', handleClick)
return () => document.removeEventListener('mousedown', handleClick)
}, [open])
const selected = options.find(o => o.value === value)
const filtered = searchable && search
? options.filter(o => o.label.toLowerCase().includes(search.toLowerCase()))
: options
const sm = size === 'sm'
return (
<div ref={ref} style={{ position: 'relative', ...style }}>
{/* Trigger */}
<button
type="button"
onClick={() => { setOpen(o => !o); setSearch('') }}
style={{
width: '100%', display: 'flex', alignItems: 'center', gap: 8,
padding: sm ? '8px 12px' : '8px 14px', borderRadius: 10,
border: '1px solid var(--border-primary)',
background: 'var(--bg-input)', color: 'var(--text-primary)',
fontSize: 13, fontWeight: 500, fontFamily: 'inherit',
cursor: 'pointer', outline: 'none', textAlign: 'left',
transition: 'border-color 0.15s',
}}
onMouseEnter={e => e.currentTarget.style.borderColor = 'var(--text-faint)'}
onMouseLeave={e => { if (!open) e.currentTarget.style.borderColor = 'var(--border-primary)' }}
>
{selected?.icon && <span style={{ display: 'flex', flexShrink: 0 }}>{selected.icon}</span>}
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', color: selected ? 'var(--text-primary)' : 'var(--text-faint)' }}>
{selected ? selected.label : placeholder}
</span>
<ChevronDown size={sm ? 12 : 14} style={{ flexShrink: 0, color: 'var(--text-faint)', transition: 'transform 0.2s', transform: open ? 'rotate(180deg)' : 'none' }} />
</button>
{/* Dropdown */}
{open && ReactDOM.createPortal(
<div ref={dropRef} style={{
position: 'fixed',
top: (() => { const r = ref.current?.getBoundingClientRect(); return r ? r.bottom + 4 : 0 })(),
left: (() => { const r = ref.current?.getBoundingClientRect(); return r ? r.left : 0 })(),
width: (() => { const r = ref.current?.getBoundingClientRect(); return r ? r.width : 200 })(),
zIndex: 99999,
background: 'var(--bg-card)',
backdropFilter: 'blur(24px) saturate(180%)',
WebkitBackdropFilter: 'blur(24px) saturate(180%)',
border: '1px solid var(--border-primary)',
borderRadius: 10,
boxShadow: '0 8px 32px rgba(0,0,0,0.12)',
overflow: 'hidden',
animation: 'selectIn 0.15s ease-out',
}}>
{/* Search */}
{searchable && (
<div style={{ padding: '6px 6px 2px' }}>
<input
ref={searchRef}
type="text"
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="..."
style={{
width: '100%', border: '1px solid var(--border-secondary)', borderRadius: 6,
padding: '5px 8px', fontSize: 12, outline: 'none', fontFamily: 'inherit',
background: 'var(--bg-secondary)', color: 'var(--text-primary)',
boxSizing: 'border-box',
}}
/>
</div>
)}
{/* Options */}
<div style={{ maxHeight: 220, overflowY: 'auto', padding: '4px' }}>
{filtered.length === 0 ? (
<div style={{ padding: '10px 12px', fontSize: 12, color: 'var(--text-faint)', textAlign: 'center' }}></div>
) : (
filtered.map(option => {
const isSelected = option.value === value
return (
<button
key={option.value}
type="button"
onClick={() => { onChange(option.value); setOpen(false); setSearch('') }}
style={{
width: '100%', display: 'flex', alignItems: 'center', gap: 8,
padding: '7px 10px', borderRadius: 6,
border: 'none', background: isSelected ? 'var(--bg-hover)' : 'transparent',
color: 'var(--text-primary)', fontSize: 13, fontFamily: 'inherit',
cursor: 'pointer', textAlign: 'left', transition: 'background 0.1s',
}}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = isSelected ? 'var(--bg-hover)' : 'transparent'}
>
{option.icon && <span style={{ display: 'flex', flexShrink: 0 }}>{option.icon}</span>}
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{option.label}</span>
{isSelected && <Check size={13} style={{ flexShrink: 0, color: 'var(--text-muted)' }} />}
</button>
)
})
)}
</div>
</div>,
document.body
)}
<style>{`
@keyframes selectIn {
from { opacity: 0; transform: translateY(-4px); }
to { opacity: 1; transform: translateY(0); }
}
`}</style>
</div>
)
}

View File

@@ -0,0 +1,224 @@
import React, { useState, useRef, useEffect } from 'react'
import ReactDOM from 'react-dom'
import { Clock, ChevronUp, ChevronDown } from 'lucide-react'
import { useSettingsStore } from '../../store/settingsStore'
function formatDisplay(val, is12h) {
if (!val) return ''
const [h, m] = val.split(':').map(Number)
if (isNaN(h) || isNaN(m)) return val
if (!is12h) return val
const period = h >= 12 ? 'PM' : 'AM'
const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h
return `${h12}:${String(m).padStart(2, '0')} ${period}`
}
export default function CustomTimePicker({ value, onChange, placeholder = '00:00', style = {} }) {
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
const [open, setOpen] = useState(false)
const [inputFocused, setInputFocused] = useState(false)
const ref = useRef(null)
const dropRef = useRef(null)
const [h, m] = (value || '').split(':').map(Number)
const hour = isNaN(h) ? null : h
const minute = isNaN(m) ? null : m
useEffect(() => {
const handler = (e) => {
if (ref.current?.contains(e.target)) return
if (dropRef.current?.contains(e.target)) return
setOpen(false)
}
if (open) document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [open])
const update = (newH, newM) => {
const hh = String(Math.max(0, Math.min(23, newH))).padStart(2, '0')
const mm = String(Math.max(0, Math.min(59, newM))).padStart(2, '0')
onChange(`${hh}:${mm}`)
}
const incHour = () => update(((hour ?? -1) + 1) % 24, minute ?? 0)
const decHour = () => update(((hour ?? 1) - 1 + 24) % 24, minute ?? 0)
const incMin = () => {
const newM = ((minute ?? -5) + 5) % 60
const newH = newM < (minute ?? 0) ? ((hour ?? 0) + 1) % 24 : (hour ?? 0)
update(newH, newM)
}
const decMin = () => {
const newM = ((minute ?? 5) - 5 + 60) % 60
const newH = newM > (minute ?? 0) ? ((hour ?? 0) - 1 + 24) % 24 : (hour ?? 0)
update(newH, newM)
}
const btnStyle = {
background: 'none', border: 'none', cursor: 'pointer', padding: 2,
color: 'var(--text-faint)', display: 'flex', borderRadius: 4,
transition: 'color 0.15s',
}
const handleInput = (e) => {
const raw = e.target.value
onChange(raw)
// Auto-format: wenn "1430" → "14:30"
const clean = raw.replace(/[^0-9:]/g, '')
if (/^\d{2}:\d{2}$/.test(clean)) onChange(clean)
else if (/^\d{4}$/.test(clean)) onChange(clean.slice(0, 2) + ':' + clean.slice(2))
else if (/^\d{1,2}:\d{2}$/.test(clean)) {
const [hh, mm] = clean.split(':')
onChange(hh.padStart(2, '0') + ':' + mm)
}
}
const handleBlur = () => {
if (!value) return
const clean = value.replace(/[^0-9:]/g, '')
if (/^\d{1,2}:\d{2}$/.test(clean)) {
const [hh, mm] = clean.split(':')
const h = Math.min(23, Math.max(0, parseInt(hh)))
const m = Math.min(59, Math.max(0, parseInt(mm)))
onChange(String(h).padStart(2, '0') + ':' + String(m).padStart(2, '0'))
} else if (/^\d{3,4}$/.test(clean)) {
const s = clean.padStart(4, '0')
const h = Math.min(23, Math.max(0, parseInt(s.slice(0, 2))))
const m = Math.min(59, Math.max(0, parseInt(s.slice(2))))
onChange(String(h).padStart(2, '0') + ':' + String(m).padStart(2, '0'))
}
}
return (
<div ref={ref} style={{ position: 'relative', ...style }}>
<div style={{
width: '100%', display: 'flex', alignItems: 'center', gap: 0,
borderRadius: 10, border: '1px solid var(--border-primary)',
background: 'var(--bg-input)', overflow: 'hidden',
transition: 'border-color 0.15s',
}}>
<input
type="text"
value={inputFocused ? value : formatDisplay(value, is12h)}
onChange={handleInput}
onFocus={() => setInputFocused(true)}
onBlur={() => { setInputFocused(false); handleBlur() }}
placeholder={is12h ? '2:30 PM' : placeholder}
style={{
flex: 1, border: 'none', outline: 'none', background: 'transparent',
padding: '8px 10px 8px 14px', fontSize: 13, fontFamily: 'inherit',
color: value ? 'var(--text-primary)' : 'var(--text-faint)',
minWidth: 0,
}}
/>
<button
type="button"
onClick={() => setOpen(o => !o)}
style={{
background: 'none', border: 'none', cursor: 'pointer', padding: '8px 10px',
display: 'flex', alignItems: 'center', color: 'var(--text-faint)',
transition: 'color 0.15s', flexShrink: 0,
}}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<Clock size={14} />
</button>
</div>
{open && ReactDOM.createPortal(
<div ref={dropRef} style={{
position: 'fixed',
top: (() => { const r = ref.current?.getBoundingClientRect(); return r ? r.bottom + 4 : 0 })(),
left: (() => { const r = ref.current?.getBoundingClientRect(); return r ? r.left : 0 })(),
zIndex: 99999,
background: 'var(--bg-card)', border: '1px solid var(--border-primary)',
borderRadius: 12, boxShadow: '0 8px 32px rgba(0,0,0,0.12)',
padding: 12, display: 'flex', alignItems: 'center', gap: 6,
animation: 'selectIn 0.15s ease-out',
backdropFilter: 'blur(24px)', WebkitBackdropFilter: 'blur(24px)',
}}>
{/* Stunden */}
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2 }}>
<button type="button" onClick={incHour} style={btnStyle}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<ChevronUp size={16} />
</button>
<div style={{
width: 44, height: 40, display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 22, fontWeight: 700, color: 'var(--text-primary)',
background: 'var(--bg-hover)', borderRadius: 8,
fontVariantNumeric: 'tabular-nums',
}}>
{hour !== null ? (is12h ? String(hour === 0 ? 12 : hour > 12 ? hour - 12 : hour) : String(hour).padStart(2, '0')) : '--'}
</div>
<button type="button" onClick={decHour} style={btnStyle}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<ChevronDown size={16} />
</button>
</div>
<span style={{ fontSize: 22, fontWeight: 700, color: 'var(--text-faint)', marginTop: -2 }}>:</span>
{/* Minuten */}
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2 }}>
<button type="button" onClick={incMin} style={btnStyle}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<ChevronUp size={16} />
</button>
<div style={{
width: 44, height: 40, display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 22, fontWeight: 700, color: 'var(--text-primary)',
background: 'var(--bg-hover)', borderRadius: 8,
fontVariantNumeric: 'tabular-nums',
}}>
{minute !== null ? String(minute).padStart(2, '0') : '--'}
</div>
<button type="button" onClick={decMin} style={btnStyle}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<ChevronDown size={16} />
</button>
</div>
{/* AM/PM Toggle */}
{is12h && hour !== null && (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2, marginLeft: 4 }}>
<button type="button" onClick={() => { if (hour < 12) update(hour + 12, minute ?? 0); else update(hour - 12, minute ?? 0) }} style={btnStyle}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<ChevronUp size={16} />
</button>
<div style={{
width: 36, height: 40, display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 14, fontWeight: 700, color: 'var(--text-primary)',
background: 'var(--bg-hover)', borderRadius: 8,
}}>
{hour >= 12 ? 'PM' : 'AM'}
</div>
<button type="button" onClick={() => { if (hour < 12) update(hour + 12, minute ?? 0); else update(hour - 12, minute ?? 0) }} style={btnStyle}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<ChevronDown size={16} />
</button>
</div>
)}
{/* Clear */}
{value && (
<button type="button" onClick={() => { onChange(''); setOpen(false) }}
style={{ ...btnStyle, marginLeft: 4, fontSize: 11, color: 'var(--text-faint)', padding: '4px 6px' }}
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
</button>
)}
</div>,
document.body
)}
<style>{`@keyframes selectIn { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: translateY(0); } }`}</style>
</div>
)
}

View File

@@ -0,0 +1,96 @@
import React, { useEffect, useCallback, useRef } from 'react'
import { X } from 'lucide-react'
const sizeClasses = {
sm: 'max-w-sm',
md: 'max-w-md',
lg: 'max-w-lg',
xl: 'max-w-2xl',
'2xl': 'max-w-4xl',
}
export default function Modal({
isOpen,
onClose,
title,
children,
size = 'md',
footer,
hideCloseButton = false,
}) {
const handleEsc = useCallback((e) => {
if (e.key === 'Escape') onClose()
}, [onClose])
useEffect(() => {
if (isOpen) {
document.addEventListener('keydown', handleEsc)
document.body.style.overflow = 'hidden'
}
return () => {
document.removeEventListener('keydown', handleEsc)
document.body.style.overflow = ''
}
}, [isOpen, handleEsc])
const mouseDownTarget = useRef(null)
if (!isOpen) return null
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center px-4 modal-backdrop"
style={{ backgroundColor: 'rgba(15, 23, 42, 0.5)', paddingTop: 70, paddingBottom: 20 }}
onMouseDown={e => { mouseDownTarget.current = e.target }}
onClick={e => {
if (e.target === e.currentTarget && mouseDownTarget.current === e.currentTarget) onClose()
mouseDownTarget.current = null
}}
>
<div
className={`
rounded-2xl shadow-2xl w-full ${sizeClasses[size] || sizeClasses.md}
flex flex-col max-h-[90vh]
animate-in fade-in zoom-in-95 duration-200
`}
style={{
animation: 'modalIn 0.2s ease-out forwards',
background: 'var(--bg-card)',
}}
onClick={e => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between p-6" style={{ borderBottom: '1px solid var(--border-secondary)' }}>
<h2 className="text-lg font-semibold" style={{ color: 'var(--text-primary)' }}>{title}</h2>
{!hideCloseButton && (
<button
onClick={onClose}
className="p-2 rounded-lg text-slate-400 hover:text-slate-600 hover:bg-slate-100 transition-colors"
>
<X className="w-5 h-5" />
</button>
)}
</div>
{/* Body */}
<div className="flex-1 overflow-y-auto p-6">
{children}
</div>
{/* Footer */}
{footer && (
<div className="p-6" style={{ borderTop: '1px solid var(--border-secondary)' }}>
{footer}
</div>
)}
</div>
<style>{`
@keyframes modalIn {
from { opacity: 0; transform: scale(0.95) translateY(-10px); }
to { opacity: 1; transform: scale(1) translateY(0); }
}
`}</style>
</div>
)
}

View File

@@ -0,0 +1,60 @@
import React, { useState, useEffect } from 'react'
import { mapsApi } from '../../api/client'
import { getCategoryIcon } from './categoryIcons'
const googlePhotoCache = new Map()
export default function PlaceAvatar({ place, size = 32, category }) {
const [photoSrc, setPhotoSrc] = useState(place.image_url || null)
useEffect(() => {
if (place.image_url) { setPhotoSrc(place.image_url); return }
if (!place.google_place_id) return
if (googlePhotoCache.has(place.google_place_id)) {
setPhotoSrc(googlePhotoCache.get(place.google_place_id))
return
}
mapsApi.placePhoto(place.google_place_id)
.then(data => {
if (data.photoUrl) {
googlePhotoCache.set(place.google_place_id, data.photoUrl)
setPhotoSrc(data.photoUrl)
}
})
.catch(() => {})
}, [place.id, place.image_url, place.google_place_id])
const bgColor = category?.color || '#6366f1'
const IconComp = getCategoryIcon(category?.icon)
const iconSize = Math.round(size * 0.46)
const containerStyle = {
width: size, height: size,
borderRadius: '50%',
overflow: 'hidden',
flexShrink: 0,
backgroundColor: bgColor,
display: 'flex', alignItems: 'center', justifyContent: 'center',
}
if (photoSrc) {
return (
<div style={containerStyle}>
<img
src={photoSrc}
alt={place.name}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
onError={() => setPhotoSrc(null)}
/>
</div>
)
}
return (
<div style={containerStyle}>
<IconComp size={iconSize} strokeWidth={1.8} color="rgba(255,255,255,0.92)" />
</div>
)
}

View File

@@ -0,0 +1,95 @@
import React, { createContext, useContext, useState, useCallback, useEffect } from 'react'
import { CheckCircle, XCircle, AlertCircle, Info, X } from 'lucide-react'
const ToastContext = createContext(null)
let toastIdCounter = 0
export function ToastContainer() {
const [toasts, setToasts] = useState([])
const addToast = useCallback((message, type = 'info', duration = 3000) => {
const id = ++toastIdCounter
setToasts(prev => [...prev, { id, message, type, duration, removing: false }])
if (duration > 0) {
setTimeout(() => {
setToasts(prev => prev.map(t => t.id === id ? { ...t, removing: true } : t))
setTimeout(() => {
setToasts(prev => prev.filter(t => t.id !== id))
}, 300)
}, duration)
}
return id
}, [])
const removeToast = useCallback((id) => {
setToasts(prev => prev.map(t => t.id === id ? { ...t, removing: true } : t))
setTimeout(() => {
setToasts(prev => prev.filter(t => t.id !== id))
}, 300)
}, [])
// Make addToast globally accessible
useEffect(() => {
window.__addToast = addToast
return () => { delete window.__addToast }
}, [addToast])
const icons = {
success: <CheckCircle className="w-5 h-5 text-emerald-500 flex-shrink-0" />,
error: <XCircle className="w-5 h-5 text-red-500 flex-shrink-0" />,
warning: <AlertCircle className="w-5 h-5 text-amber-500 flex-shrink-0" />,
info: <Info className="w-5 h-5 text-blue-500 flex-shrink-0" />,
}
const bgColors = {
success: 'bg-white border-l-4 border-emerald-500',
error: 'bg-white border-l-4 border-red-500',
warning: 'bg-white border-l-4 border-amber-500',
info: 'bg-white border-l-4 border-blue-500',
}
return (
<div className="fixed top-4 right-4 z-[9999] flex flex-col gap-2 max-w-sm w-full pointer-events-none">
{toasts.map(toast => (
<div
key={toast.id}
className={`
${bgColors[toast.type] || bgColors.info}
${toast.removing ? 'toast-exit' : 'toast-enter'}
flex items-start gap-3 p-4 rounded-lg shadow-lg pointer-events-auto
min-w-0
`}
>
{icons[toast.type] || icons.info}
<p className="text-sm text-slate-700 flex-1 leading-relaxed">{toast.message}</p>
<button
onClick={() => removeToast(toast.id)}
className="text-slate-400 hover:text-slate-600 transition-colors flex-shrink-0"
>
<X className="w-4 h-4" />
</button>
</div>
))}
</div>
)
}
export const useToast = () => {
const show = useCallback((message, type, duration) => {
if (window.__addToast) {
window.__addToast(message, type, duration)
}
}, [])
return {
success: (message, duration) => show(message, 'success', duration),
error: (message, duration) => show(message, 'error', duration),
warning: (message, duration) => show(message, 'warning', duration),
info: (message, duration) => show(message, 'info', duration),
}
}
export default useToast

View File

@@ -0,0 +1,43 @@
import {
MapPin, Building2, BedDouble, UtensilsCrossed, Landmark, ShoppingBag,
Bus, Train, Car, Plane, Ship, Bike,
Activity, Dumbbell, Mountain, Tent, Anchor,
Coffee, Beer, Wine, Utensils,
Camera, Music, Theater, Ticket,
TreePine, Waves, Leaf, Flower2, Sun,
Globe, Compass, Flag, Navigation, Map,
Church, Library, Store, Home, Cross,
Heart, Star, CreditCard, Wifi,
Luggage, Backpack, Zap,
} from 'lucide-react'
export const CATEGORY_ICON_MAP = {
MapPin, Building2, BedDouble, UtensilsCrossed, Landmark, ShoppingBag,
Bus, Train, Car, Plane, Ship, Bike,
Activity, Dumbbell, Mountain, Tent, Anchor,
Coffee, Beer, Wine, Utensils,
Camera, Music, Theater, Ticket,
TreePine, Waves, Leaf, Flower2, Sun,
Globe, Compass, Flag, Navigation, Map,
Church, Library, Store, Home, Cross,
Heart, Star, CreditCard, Wifi,
Luggage, Backpack, Zap,
}
export const ICON_LABELS = {
MapPin: 'Pin', Building2: 'Gebäude', BedDouble: 'Hotel', UtensilsCrossed: 'Restaurant',
Landmark: 'Sehenswürdigkeit', ShoppingBag: 'Shopping', Bus: 'Bus', Train: 'Zug',
Car: 'Auto', Plane: 'Flugzeug', Ship: 'Schiff', Bike: 'Fahrrad',
Activity: 'Aktivität', Dumbbell: 'Fitness', Mountain: 'Berg', Tent: 'Camping',
Anchor: 'Hafen', Coffee: 'Café', Beer: 'Bar', Wine: 'Wein', Utensils: 'Essen',
Camera: 'Foto', Music: 'Musik', Theater: 'Theater', Ticket: 'Events',
TreePine: 'Natur', Waves: 'Strand', Leaf: 'Grün', Flower2: 'Garten', Sun: 'Sonne',
Globe: 'Welt', Compass: 'Erkundung', Flag: 'Flagge', Navigation: 'Navigation', Map: 'Karte',
Church: 'Kirche', Library: 'Museum', Store: 'Markt', Home: 'Unterkunft', Cross: 'Medizin',
Heart: 'Favorit', Star: 'Top', CreditCard: 'Bank', Wifi: 'Internet',
Luggage: 'Gepäck', Backpack: 'Rucksack', Zap: 'Abenteuer',
}
export function getCategoryIcon(iconName) {
return CATEGORY_ICON_MAP[iconName] || MapPin
}

View File

@@ -0,0 +1,36 @@
import React, { createContext, useContext, useMemo } from 'react'
import { useSettingsStore } from '../store/settingsStore'
import de from './translations/de'
import en from './translations/en'
const translations = { de, en }
const TranslationContext = createContext({ t: (k) => k, language: 'de', locale: 'de-DE' })
export function TranslationProvider({ children }) {
const language = useSettingsStore(s => s.settings.language) || 'de'
const value = useMemo(() => {
const strings = translations[language] || translations.de
const fallback = translations.de
function t(key, params) {
let val = strings[key] ?? fallback[key] ?? key
// Arrays/Objects direkt zurückgeben (z.B. Vorschläge-Liste)
if (typeof val !== 'string') return val
if (params) {
Object.entries(params).forEach(([k, v]) => {
val = val.replace(new RegExp(`\\{${k}\\}`, 'g'), v)
})
}
return val
}
return { t, language, locale: language === 'en' ? 'en-US' : 'de-DE' }
}, [language])
return <TranslationContext.Provider value={value}>{children}</TranslationContext.Provider>
}
export function useTranslation() {
return useContext(TranslationContext)
}

1
client/src/i18n/index.js Normal file
View File

@@ -0,0 +1 @@
export { TranslationProvider, useTranslation } from './TranslationContext'

View File

@@ -0,0 +1,567 @@
const de = {
// Allgemein
'common.save': 'Speichern',
'common.cancel': 'Abbrechen',
'common.delete': 'Löschen',
'common.edit': 'Bearbeiten',
'common.add': 'Hinzufügen',
'common.loading': 'Laden...',
'common.error': 'Fehler',
'common.back': 'Zurück',
'common.all': 'Alle',
'common.close': 'Schließen',
'common.open': 'Öffnen',
'common.upload': 'Hochladen',
'common.search': 'Suchen',
'common.confirm': 'Bestätigen',
'common.ok': 'OK',
'common.yes': 'Ja',
'common.no': 'Nein',
'common.or': 'oder',
'common.none': 'Keine',
'common.rename': 'Umbenennen',
'common.name': 'Name',
'common.email': 'E-Mail',
'common.password': 'Passwort',
'common.saving': 'Speichern...',
'common.update': 'Aktualisieren',
'common.change': 'Ändern',
'common.uploading': 'Hochladen…',
// Navbar
'nav.trip': 'Reise',
'nav.share': 'Teilen',
'nav.settings': 'Einstellungen',
'nav.admin': 'Admin',
'nav.logout': 'Abmelden',
'nav.lightMode': 'Heller Modus',
'nav.darkMode': 'Dunkler Modus',
'nav.administrator': 'Administrator',
// Dashboard
'dashboard.title': 'Meine Reisen',
'dashboard.subtitle.loading': 'Reisen werden geladen...',
'dashboard.subtitle.trips': '{count} Reisen ({archived} archiviert)',
'dashboard.subtitle.empty': 'Starte deine erste Reise',
'dashboard.subtitle.activeOne': '{count} aktive Reise',
'dashboard.subtitle.activeMany': '{count} aktive Reisen',
'dashboard.subtitle.archivedSuffix': ' · {count} archiviert',
'dashboard.newTrip': 'Neue Reise',
'dashboard.emptyTitle': 'Noch keine Reisen',
'dashboard.emptyText': 'Erstelle deine erste Reise und beginne mit der Planung von Orten, Tagesabläufen und Packlisten.',
'dashboard.emptyButton': 'Erste Reise erstellen',
'dashboard.nextTrip': 'Nächste Reise',
'dashboard.shared': 'Geteilt',
'dashboard.sharedBy': 'Geteilt von {name}',
'dashboard.days': 'Tage',
'dashboard.places': 'Orte',
'dashboard.archive': 'Archivieren',
'dashboard.restore': 'Wiederherstellen',
'dashboard.archived': 'Archiviert',
'dashboard.status.ongoing': 'Laufend',
'dashboard.status.today': 'Heute',
'dashboard.status.tomorrow': 'Morgen',
'dashboard.status.past': 'Vergangen',
'dashboard.status.daysLeft': 'Noch {count} Tage',
'dashboard.toast.loadError': 'Fehler beim Laden der Reisen',
'dashboard.toast.created': 'Reise erfolgreich erstellt!',
'dashboard.toast.createError': 'Fehler beim Erstellen',
'dashboard.toast.updated': 'Reise aktualisiert!',
'dashboard.toast.updateError': 'Fehler beim Aktualisieren',
'dashboard.toast.deleted': 'Reise gelöscht',
'dashboard.toast.deleteError': 'Fehler beim Löschen',
'dashboard.toast.archived': 'Reise archiviert',
'dashboard.toast.archiveError': 'Fehler beim Archivieren',
'dashboard.toast.restored': 'Reise wiederhergestellt',
'dashboard.toast.restoreError': 'Fehler beim Wiederherstellen',
'dashboard.confirm.delete': 'Reise "{title}" löschen? Alle Orte und Pläne werden unwiderruflich gelöscht.',
'dashboard.editTrip': 'Reise bearbeiten',
'dashboard.createTrip': 'Neue Reise erstellen',
'dashboard.tripTitle': 'Titel',
'dashboard.tripTitlePlaceholder': 'z.B. Sommer in Japan',
'dashboard.tripDescription': 'Beschreibung',
'dashboard.tripDescriptionPlaceholder': 'Worum geht es bei dieser Reise?',
'dashboard.startDate': 'Startdatum',
'dashboard.endDate': 'Enddatum',
'dashboard.noDateHint': 'Kein Datum gesetzt — es werden 7 Standardtage erstellt. Du kannst das jederzeit ändern.',
'dashboard.coverImage': 'Titelbild',
'dashboard.addCoverImage': 'Titelbild hinzufügen',
'dashboard.coverSaved': 'Titelbild gespeichert',
'dashboard.coverUploadError': 'Fehler beim Hochladen',
'dashboard.coverRemoveError': 'Fehler beim Entfernen',
'dashboard.titleRequired': 'Titel ist erforderlich',
'dashboard.endDateError': 'Enddatum muss nach dem Startdatum liegen',
// Settings
'settings.title': 'Einstellungen',
'settings.subtitle': 'Konfigurieren Sie Ihre persönlichen Einstellungen',
'settings.map': 'Karte',
'settings.mapTemplate': 'Karten-Vorlage',
'settings.mapTemplatePlaceholder.select': 'Vorlage auswählen...',
'settings.mapDefaultHint': 'Leer lassen für OpenStreetMap (Standard)',
'settings.mapTemplatePlaceholder': 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
'settings.mapHint': 'URL-Template für die Kartenkacheln',
'settings.latitude': 'Breitengrad',
'settings.longitude': 'Längengrad',
'settings.saveMap': 'Karte speichern',
'settings.apiKeys': 'API-Schlüssel',
'settings.mapsKey': 'Google Maps API-Schlüssel',
'settings.mapsKeyHint': 'Für Ortsuche. Benötigt Places API (New). Erhalten unter console.cloud.google.com',
'settings.weatherKey': 'OpenWeatherMap API-Schlüssel',
'settings.weatherKeyHint': 'Für Wetterdaten. Kostenlos unter openweathermap.org/api',
'settings.keyPlaceholder': 'Schlüssel eingeben...',
'settings.configured': 'Konfiguriert',
'settings.saveKeys': 'Schlüssel speichern',
'settings.display': 'Darstellung',
'settings.colorMode': 'Farbmodus',
'settings.light': 'Hell',
'settings.dark': 'Dunkel',
'settings.language': 'Sprache',
'settings.temperature': 'Temperatureinheit',
'settings.timeFormat': 'Zeitformat',
'settings.account': 'Konto',
'settings.username': 'Benutzername',
'settings.email': 'E-Mail',
'settings.role': 'Rolle',
'settings.roleAdmin': 'Administrator',
'settings.roleUser': 'Benutzer',
'settings.saveProfile': 'Profil speichern',
'settings.toast.mapSaved': 'Karteneinstellungen gespeichert',
'settings.toast.keysSaved': 'API-Schlüssel gespeichert',
'settings.toast.displaySaved': 'Anzeigeeinstellungen gespeichert',
'settings.toast.profileSaved': 'Profil aktualisiert',
'settings.uploadAvatar': 'Profilbild hochladen',
'settings.removeAvatar': 'Profilbild entfernen',
'settings.avatarUploaded': 'Profilbild aktualisiert',
'settings.avatarRemoved': 'Profilbild entfernt',
'settings.avatarError': 'Fehler beim Hochladen',
// Login
'login.error': 'Anmeldung fehlgeschlagen. Bitte Zugangsdaten prüfen.',
'login.tagline': 'Deine Reisen.\nDein Plan.',
'login.description': 'Plane Reisen mit interaktiven Karten, Tagesabläufen und smarten Checklisten.',
'login.features.places': 'Orte',
'login.features.schedule': 'Tagesplan',
'login.features.packing': 'Packliste',
'login.title': 'Anmelden',
'login.subtitle': 'Willkommen zurück',
'login.signingIn': 'Anmelden…',
'login.signIn': 'Anmelden',
'login.createAdmin': 'Admin-Konto erstellen',
'login.createAdminHint': 'Erstelle das erste Admin-Konto für NOMAD.',
'login.createAccount': 'Konto erstellen',
'login.createAccountHint': 'Neues Konto registrieren.',
'login.creating': 'Erstelle…',
'login.noAccount': 'Noch kein Konto?',
'login.hasAccount': 'Bereits ein Konto?',
'login.register': 'Registrieren',
'login.username': 'Benutzername',
// Admin
'admin.title': 'Administration',
'admin.subtitle': 'Benutzerverwaltung und Systemeinstellungen',
'admin.tabs.users': 'Benutzer',
'admin.tabs.categories': 'Kategorien',
'admin.tabs.backup': 'Backup',
'admin.stats.users': 'Benutzer',
'admin.stats.trips': 'Reisen',
'admin.stats.places': 'Orte',
'admin.stats.photos': 'Fotos',
'admin.stats.files': 'Dateien',
'admin.table.user': 'Benutzer',
'admin.table.email': 'E-Mail',
'admin.table.role': 'Rolle',
'admin.table.created': 'Erstellt',
'admin.table.actions': 'Aktionen',
'admin.you': '(Du)',
'admin.editUser': 'Benutzer bearbeiten',
'admin.newPassword': 'Neues Passwort',
'admin.newPasswordHint': 'Leer lassen, um das Passwort nicht zu ändern',
'admin.deleteUser': 'Benutzer "{name}" löschen? Alle Reisen werden unwiderruflich gelöscht.',
'admin.deleteUserTitle': 'Benutzer löschen',
'admin.newPasswordPlaceholder': 'Neues Passwort eingeben…',
'admin.toast.loadError': 'Fehler beim Laden der Admin-Daten',
'admin.toast.userUpdated': 'Benutzer aktualisiert',
'admin.toast.updateError': 'Fehler beim Aktualisieren',
'admin.toast.userDeleted': 'Benutzer gelöscht',
'admin.toast.deleteError': 'Fehler beim Löschen',
'admin.toast.cannotDeleteSelf': 'Eigenes Konto kann nicht gelöscht werden',
'admin.tabs.settings': 'Einstellungen',
'admin.allowRegistration': 'Registrierung erlauben',
'admin.allowRegistrationHint': 'Neue Benutzer können sich selbst registrieren',
'admin.apiKeys': 'API-Schlüssel',
'admin.mapsKey': 'Google Maps API Key',
'admin.mapsKeyHint': 'Für Ortsuche benötigt. Erstellen unter console.cloud.google.com',
'admin.weatherKey': 'OpenWeatherMap API Key',
'admin.weatherKeyHint': 'Für Wetterdaten. Kostenlos unter openweathermap.org',
'admin.validateKey': 'Test',
'admin.keyValid': 'Verbunden',
'admin.keyInvalid': 'Ungültig',
'admin.keySaved': 'API-Schlüssel gespeichert',
// Trip Planner
'trip.tabs.plan': 'Planung',
'trip.tabs.reservations': 'Reservierungen',
'trip.tabs.packing': 'Packliste',
'trip.tabs.packingShort': 'Packen',
'trip.tabs.budget': 'Budget',
'trip.tabs.files': 'Dateien',
'trip.loading': 'Reise wird geladen...',
'trip.mobilePlan': 'Planung',
'trip.mobilePlaces': 'Orte',
'trip.toast.placeUpdated': 'Ort aktualisiert',
'trip.toast.placeAdded': 'Ort hinzugefügt',
'trip.toast.placeDeleted': 'Ort gelöscht',
'trip.toast.selectDay': 'Bitte wähle zuerst einen Tag aus',
'trip.toast.assignedToDay': 'Ort wurde dem Tag zugewiesen',
'trip.toast.reorderError': 'Fehler beim Sortieren',
'trip.toast.reservationUpdated': 'Reservierung aktualisiert',
'trip.toast.reservationAdded': 'Reservierung hinzugefügt',
'trip.toast.deleted': 'Gelöscht',
'trip.confirm.deletePlace': 'Möchtest du diesen Ort wirklich löschen?',
// Day Plan Sidebar
'dayplan.transport.car': 'Auto',
'dayplan.transport.walk': 'Zu Fuß',
'dayplan.transport.bike': 'Fahrrad',
'dayplan.emptyDay': 'Keine Orte für diesen Tag geplant',
'dayplan.addNote': 'Notiz hinzufügen',
'dayplan.editNote': 'Notiz bearbeiten',
'dayplan.noteAdd': 'Notiz hinzufügen',
'dayplan.noteEdit': 'Notiz bearbeiten',
'dayplan.noteTitle': 'Notiz',
'dayplan.noteSubtitle': 'Tagesnotiz',
'dayplan.totalCost': 'Gesamtkosten',
'dayplan.days': 'Tage',
'dayplan.dayN': 'Tag {n}',
'dayplan.calculating': 'Berechne...',
'dayplan.route': 'Route',
'dayplan.optimize': 'Optimieren',
'dayplan.optimized': 'Route optimiert',
'dayplan.routeError': 'Fehler bei der Routenberechnung',
'dayplan.confirmed': 'Bestätigt',
'dayplan.pendingRes': 'Ausstehend',
'dayplan.pdf': 'PDF',
'dayplan.pdfTooltip': 'Tagesplan als PDF exportieren',
'dayplan.pdfError': 'Fehler beim PDF-Export',
// Places Sidebar
'places.addPlace': 'Ort hinzufügen',
'places.all': 'Alle',
'places.unplanned': 'Ungeplant',
'places.search': 'Orte suchen...',
'places.allCategories': 'Alle Kategorien',
'places.count': '{count} Orte',
'places.countSingular': '1 Ort',
'places.allPlanned': 'Alle Orte sind eingeplant',
'places.noneFound': 'Keine Orte gefunden',
'places.editPlace': 'Ort bearbeiten',
'places.formName': 'Name',
'places.formNamePlaceholder': 'z.B. Eiffelturm',
'places.formDescription': 'Beschreibung',
'places.formDescriptionPlaceholder': 'Kurze Beschreibung...',
'places.formAddress': 'Adresse',
'places.formAddressPlaceholder': 'Straße, Stadt, Land',
'places.formLat': 'Breitengrad (z.B. 48.8566)',
'places.formLng': 'Längengrad (z.B. 2.3522)',
'places.formCategory': 'Kategorie',
'places.noCategory': 'Keine Kategorie',
'places.categoryNamePlaceholder': 'Kategoriename',
'places.formTime': 'Uhrzeit',
'places.formWebsite': 'Website',
'places.formNotesPlaceholder': 'Persönliche Notizen...',
'places.formReservation': 'Reservierung',
'places.reservationNotesPlaceholder': 'Reservierungsnotizen, Bestätigungsnummer...',
'places.mapsSearchPlaceholder': 'Google Maps suchen...',
'places.mapsSearchError': 'Google Maps Suche fehlgeschlagen. Bitte API-Schlüssel in den Einstellungen hinterlegen.',
'places.categoryCreateError': 'Fehler beim Erstellen der Kategorie',
'places.nameRequired': 'Bitte einen Namen eingeben',
'places.saveError': 'Fehler beim Speichern',
'places.transport.walking': '🚶 Zu Fuß',
'places.transport.driving': '🚗 Auto',
'places.transport.cycling': '🚲 Fahrrad',
'places.transport.transit': '🚌 ÖPNV',
// Place Inspector
'inspector.opened': 'Geöffnet',
'inspector.closed': 'Geschlossen',
'inspector.openingHours': 'Öffnungszeiten',
'inspector.showHours': 'Öffnungszeiten anzeigen',
'inspector.files': 'Dateien',
'inspector.filesCount': '{count} Dateien',
'inspector.removeFromDay': 'Vom Tag entfernen',
'inspector.addToDay': 'Zum Tag hinzufügen',
'inspector.confirmedRes': 'Bestätigte Reservierung',
'inspector.pendingRes': 'Ausstehende Reservierung',
'inspector.google': 'In Google Maps öffnen',
'inspector.website': 'Webseite öffnen',
// Reservations
'reservations.title': 'Reservierungen',
'reservations.empty': 'Keine Reservierungen vorhanden',
'reservations.emptyHint': 'Füge Reservierungen für Flüge, Hotels und mehr hinzu',
'reservations.add': 'Reservierung hinzufügen',
'reservations.addManual': 'Manuelle Buchung',
'reservations.placeHint': 'Tipp: Buchungen werden am besten direkt über einen angelegten Ort erstellt, um sie mit dem Tagesplan zu verknüpfen.',
'reservations.confirmed': 'Bestätigt',
'reservations.pending': 'Ausstehend',
'reservations.summary': '{confirmed} bestätigt, {pending} ausstehend',
'reservations.fromPlan': 'Aus Planung',
'reservations.showFiles': 'Dateien anzeigen',
'reservations.editTitle': 'Reservierung bearbeiten',
'reservations.status': 'Status',
'reservations.datetime': 'Datum & Uhrzeit',
'reservations.timeAlt': 'Uhrzeit (alternativ, z.B. 19:30)',
'reservations.notes': 'Notizen',
'reservations.notesPlaceholder': 'Zusätzliche Notizen...',
'reservations.type.flight': 'Flug',
'reservations.type.hotel': 'Hotel',
'reservations.type.restaurant': 'Restaurant',
'reservations.type.train': 'Zug',
'reservations.type.car': 'Mietwagen',
'reservations.type.cruise': 'Kreuzfahrt',
'reservations.type.event': 'Veranstaltung',
'reservations.type.tour': 'Tour',
'reservations.type.other': 'Sonstiges',
'reservations.confirm.delete': 'Möchtest du die Reservierung "{name}" wirklich löschen?',
'reservations.toast.updated': 'Reservierung aktualisiert',
'reservations.toast.removed': 'Reservierung gelöscht',
'reservations.toast.saveError': 'Fehler beim Speichern',
'reservations.toast.updateError': 'Fehler beim Aktualisieren',
'reservations.toast.deleteError': 'Fehler beim Löschen',
'reservations.confirm.remove': 'Reservierung für "{name}" entfernen?',
'reservations.toast.fileUploaded': 'Datei hochgeladen',
'reservations.toast.uploadError': 'Fehler beim Hochladen',
'reservations.newTitle': 'Neue Buchung',
'reservations.bookingType': 'Art der Buchung',
'reservations.titleLabel': 'Titel',
'reservations.titlePlaceholder': 'z.B. Lufthansa LH123, Hotel Adlon, ...',
'reservations.locationAddress': 'Ort / Adresse',
'reservations.locationPlaceholder': 'Adresse, Flughafen, Hotel...',
'reservations.confirmationCode': 'Bestätigungsnummer / Buchungscode',
'reservations.confirmationPlaceholder': 'z.B. ABC12345',
'reservations.day': 'Tag',
'reservations.noDay': 'Kein Tag',
'reservations.place': 'Ort',
'reservations.noPlace': 'Kein Ort',
'reservations.pendingSave': 'wird gespeichert…',
'reservations.uploading': 'Wird hochgeladen...',
'reservations.attachFile': 'Datei anhängen',
// Budget
'budget.title': 'Budget',
'budget.emptyTitle': 'Noch kein Budget erstellt',
'budget.emptyText': 'Erstelle Kategorien und Einträge, um dein Reisebudget zu planen',
'budget.emptyPlaceholder': 'Kategoriename eingeben...',
'budget.createCategory': 'Kategorie erstellen',
'budget.category': 'Kategorie',
'budget.categoryName': 'Kategoriename',
'budget.table.name': 'Name',
'budget.table.total': 'Gesamt',
'budget.table.persons': 'Personen',
'budget.table.days': 'Tage',
'budget.table.perPerson': 'Pro Person',
'budget.table.perDay': 'Pro Tag',
'budget.table.perPersonDay': 'Pro Person/Tag',
'budget.table.note': 'Notiz',
'budget.newEntry': 'Neuer Eintrag',
'budget.defaultEntry': 'Neuer Eintrag',
'budget.defaultCategory': 'Neue Kategorie',
'budget.total': 'Gesamt',
'budget.totalBudget': 'Gesamtbudget',
'budget.byCategory': 'Nach Kategorie',
'budget.editTooltip': 'Klicken zum Bearbeiten',
'budget.confirm.deleteCategory': 'Möchtest du die Kategorie "{name}" mit {count} Einträgen wirklich löschen?',
'budget.deleteCategory': 'Kategorie löschen',
// Files
'files.title': 'Dateien',
'files.count': '{count} Dateien',
'files.countSingular': '1 Datei',
'files.uploaded': '{count} hochgeladen',
'files.uploadError': 'Fehler beim Hochladen',
'files.dropzone': 'Dateien hier ablegen',
'files.dropzoneHint': 'oder klicken zum Auswählen',
'files.uploading': 'Wird hochgeladen...',
'files.filterAll': 'Alle',
'files.filterPdf': 'PDFs',
'files.filterImages': 'Bilder',
'files.filterDocs': 'Dokumente',
'files.empty': 'Keine Dateien vorhanden',
'files.emptyHint': 'Lade Dateien hoch, um sie mit deiner Reise zu verknüpfen',
'files.openTab': 'In neuem Tab öffnen',
'files.confirm.delete': 'Möchtest du diese Datei wirklich löschen?',
'files.toast.deleted': 'Datei gelöscht',
'files.toast.deleteError': 'Fehler beim Löschen der Datei',
'files.sourcePlan': 'Tagesplan',
'files.sourceBooking': 'Buchung',
// Packing
'packing.title': 'Packliste',
'packing.empty': 'Packliste ist leer',
'packing.progress': '{packed} von {total} gepackt ({percent}%)',
'packing.clearChecked': '{count} abgehakte entfernen',
'packing.suggestions': 'Vorschläge',
'packing.suggestionsTitle': 'Vorschläge hinzufügen',
'packing.allSuggested': 'Alle Vorschläge hinzugefügt',
'packing.allPacked': 'Alles gepackt!',
'packing.addPlaceholder': 'Neuen Gegenstand hinzufügen...',
'packing.categoryPlaceholder': 'Kategorie...',
'packing.filterAll': 'Alle',
'packing.filterOpen': 'Offen',
'packing.filterDone': 'Erledigt',
'packing.emptyTitle': 'Packliste ist leer',
'packing.emptyHint': 'Füge Gegenstände hinzu oder nutze die Vorschläge',
'packing.emptyFiltered': 'Keine Gegenstände in diesem Filter',
'packing.menuRename': 'Umbenennen',
'packing.menuCheckAll': 'Alle abhaken',
'packing.menuUncheckAll': 'Alle Haken entfernen',
'packing.menuDeleteCat': 'Kategorie löschen',
'packing.changeCategory': 'Kategorie ändern',
'packing.confirm.clearChecked': 'Möchtest du {count} abgehakte Gegenstände wirklich entfernen?',
'packing.confirm.deleteCat': 'Möchtest du die Kategorie "{name}" mit {count} Gegenständen wirklich löschen?',
'packing.defaultCategory': 'Sonstiges',
'packing.toast.saveError': 'Fehler beim Speichern',
'packing.toast.deleteError': 'Fehler beim Löschen',
'packing.toast.renameError': 'Fehler beim Umbenennen',
'packing.toast.addError': 'Fehler beim Hinzufügen',
// Packing suggestions
'packing.suggestions.items': [
{ name: 'Reisepass', category: 'Dokumente' },
{ name: 'Personalausweis', category: 'Dokumente' },
{ name: 'Reiseversicherung', category: 'Dokumente' },
{ name: 'Flugtickets', category: 'Dokumente' },
{ name: 'Kreditkarte', category: 'Finanzen' },
{ name: 'Bargeld', category: 'Finanzen' },
{ name: 'Visum', category: 'Dokumente' },
{ name: 'T-Shirts', category: 'Kleidung' },
{ name: 'Hosen', category: 'Kleidung' },
{ name: 'Unterwäsche', category: 'Kleidung' },
{ name: 'Socken', category: 'Kleidung' },
{ name: 'Jacke', category: 'Kleidung' },
{ name: 'Schlafkleidung', category: 'Kleidung' },
{ name: 'Badekleidung', category: 'Kleidung' },
{ name: 'Regenjacke', category: 'Kleidung' },
{ name: 'Bequeme Schuhe', category: 'Kleidung' },
{ name: 'Zahnbürste', category: 'Hygiene' },
{ name: 'Zahnpasta', category: 'Hygiene' },
{ name: 'Shampoo', category: 'Hygiene' },
{ name: 'Deo', category: 'Hygiene' },
{ name: 'Sonnencreme', category: 'Hygiene' },
{ name: 'Rasierer', category: 'Hygiene' },
{ name: 'Ladegerät', category: 'Elektronik' },
{ name: 'Powerbank', category: 'Elektronik' },
{ name: 'Kopfhörer', category: 'Elektronik' },
{ name: 'Reiseadapter', category: 'Elektronik' },
{ name: 'Kamera', category: 'Elektronik' },
{ name: 'Schmerzmittel', category: 'Gesundheit' },
{ name: 'Pflaster', category: 'Gesundheit' },
{ name: 'Desinfektionsmittel', category: 'Gesundheit' },
],
// Members / Sharing
'members.shareTrip': 'Reise teilen',
'members.inviteUser': 'Benutzer einladen',
'members.selectUser': 'Benutzer auswählen…',
'members.invite': 'Einladen',
'members.allHaveAccess': 'Alle Benutzer haben bereits Zugriff.',
'members.access': 'Zugriff',
'members.person': 'Person',
'members.persons': 'Personen',
'members.you': 'du',
'members.owner': 'Eigentümer',
'members.leaveTrip': 'Reise verlassen',
'members.removeAccess': 'Zugriff entfernen',
'members.confirmLeave': 'Reise verlassen? Du verlierst den Zugriff.',
'members.confirmRemove': 'Zugriff für diesen Benutzer entfernen?',
'members.loadError': 'Fehler beim Laden der Mitglieder',
'members.added': 'hinzugefügt',
'members.addError': 'Fehler beim Hinzufügen',
'members.removed': 'Mitglied entfernt',
'members.removeError': 'Fehler beim Entfernen',
// Categories (Admin)
'categories.title': 'Kategorien',
'categories.subtitle': 'Kategorien für Orte verwalten',
'categories.new': 'Neue Kategorie',
'categories.empty': 'Keine Kategorien vorhanden',
'categories.namePlaceholder': 'Kategoriename',
'categories.icon': 'Symbol',
'categories.color': 'Farbe',
'categories.customColor': 'Eigene Farbe wählen',
'categories.preview': 'Vorschau',
'categories.defaultName': 'Kategorie',
'categories.update': 'Aktualisieren',
'categories.create': 'Erstellen',
'categories.confirm.delete': 'Kategorie löschen? Orte dieser Kategorie werden nicht gelöscht.',
'categories.toast.loadError': 'Fehler beim Laden der Kategorien',
'categories.toast.nameRequired': 'Bitte einen Namen eingeben',
'categories.toast.updated': 'Kategorie aktualisiert',
'categories.toast.created': 'Kategorie erstellt',
'categories.toast.saveError': 'Fehler beim Speichern',
'categories.toast.deleted': 'Kategorie gelöscht',
'categories.toast.deleteError': 'Fehler beim Löschen',
// Backup (Admin)
'backup.title': 'Datensicherung',
'backup.subtitle': 'Datenbank und alle hochgeladenen Dateien',
'backup.refresh': 'Aktualisieren',
'backup.upload': 'Backup hochladen',
'backup.uploading': 'Wird hochgeladen…',
'backup.create': 'Backup erstellen',
'backup.creating': 'Erstelle…',
'backup.empty': 'Noch keine Backups vorhanden',
'backup.createFirst': 'Erstes Backup erstellen',
'backup.download': 'Herunterladen',
'backup.restore': 'Wiederherstellen',
'backup.confirm.restore': 'Backup "{name}" wiederherstellen?\n\nAlle aktuellen Daten werden durch den Backup-Stand ersetzt.',
'backup.confirm.uploadRestore': 'Backup-Datei "{name}" hochladen und wiederherstellen?\n\nAlle aktuellen Daten werden überschrieben.',
'backup.confirm.delete': 'Backup "{name}" löschen?',
'backup.toast.loadError': 'Fehler beim Laden der Backups',
'backup.toast.created': 'Backup erfolgreich erstellt',
'backup.toast.createError': 'Fehler beim Erstellen des Backups',
'backup.toast.restored': 'Backup wiederhergestellt. Seite wird neu geladen…',
'backup.toast.restoreError': 'Fehler beim Wiederherstellen',
'backup.toast.uploadError': 'Fehler beim Hochladen',
'backup.toast.deleted': 'Backup gelöscht',
'backup.toast.deleteError': 'Fehler beim Löschen',
'backup.toast.downloadError': 'Download fehlgeschlagen',
'backup.toast.settingsSaved': 'Auto-Backup Einstellungen gespeichert',
'backup.toast.settingsError': 'Fehler beim Speichern der Einstellungen',
'backup.auto.title': 'Auto-Backup',
'backup.auto.subtitle': 'Automatische Sicherung nach Zeitplan',
'backup.auto.enable': 'Auto-Backup aktivieren',
'backup.auto.enableHint': 'Backups werden automatisch nach dem gewählten Zeitplan erstellt',
'backup.auto.interval': 'Intervall',
'backup.auto.keepLabel': 'Alte Backups löschen nach',
'backup.interval.hourly': 'Stündlich',
'backup.interval.daily': 'Täglich',
'backup.interval.weekly': 'Wöchentlich',
'backup.interval.monthly': 'Monatlich',
'backup.keep.1day': '1 Tag',
'backup.keep.3days': '3 Tage',
'backup.keep.7days': '7 Tage',
'backup.keep.14days': '14 Tage',
'backup.keep.30days': '30 Tage',
'backup.keep.forever': 'Immer behalten',
// PDF
'pdf.travelPlan': 'Reiseplan',
'pdf.planned': 'Eingeplant',
'pdf.costLabel': 'Kosten EUR',
'pdf.preview': 'PDF Vorschau',
'pdf.saveAsPdf': 'Als PDF speichern',
// Dashboard Stats
'stats.countries': 'Länder',
'stats.cities': 'Städte',
'stats.trips': 'Reisen',
'stats.places': 'Orte',
'stats.worldProgress': 'Weltfortschritt',
'stats.visited': 'besucht',
'stats.remaining': 'verbleibend',
'stats.visitedCountries': 'Besuchte Länder',
}
export default de

View File

@@ -0,0 +1,567 @@
const en = {
// Common
'common.save': 'Save',
'common.cancel': 'Cancel',
'common.delete': 'Delete',
'common.edit': 'Edit',
'common.add': 'Add',
'common.loading': 'Loading...',
'common.error': 'Error',
'common.back': 'Back',
'common.all': 'All',
'common.close': 'Close',
'common.open': 'Open',
'common.upload': 'Upload',
'common.search': 'Search',
'common.confirm': 'Confirm',
'common.ok': 'OK',
'common.yes': 'Yes',
'common.no': 'No',
'common.or': 'or',
'common.none': 'None',
'common.rename': 'Rename',
'common.name': 'Name',
'common.email': 'Email',
'common.password': 'Password',
'common.saving': 'Saving...',
'common.update': 'Update',
'common.change': 'Change',
'common.uploading': 'Uploading…',
// Navbar
'nav.trip': 'Trip',
'nav.share': 'Share',
'nav.settings': 'Settings',
'nav.admin': 'Admin',
'nav.logout': 'Log out',
'nav.lightMode': 'Light Mode',
'nav.darkMode': 'Dark Mode',
'nav.administrator': 'Administrator',
// Dashboard
'dashboard.title': 'My Trips',
'dashboard.subtitle.loading': 'Loading trips...',
'dashboard.subtitle.trips': '{count} trips ({archived} archived)',
'dashboard.subtitle.empty': 'Start your first trip',
'dashboard.subtitle.activeOne': '{count} active trip',
'dashboard.subtitle.activeMany': '{count} active trips',
'dashboard.subtitle.archivedSuffix': ' · {count} archived',
'dashboard.newTrip': 'New Trip',
'dashboard.emptyTitle': 'No trips yet',
'dashboard.emptyText': 'Create your first trip and start planning!',
'dashboard.emptyButton': 'Create First Trip',
'dashboard.nextTrip': 'Next Trip',
'dashboard.shared': 'Shared',
'dashboard.sharedBy': 'Shared by {name}',
'dashboard.days': 'Days',
'dashboard.places': 'Places',
'dashboard.archive': 'Archive',
'dashboard.restore': 'Restore',
'dashboard.archived': 'Archived',
'dashboard.status.ongoing': 'Ongoing',
'dashboard.status.today': 'Today',
'dashboard.status.tomorrow': 'Tomorrow',
'dashboard.status.past': 'Past',
'dashboard.status.daysLeft': '{count} days left',
'dashboard.toast.loadError': 'Failed to load trips',
'dashboard.toast.created': 'Trip created successfully!',
'dashboard.toast.createError': 'Failed to create trip',
'dashboard.toast.updated': 'Trip updated!',
'dashboard.toast.updateError': 'Failed to update trip',
'dashboard.toast.deleted': 'Trip deleted',
'dashboard.toast.deleteError': 'Failed to delete trip',
'dashboard.toast.archived': 'Trip archived',
'dashboard.toast.archiveError': 'Failed to archive trip',
'dashboard.toast.restored': 'Trip restored',
'dashboard.toast.restoreError': 'Failed to restore trip',
'dashboard.confirm.delete': 'Delete trip "{title}"? All places and plans will be permanently deleted.',
'dashboard.editTrip': 'Edit Trip',
'dashboard.createTrip': 'Create New Trip',
'dashboard.tripTitle': 'Title',
'dashboard.tripTitlePlaceholder': 'e.g. Summer in Japan',
'dashboard.tripDescription': 'Description',
'dashboard.tripDescriptionPlaceholder': 'What is this trip about?',
'dashboard.startDate': 'Start Date',
'dashboard.endDate': 'End Date',
'dashboard.noDateHint': 'No date set — 7 default days will be created. You can change this anytime.',
'dashboard.coverImage': 'Cover Image',
'dashboard.addCoverImage': 'Add cover image',
'dashboard.coverSaved': 'Cover image saved',
'dashboard.coverUploadError': 'Failed to upload',
'dashboard.coverRemoveError': 'Failed to remove',
'dashboard.titleRequired': 'Title is required',
'dashboard.endDateError': 'End date must be after start date',
// Settings
'settings.title': 'Settings',
'settings.subtitle': 'Configure your personal settings',
'settings.map': 'Map',
'settings.mapTemplate': 'Map Template',
'settings.mapTemplatePlaceholder.select': 'Select template...',
'settings.mapDefaultHint': 'Leave empty for OpenStreetMap (default)',
'settings.mapTemplatePlaceholder': 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
'settings.mapHint': 'URL template for map tiles',
'settings.latitude': 'Latitude',
'settings.longitude': 'Longitude',
'settings.saveMap': 'Save Map',
'settings.apiKeys': 'API Keys',
'settings.mapsKey': 'Google Maps API Key',
'settings.mapsKeyHint': 'For place search. Requires Places API (New). Get at console.cloud.google.com',
'settings.weatherKey': 'OpenWeatherMap API Key',
'settings.weatherKeyHint': 'For weather data. Free at openweathermap.org/api',
'settings.keyPlaceholder': 'Enter key...',
'settings.configured': 'Configured',
'settings.saveKeys': 'Save Keys',
'settings.display': 'Display',
'settings.colorMode': 'Color Mode',
'settings.light': 'Light',
'settings.dark': 'Dark',
'settings.language': 'Language',
'settings.temperature': 'Temperature Unit',
'settings.timeFormat': 'Time Format',
'settings.account': 'Account',
'settings.username': 'Username',
'settings.email': 'Email',
'settings.role': 'Role',
'settings.roleAdmin': 'Administrator',
'settings.roleUser': 'User',
'settings.saveProfile': 'Save Profile',
'settings.toast.mapSaved': 'Map settings saved',
'settings.toast.keysSaved': 'API keys saved',
'settings.toast.displaySaved': 'Display settings saved',
'settings.toast.profileSaved': 'Profile saved',
'settings.uploadAvatar': 'Upload Profile Picture',
'settings.removeAvatar': 'Remove Profile Picture',
'settings.avatarUploaded': 'Profile picture updated',
'settings.avatarRemoved': 'Profile picture removed',
'settings.avatarError': 'Upload failed',
// Login
'login.error': 'Login failed. Please check your credentials.',
'login.tagline': 'Your Trips.\nYour Plan.',
'login.description': 'Plan trips with interactive maps, daily schedules and smart checklists.',
'login.features.places': 'Places',
'login.features.schedule': 'Schedule',
'login.features.packing': 'Packing List',
'login.title': 'Sign In',
'login.subtitle': 'Welcome back',
'login.signingIn': 'Signing in…',
'login.signIn': 'Sign In',
'login.createAdmin': 'Create Admin Account',
'login.createAdminHint': 'Set up the first admin account for NOMAD.',
'login.createAccount': 'Create Account',
'login.createAccountHint': 'Register a new account.',
'login.creating': 'Creating…',
'login.noAccount': "Don't have an account?",
'login.hasAccount': 'Already have an account?',
'login.register': 'Register',
'login.username': 'Username',
// Admin
'admin.title': 'Administration',
'admin.subtitle': 'User management and system settings',
'admin.tabs.users': 'Users',
'admin.tabs.categories': 'Categories',
'admin.tabs.backup': 'Backup',
'admin.stats.users': 'Users',
'admin.stats.trips': 'Trips',
'admin.stats.places': 'Places',
'admin.stats.photos': 'Photos',
'admin.stats.files': 'Files',
'admin.table.user': 'User',
'admin.table.email': 'Email',
'admin.table.role': 'Role',
'admin.table.created': 'Created',
'admin.table.actions': 'Actions',
'admin.you': '(You)',
'admin.editUser': 'Edit User',
'admin.newPassword': 'New Password',
'admin.newPasswordHint': 'Leave empty to keep current password',
'admin.deleteUser': 'Delete user "{name}"? All trips will be permanently deleted.',
'admin.deleteUserTitle': 'Delete user',
'admin.newPasswordPlaceholder': 'Enter new password…',
'admin.toast.loadError': 'Failed to load admin data',
'admin.toast.userUpdated': 'User updated',
'admin.toast.updateError': 'Failed to update',
'admin.toast.userDeleted': 'User deleted',
'admin.toast.deleteError': 'Failed to delete',
'admin.toast.cannotDeleteSelf': 'Cannot delete your own account',
'admin.tabs.settings': 'Settings',
'admin.allowRegistration': 'Allow Registration',
'admin.allowRegistrationHint': 'New users can register themselves',
'admin.apiKeys': 'API Keys',
'admin.mapsKey': 'Google Maps API Key',
'admin.mapsKeyHint': 'Required for place search. Get at console.cloud.google.com',
'admin.weatherKey': 'OpenWeatherMap API Key',
'admin.weatherKeyHint': 'For weather data. Free at openweathermap.org',
'admin.validateKey': 'Test',
'admin.keyValid': 'Connected',
'admin.keyInvalid': 'Invalid',
'admin.keySaved': 'API keys saved',
// Trip Planner
'trip.tabs.plan': 'Plan',
'trip.tabs.reservations': 'Bookings',
'trip.tabs.packing': 'Packing List',
'trip.tabs.packingShort': 'Packing',
'trip.tabs.budget': 'Budget',
'trip.tabs.files': 'Files',
'trip.loading': 'Loading trip...',
'trip.mobilePlan': 'Plan',
'trip.mobilePlaces': 'Places',
'trip.toast.placeUpdated': 'Place updated',
'trip.toast.placeAdded': 'Place added',
'trip.toast.placeDeleted': 'Place deleted',
'trip.toast.selectDay': 'Please select a day first',
'trip.toast.assignedToDay': 'Place assigned to day',
'trip.toast.reorderError': 'Failed to reorder',
'trip.toast.reservationUpdated': 'Reservation updated',
'trip.toast.reservationAdded': 'Reservation added',
'trip.toast.deleted': 'Deleted',
'trip.confirm.deletePlace': 'Are you sure you want to delete this place?',
// Day Plan Sidebar
'dayplan.transport.car': 'Car',
'dayplan.transport.walk': 'Walk',
'dayplan.transport.bike': 'Bike',
'dayplan.emptyDay': 'No places planned for this day',
'dayplan.addNote': 'Add Note',
'dayplan.editNote': 'Edit Note',
'dayplan.noteAdd': 'Add Note',
'dayplan.noteEdit': 'Edit Note',
'dayplan.noteTitle': 'Note',
'dayplan.noteSubtitle': 'Daily Note',
'dayplan.totalCost': 'Total Cost',
'dayplan.days': 'Days',
'dayplan.dayN': 'Day {n}',
'dayplan.calculating': 'Calculating...',
'dayplan.route': 'Route',
'dayplan.optimize': 'Optimize',
'dayplan.optimized': 'Route optimized',
'dayplan.routeError': 'Failed to calculate route',
'dayplan.confirmed': 'Confirmed',
'dayplan.pendingRes': 'Pending',
'dayplan.pdf': 'PDF',
'dayplan.pdfTooltip': 'Export day plan as PDF',
'dayplan.pdfError': 'Failed to export PDF',
// Places Sidebar
'places.addPlace': 'Add Place',
'places.all': 'All',
'places.unplanned': 'Unplanned',
'places.search': 'Search places...',
'places.allCategories': 'All Categories',
'places.count': '{count} places',
'places.countSingular': '1 place',
'places.allPlanned': 'All places are planned',
'places.noneFound': 'No places found',
'places.editPlace': 'Edit Place',
'places.formName': 'Name',
'places.formNamePlaceholder': 'e.g. Eiffel Tower',
'places.formDescription': 'Description',
'places.formDescriptionPlaceholder': 'Short description...',
'places.formAddress': 'Address',
'places.formAddressPlaceholder': 'Street, City, Country',
'places.formLat': 'Latitude (e.g. 48.8566)',
'places.formLng': 'Longitude (e.g. 2.3522)',
'places.formCategory': 'Category',
'places.noCategory': 'No Category',
'places.categoryNamePlaceholder': 'Category name',
'places.formTime': 'Time',
'places.formWebsite': 'Website',
'places.formNotesPlaceholder': 'Personal notes...',
'places.formReservation': 'Reservation',
'places.reservationNotesPlaceholder': 'Reservation notes, confirmation number...',
'places.mapsSearchPlaceholder': 'Search Google Maps...',
'places.mapsSearchError': 'Google Maps search failed. Please add an API key in settings.',
'places.categoryCreateError': 'Failed to create category',
'places.nameRequired': 'Please enter a name',
'places.saveError': 'Failed to save',
'places.transport.walking': '🚶 Walking',
'places.transport.driving': '🚗 Driving',
'places.transport.cycling': '🚲 Cycling',
'places.transport.transit': '🚌 Transit',
// Place Inspector
'inspector.opened': 'Open',
'inspector.closed': 'Closed',
'inspector.openingHours': 'Opening Hours',
'inspector.showHours': 'Show opening hours',
'inspector.files': 'Files',
'inspector.filesCount': '{count} files',
'inspector.removeFromDay': 'Remove from Day',
'inspector.addToDay': 'Add to Day',
'inspector.confirmedRes': 'Confirmed Reservation',
'inspector.pendingRes': 'Pending Reservation',
'inspector.google': 'Open in Google Maps',
'inspector.website': 'Open Website',
// Reservations
'reservations.title': 'Bookings',
'reservations.empty': 'No reservations yet',
'reservations.emptyHint': 'Add reservations for flights, hotels and more',
'reservations.add': 'Add Reservation',
'reservations.addManual': 'Manual Booking',
'reservations.placeHint': 'Tip: Reservations are best created directly from a place to link them with your day plan.',
'reservations.confirmed': 'Confirmed',
'reservations.pending': 'Pending',
'reservations.summary': '{confirmed} confirmed, {pending} pending',
'reservations.fromPlan': 'From Plan',
'reservations.showFiles': 'Show Files',
'reservations.editTitle': 'Edit Reservation',
'reservations.status': 'Status',
'reservations.datetime': 'Date & Time',
'reservations.timeAlt': 'Time (alternative, e.g. 19:30)',
'reservations.notes': 'Notes',
'reservations.notesPlaceholder': 'Additional notes...',
'reservations.type.flight': 'Flight',
'reservations.type.hotel': 'Hotel',
'reservations.type.restaurant': 'Restaurant',
'reservations.type.train': 'Train',
'reservations.type.car': 'Rental Car',
'reservations.type.cruise': 'Cruise',
'reservations.type.event': 'Event',
'reservations.type.tour': 'Tour',
'reservations.type.other': 'Other',
'reservations.confirm.delete': 'Are you sure you want to delete the reservation "{name}"?',
'reservations.toast.updated': 'Reservation updated',
'reservations.toast.removed': 'Reservation deleted',
'reservations.toast.fileUploaded': 'File uploaded',
'reservations.toast.uploadError': 'Failed to upload',
'reservations.newTitle': 'New Reservation',
'reservations.bookingType': 'Booking Type',
'reservations.titleLabel': 'Title',
'reservations.titlePlaceholder': 'e.g. Lufthansa LH123, Hotel Adlon, ...',
'reservations.locationAddress': 'Location / Address',
'reservations.locationPlaceholder': 'Address, Airport, Hotel...',
'reservations.confirmationCode': 'Confirmation Number / Booking Code',
'reservations.confirmationPlaceholder': 'e.g. ABC12345',
'reservations.day': 'Day',
'reservations.noDay': 'No Day',
'reservations.place': 'Place',
'reservations.noPlace': 'No Place',
'reservations.pendingSave': 'will be saved…',
'reservations.uploading': 'Uploading...',
'reservations.attachFile': 'Attach file',
'reservations.toast.saveError': 'Failed to save',
'reservations.toast.updateError': 'Failed to update',
'reservations.toast.deleteError': 'Failed to delete',
'reservations.confirm.remove': 'Remove reservation for "{name}"?',
// Budget
'budget.title': 'Budget',
'budget.emptyTitle': 'No budget created yet',
'budget.emptyText': 'Create categories and entries to plan your travel budget',
'budget.emptyPlaceholder': 'Enter category name...',
'budget.createCategory': 'Create Category',
'budget.category': 'Category',
'budget.categoryName': 'Category Name',
'budget.table.name': 'Name',
'budget.table.total': 'Total',
'budget.table.persons': 'Persons',
'budget.table.days': 'Days',
'budget.table.perPerson': 'Per Person',
'budget.table.perDay': 'Per Day',
'budget.table.perPersonDay': 'Per Person/Day',
'budget.table.note': 'Note',
'budget.newEntry': 'New Entry',
'budget.defaultEntry': 'New Entry',
'budget.defaultCategory': 'New Category',
'budget.total': 'Total',
'budget.totalBudget': 'Total Budget',
'budget.byCategory': 'By Category',
'budget.editTooltip': 'Click to edit',
'budget.confirm.deleteCategory': 'Are you sure you want to delete the category "{name}" with {count} entries?',
'budget.deleteCategory': 'Delete Category',
// Files
'files.title': 'Files',
'files.count': '{count} files',
'files.countSingular': '1 file',
'files.uploaded': '{count} uploaded',
'files.uploadError': 'Upload failed',
'files.dropzone': 'Drop files here',
'files.dropzoneHint': 'or click to browse',
'files.uploading': 'Uploading...',
'files.filterAll': 'All',
'files.filterPdf': 'PDFs',
'files.filterImages': 'Images',
'files.filterDocs': 'Documents',
'files.empty': 'No files yet',
'files.emptyHint': 'Upload files to attach them to your trip',
'files.openTab': 'Open in new tab',
'files.confirm.delete': 'Are you sure you want to delete this file?',
'files.toast.deleted': 'File deleted',
'files.toast.deleteError': 'Failed to delete file',
'files.sourcePlan': 'Day Plan',
'files.sourceBooking': 'Booking',
// Packing
'packing.title': 'Packing List',
'packing.empty': 'Packing list is empty',
'packing.progress': '{packed} of {total} packed ({percent}%)',
'packing.clearChecked': 'Remove {count} checked',
'packing.suggestions': 'Suggestions',
'packing.suggestionsTitle': 'Add Suggestions',
'packing.allSuggested': 'All suggestions added',
'packing.allPacked': 'All packed!',
'packing.addPlaceholder': 'Add new item...',
'packing.categoryPlaceholder': 'Category...',
'packing.filterAll': 'All',
'packing.filterOpen': 'Open',
'packing.filterDone': 'Done',
'packing.emptyTitle': 'Packing list is empty',
'packing.emptyHint': 'Add items or use the suggestions',
'packing.emptyFiltered': 'No items match this filter',
'packing.menuRename': 'Rename',
'packing.menuCheckAll': 'Check All',
'packing.menuUncheckAll': 'Uncheck All',
'packing.menuDeleteCat': 'Delete Category',
'packing.changeCategory': 'Change Category',
'packing.confirm.clearChecked': 'Are you sure you want to remove {count} checked items?',
'packing.confirm.deleteCat': 'Are you sure you want to delete the category "{name}" with {count} items?',
'packing.defaultCategory': 'Other',
'packing.toast.saveError': 'Failed to save',
'packing.toast.deleteError': 'Failed to delete',
'packing.toast.renameError': 'Failed to rename',
'packing.toast.addError': 'Failed to add',
// Packing suggestions
'packing.suggestions.items': [
{ name: 'Passport', category: 'Documents' },
{ name: 'ID Card', category: 'Documents' },
{ name: 'Travel Insurance', category: 'Documents' },
{ name: 'Flight Tickets', category: 'Documents' },
{ name: 'Credit Card', category: 'Finances' },
{ name: 'Cash', category: 'Finances' },
{ name: 'Visa', category: 'Documents' },
{ name: 'T-Shirts', category: 'Clothing' },
{ name: 'Pants', category: 'Clothing' },
{ name: 'Underwear', category: 'Clothing' },
{ name: 'Socks', category: 'Clothing' },
{ name: 'Jacket', category: 'Clothing' },
{ name: 'Sleepwear', category: 'Clothing' },
{ name: 'Swimwear', category: 'Clothing' },
{ name: 'Rain Jacket', category: 'Clothing' },
{ name: 'Comfortable Shoes', category: 'Clothing' },
{ name: 'Toothbrush', category: 'Toiletries' },
{ name: 'Toothpaste', category: 'Toiletries' },
{ name: 'Shampoo', category: 'Toiletries' },
{ name: 'Deodorant', category: 'Toiletries' },
{ name: 'Sunscreen', category: 'Toiletries' },
{ name: 'Razor', category: 'Toiletries' },
{ name: 'Charger', category: 'Electronics' },
{ name: 'Power Bank', category: 'Electronics' },
{ name: 'Headphones', category: 'Electronics' },
{ name: 'Travel Adapter', category: 'Electronics' },
{ name: 'Camera', category: 'Electronics' },
{ name: 'Pain Medication', category: 'Health' },
{ name: 'Band-Aids', category: 'Health' },
{ name: 'Disinfectant', category: 'Health' },
],
// Members / Sharing
'members.shareTrip': 'Share Trip',
'members.inviteUser': 'Invite User',
'members.selectUser': 'Select user…',
'members.invite': 'Invite',
'members.allHaveAccess': 'All users already have access.',
'members.access': 'Access',
'members.person': 'person',
'members.persons': 'persons',
'members.you': 'you',
'members.owner': 'Owner',
'members.leaveTrip': 'Leave trip',
'members.removeAccess': 'Remove access',
'members.confirmLeave': 'Leave trip? You will lose access.',
'members.confirmRemove': 'Remove access for this user?',
'members.loadError': 'Failed to load members',
'members.added': 'added',
'members.addError': 'Failed to add',
'members.removed': 'Member removed',
'members.removeError': 'Failed to remove',
// Categories (Admin)
'categories.title': 'Categories',
'categories.subtitle': 'Manage categories for places',
'categories.new': 'New Category',
'categories.empty': 'No categories yet',
'categories.namePlaceholder': 'Category name',
'categories.icon': 'Icon',
'categories.color': 'Color',
'categories.customColor': 'Choose custom color',
'categories.preview': 'Preview',
'categories.defaultName': 'Category',
'categories.update': 'Update',
'categories.create': 'Create',
'categories.confirm.delete': 'Delete category? Places in this category will not be deleted.',
'categories.toast.loadError': 'Failed to load categories',
'categories.toast.nameRequired': 'Please enter a name',
'categories.toast.updated': 'Category updated',
'categories.toast.created': 'Category created',
'categories.toast.saveError': 'Failed to save',
'categories.toast.deleted': 'Category deleted',
'categories.toast.deleteError': 'Failed to delete',
// Backup (Admin)
'backup.title': 'Data Backup',
'backup.subtitle': 'Database and all uploaded files',
'backup.refresh': 'Refresh',
'backup.upload': 'Upload Backup',
'backup.uploading': 'Uploading…',
'backup.create': 'Create Backup',
'backup.creating': 'Creating…',
'backup.empty': 'No backups yet',
'backup.createFirst': 'Create first backup',
'backup.download': 'Download',
'backup.restore': 'Restore',
'backup.confirm.restore': 'Restore backup "{name}"?\n\nAll current data will be replaced with the backup.',
'backup.confirm.uploadRestore': 'Upload and restore backup file "{name}"?\n\nAll current data will be overwritten.',
'backup.confirm.delete': 'Delete backup "{name}"?',
'backup.toast.loadError': 'Failed to load backups',
'backup.toast.created': 'Backup created successfully',
'backup.toast.createError': 'Failed to create backup',
'backup.toast.restored': 'Backup restored. Page will reload…',
'backup.toast.restoreError': 'Failed to restore',
'backup.toast.uploadError': 'Failed to upload',
'backup.toast.deleted': 'Backup deleted',
'backup.toast.deleteError': 'Failed to delete',
'backup.toast.downloadError': 'Download failed',
'backup.toast.settingsSaved': 'Auto-backup settings saved',
'backup.toast.settingsError': 'Failed to save settings',
'backup.auto.title': 'Auto-Backup',
'backup.auto.subtitle': 'Automatic backup on a schedule',
'backup.auto.enable': 'Enable auto-backup',
'backup.auto.enableHint': 'Backups will be created automatically on the chosen schedule',
'backup.auto.interval': 'Interval',
'backup.auto.keepLabel': 'Delete old backups after',
'backup.interval.hourly': 'Hourly',
'backup.interval.daily': 'Daily',
'backup.interval.weekly': 'Weekly',
'backup.interval.monthly': 'Monthly',
'backup.keep.1day': '1 day',
'backup.keep.3days': '3 days',
'backup.keep.7days': '7 days',
'backup.keep.14days': '14 days',
'backup.keep.30days': '30 days',
'backup.keep.forever': 'Keep forever',
// PDF
'pdf.travelPlan': 'Travel Plan',
'pdf.planned': 'Planned',
'pdf.costLabel': 'Cost EUR',
'pdf.preview': 'PDF Preview',
'pdf.saveAsPdf': 'Save as PDF',
// Dashboard Stats
'stats.countries': 'Countries',
'stats.cities': 'Cities',
'stats.trips': 'Trips',
'stats.places': 'Places',
'stats.worldProgress': 'World Progress',
'stats.visited': 'visited',
'stats.remaining': 'remaining',
'stats.visitedCountries': 'Visited Countries',
}
export default en

273
client/src/index.css Normal file
View File

@@ -0,0 +1,273 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Ort-Zeile Hover: Sortier-Buttons anzeigen */
.place-row:hover .reorder-btns {
opacity: 1 !important;
}
/* Ladeanimation */
@keyframes spin {
to { transform: rotate(360deg); }
}
/* ── Design tokens ─────────────────────────────── */
:root {
--font-system: -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', system-ui, sans-serif;
--sp-1: 4px;
--sp-2: 8px;
--sp-3: 12px;
--sp-4: 16px;
--sp-6: 24px;
--radius-sm: 8px;
--radius-md: 12px;
--radius-lg: 16px;
--radius-xl: 20px;
--shadow-card: 0 1px 3px rgba(0,0,0,0.08), 0 1px 2px rgba(0,0,0,0.04);
--shadow-elevated: 0 4px 16px rgba(0,0,0,0.1);
/* Theme colors */
--bg-primary: #ffffff;
--bg-secondary: #f8fafc;
--bg-tertiary: #f1f5f9;
--bg-elevated: rgba(250,250,250,0.82);
--bg-card: #ffffff;
--bg-input: #ffffff;
--bg-hover: rgba(0,0,0,0.03);
--text-primary: #111827;
--text-secondary: #374151;
--text-muted: #6b7280;
--text-faint: #9ca3af;
--border-primary: #e5e7eb;
--border-secondary: #f3f4f6;
--border-faint: rgba(0,0,0,0.06);
--accent: #111827;
--accent-text: #ffffff;
--sidebar-bg: rgba(250,250,250,0.82);
--sidebar-shadow: 0 4px 32px rgba(0,0,0,0.10), 0 0 0 1px rgba(0,0,0,0.06);
--tooltip-bg: rgba(255,255,255,0.96);
--scrollbar-track: #f1f5f9;
--scrollbar-thumb: #d1d5db;
--scrollbar-hover: #9ca3af;
}
.dark {
--bg-primary: #121215;
--bg-secondary: #1a1a1e;
--bg-tertiary: #1c1c21;
--bg-elevated: rgba(19,19,22,0.82);
--bg-card: #131316;
--bg-input: #1c1c21;
--bg-hover: rgba(255,255,255,0.06);
--text-primary: #f4f4f5;
--text-secondary: #d4d4d8;
--text-muted: #a1a1aa;
--text-faint: #71717a;
--border-primary: #27272a;
--border-secondary: #1c1c21;
--border-faint: rgba(255,255,255,0.07);
--accent: #e4e4e7;
--accent-text: #09090b;
--sidebar-bg: rgba(19,19,22,0.82);
--sidebar-shadow: 0 4px 32px rgba(0,0,0,0.4), 0 0 0 1px rgba(255,255,255,0.05);
--tooltip-bg: rgba(19,19,22,0.96);
--scrollbar-track: #131316;
--scrollbar-thumb: #3f3f46;
--scrollbar-hover: #52525b;
}
body {
font-family: var(--font-system);
background-color: var(--bg-primary);
color: var(--text-primary);
transition: background-color 0.2s, color 0.2s;
}
/* ── Leaflet z-index ───────────────────────────── */
.leaflet-container {
z-index: 0;
}
.leaflet-pane {
z-index: 0 !important;
}
.leaflet-top, .leaflet-bottom {
z-index: 1 !important;
}
/* ── iOS-style map tooltip ─────────────────────── */
.leaflet-tooltip.map-tooltip {
background: var(--tooltip-bg);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
border: none;
border-radius: var(--radius-md);
box-shadow: var(--shadow-elevated);
padding: 6px 10px;
font-family: var(--font-system);
pointer-events: none;
color: var(--text-primary);
}
.leaflet-tooltip.map-tooltip::before {
border-right-color: var(--tooltip-bg);
}
.leaflet-tooltip-left.map-tooltip::before {
border-left-color: var(--tooltip-bg);
}
/* Scrollbalken */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: var(--scrollbar-track);
border-radius: 3px;
}
::-webkit-scrollbar-thumb {
background: var(--scrollbar-thumb);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--scrollbar-hover);
}
/* Einheitliche Formular-Inputs */
.form-input {
width: 100%;
border: 1px solid var(--border-primary);
border-radius: 10px;
padding: 8px 14px;
font-size: 13px;
font-family: inherit;
outline: none;
box-sizing: border-box;
color: var(--text-primary);
background: var(--bg-input);
transition: border-color 0.15s;
}
.form-input:focus {
border-color: var(--text-faint);
}
.form-input::placeholder {
color: var(--text-faint);
}
/* Weiche Übergänge */
.transition-smooth {
transition: all 0.2s ease;
}
/* Tagesliste */
.day-list-item {
transition: background-color 0.15s ease;
}
.day-list-item:hover {
background-color: var(--bg-secondary);
}
.day-list-item.active {
background-color: #4f46e5;
color: white;
}
.scroll-container {
scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track);
}
/* ── Dark-Mode: Tailwind-Klassen ─────────────── */
.dark .bg-slate-50 { background-color: #131316 !important; }
.dark .bg-slate-100 { background-color: #1c1c21 !important; }
.dark .bg-white { background-color: #09090b !important; }
.dark .bg-gray-50 { background-color: #131316 !important; }
.dark .bg-gray-100 { background-color: #1c1c21 !important; }
.dark .text-slate-900, .dark .text-gray-900, .dark .text-slate-800 { color: #f4f4f5 !important; }
.dark .text-slate-700, .dark .text-gray-700 { color: #d4d4d8 !important; }
.dark .text-slate-600, .dark .text-gray-600 { color: #a1a1aa !important; }
.dark .text-slate-500, .dark .text-gray-500 { color: #a1a1aa !important; }
.dark .text-slate-400, .dark .text-gray-400 { color: #71717a !important; }
.dark .border-slate-200, .dark .border-gray-200, .dark .border-slate-300 { border-color: #27272a !important; }
.dark .border-gray-100, .dark .border-slate-100 { border-color: #1c1c21 !important; }
.dark .hover\:bg-slate-50:hover, .dark .hover\:bg-gray-50:hover { background-color: #1c1c21 !important; }
.dark .bg-slate-50\/60 { background-color: transparent !important; }
.dark .divide-slate-100 > :not([hidden]) ~ :not([hidden]) { border-color: #27272a !important; }
.dark .bg-slate-100 { background-color: #27272a !important; }
.dark .bg-slate-900 { background-color: #e4e4e7 !important; color: #09090b !important; }
.dark .hover\:bg-slate-700:hover { background-color: #d4d4d8 !important; color: #09090b !important; }
.dark input, .dark textarea, .dark select {
background-color: #1c1c21;
color: #f4f4f5;
border-color: #27272a;
}
.dark input::placeholder, .dark textarea::placeholder { color: #71717a; }
.dark .bg-emerald-50 { background-color: rgba(16,185,129,0.15) !important; }
.dark .bg-red-50 { background-color: rgba(239,68,68,0.15) !important; }
/* Ladebildschirm */
.dark .min-h-screen { background-color: var(--bg-primary) !important; }
/* Modal-Hintergrund */
.dark .modal-backdrop { background: rgba(0,0,0,0.6); }
/* ── Dark: Fallback für Komponenten die noch nicht auf CSS-Variablen umgestellt sind ── */
.dark {
color-scheme: dark;
}
/* Scroll-Container */
.scroll-container {
scrollbar-width: thin;
scrollbar-color: #d1d5db #f1f5f9;
}
/* Toast-Animationen */
@keyframes slide-in-right {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
@keyframes slide-out-right {
from { transform: translateX(0); opacity: 1; }
to { transform: translateX(100%); opacity: 0; }
}
.toast-enter {
animation: slide-in-right 0.3s ease-out forwards;
}
.toast-exit {
animation: slide-out-right 0.3s ease-in forwards;
}
/* Modal-Hintergrund */
.modal-backdrop {
backdrop-filter: blur(4px);
}
/* Fortschrittsbalken */
@keyframes progress-fill {
from { width: 0; }
to { width: var(--progress); }
}
/* Anmeldeseiten-Gradient */
.auth-gradient {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
/* Bildanzeige-Overlay */
.lightbox-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.95);
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
}

13
client/src/main.jsx Normal file
View File

@@ -0,0 +1,13 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import App from './App.jsx'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>,
)

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View File

@@ -0,0 +1,127 @@
import { create } from 'zustand'
import { authApi } from '../api/client'
export const useAuthStore = create((set, get) => ({
user: null,
token: localStorage.getItem('auth_token') || null,
isAuthenticated: !!localStorage.getItem('auth_token'),
isLoading: false,
error: null,
login: async (email, password) => {
set({ isLoading: true, error: null })
try {
const data = await authApi.login({ email, password })
localStorage.setItem('auth_token', data.token)
set({
user: data.user,
token: data.token,
isAuthenticated: true,
isLoading: false,
error: null,
})
return data
} catch (err) {
const error = err.response?.data?.error || 'Anmeldung fehlgeschlagen'
set({ isLoading: false, error })
throw new Error(error)
}
},
register: async (username, email, password) => {
set({ isLoading: true, error: null })
try {
const data = await authApi.register({ username, email, password })
localStorage.setItem('auth_token', data.token)
set({
user: data.user,
token: data.token,
isAuthenticated: true,
isLoading: false,
error: null,
})
return data
} catch (err) {
const error = err.response?.data?.error || 'Registrierung fehlgeschlagen'
set({ isLoading: false, error })
throw new Error(error)
}
},
logout: () => {
localStorage.removeItem('auth_token')
set({
user: null,
token: null,
isAuthenticated: false,
error: null,
})
},
loadUser: async () => {
const token = get().token
if (!token) {
set({ isLoading: false })
return
}
set({ isLoading: true })
try {
const data = await authApi.me()
set({
user: data.user,
isAuthenticated: true,
isLoading: false,
})
} catch (err) {
localStorage.removeItem('auth_token')
set({
user: null,
token: null,
isAuthenticated: false,
isLoading: false,
})
}
},
updateMapsKey: async (key) => {
try {
await authApi.updateMapsKey(key)
set(state => ({
user: { ...state.user, maps_api_key: key || null }
}))
} catch (err) {
throw new Error(err.response?.data?.error || 'Fehler beim Speichern des API-Schlüssels')
}
},
updateApiKeys: async (keys) => {
try {
const data = await authApi.updateApiKeys(keys)
set({ user: data.user })
} catch (err) {
throw new Error(err.response?.data?.error || 'Fehler beim Speichern der API-Schlüssel')
}
},
updateProfile: async (profileData) => {
try {
const data = await authApi.updateSettings(profileData)
set({ user: data.user })
} catch (err) {
throw new Error(err.response?.data?.error || 'Fehler beim Aktualisieren des Profils')
}
},
uploadAvatar: async (file) => {
const formData = new FormData()
formData.append('avatar', file)
const data = await authApi.uploadAvatar(formData)
set(state => ({ user: { ...state.user, avatar_url: data.avatar_url } }))
return data
},
deleteAvatar: async () => {
await authApi.deleteAvatar()
set(state => ({ user: { ...state.user, avatar_url: null } }))
},
}))

View File

@@ -0,0 +1,61 @@
import { create } from 'zustand'
import { settingsApi } from '../api/client'
export const useSettingsStore = create((set, get) => ({
settings: {
map_tile_url: '',
default_lat: 48.8566,
default_lng: 2.3522,
default_zoom: 10,
dark_mode: false,
default_currency: 'USD',
language: localStorage.getItem('app_language') || 'en',
temperature_unit: 'fahrenheit',
time_format: '12h',
show_place_description: false,
},
isLoaded: false,
loadSettings: async () => {
try {
const data = await settingsApi.get()
set(state => ({
settings: { ...state.settings, ...data.settings },
isLoaded: true,
}))
} catch (err) {
set({ isLoaded: true })
console.error('Failed to load settings:', err)
}
},
updateSetting: async (key, value) => {
set(state => ({
settings: { ...state.settings, [key]: value }
}))
if (key === 'language') localStorage.setItem('app_language', value)
try {
await settingsApi.set(key, value)
} catch (err) {
console.error('Failed to save setting:', err)
throw new Error(err.response?.data?.error || 'Fehler beim Speichern der Einstellung')
}
},
setLanguageLocal: (lang) => {
localStorage.setItem('app_language', lang)
set(state => ({ settings: { ...state.settings, language: lang } }))
},
updateSettings: async (settingsObj) => {
set(state => ({
settings: { ...state.settings, ...settingsObj }
}))
try {
await settingsApi.setBulk(settingsObj)
} catch (err) {
console.error('Failed to save settings:', err)
throw new Error(err.response?.data?.error || 'Fehler beim Speichern der Einstellungen')
}
},
}))

View File

@@ -0,0 +1,577 @@
import { create } from 'zustand'
import { tripsApi, daysApi, placesApi, assignmentsApi, packingApi, tagsApi, categoriesApi, budgetApi, filesApi, reservationsApi, dayNotesApi } from '../api/client'
export const useTripStore = create((set, get) => ({
trip: null,
days: [],
places: [],
assignments: {}, // { [dayId]: [assignment objects] }
dayNotes: {}, // { [dayId]: [note objects] }
packingItems: [],
tags: [],
categories: [],
budgetItems: [],
files: [],
reservations: [],
selectedDayId: null,
isLoading: false,
error: null,
setSelectedDay: (dayId) => set({ selectedDayId: dayId }),
// Load everything for a trip
loadTrip: async (tripId) => {
set({ isLoading: true, error: null })
try {
const [tripData, daysData, placesData, packingData, tagsData, categoriesData] = await Promise.all([
tripsApi.get(tripId),
daysApi.list(tripId),
placesApi.list(tripId),
packingApi.list(tripId),
tagsApi.list(),
categoriesApi.list(),
])
const assignmentsMap = {}
const dayNotesMap = {}
for (const day of daysData.days) {
assignmentsMap[String(day.id)] = day.assignments || []
dayNotesMap[String(day.id)] = day.notes_items || []
}
set({
trip: tripData.trip,
days: daysData.days,
places: placesData.places,
assignments: assignmentsMap,
dayNotes: dayNotesMap,
packingItems: packingData.items,
tags: tagsData.tags,
categories: categoriesData.categories,
isLoading: false,
})
} catch (err) {
set({ isLoading: false, error: err.message })
throw err
}
},
// Refresh just the places
refreshPlaces: async (tripId) => {
try {
const data = await placesApi.list(tripId)
set({ places: data.places })
} catch (err) {
console.error('Failed to refresh places:', err)
}
},
// Places
addPlace: async (tripId, placeData) => {
try {
const data = await placesApi.create(tripId, placeData)
set(state => ({ places: [data.place, ...state.places] }))
return data.place
} catch (err) {
throw new Error(err.response?.data?.error || 'Fehler beim Hinzufügen des Ortes')
}
},
updatePlace: async (tripId, placeId, placeData) => {
try {
const data = await placesApi.update(tripId, placeId, placeData)
set(state => ({
places: state.places.map(p => p.id === placeId ? data.place : p),
assignments: Object.fromEntries(
Object.entries(state.assignments).map(([dayId, items]) => [
dayId,
items.map(a => a.place?.id === placeId ? { ...a, place: data.place } : a)
])
),
}))
return data.place
} catch (err) {
throw new Error(err.response?.data?.error || 'Fehler beim Aktualisieren des Ortes')
}
},
deletePlace: async (tripId, placeId) => {
try {
await placesApi.delete(tripId, placeId)
set(state => ({
places: state.places.filter(p => p.id !== placeId),
assignments: Object.fromEntries(
Object.entries(state.assignments).map(([dayId, items]) => [
dayId,
items.filter(a => a.place?.id !== placeId)
])
),
}))
} catch (err) {
throw new Error(err.response?.data?.error || 'Fehler beim Löschen des Ortes')
}
},
// Assignments
assignPlaceToDay: async (tripId, dayId, placeId, position) => {
const state = get()
const place = state.places.find(p => p.id === parseInt(placeId))
if (!place) return
// Check if already assigned
const existing = (state.assignments[String(dayId)] || []).find(a => a.place?.id === parseInt(placeId))
if (existing) return
const tempId = Date.now() * -1
const current = [...(state.assignments[String(dayId)] || [])]
const insertIdx = position != null ? position : current.length
const tempAssignment = {
id: tempId,
day_id: parseInt(dayId),
order_index: insertIdx,
notes: null,
place,
}
current.splice(insertIdx, 0, tempAssignment)
set(state => ({
assignments: {
...state.assignments,
[String(dayId)]: current,
}
}))
try {
const data = await assignmentsApi.create(tripId, dayId, { place_id: placeId })
const newAssignment = position != null
? { ...data.assignment, order_index: insertIdx }
: data.assignment
set(state => ({
assignments: {
...state.assignments,
[String(dayId)]: state.assignments[String(dayId)].map(
a => a.id === tempId ? newAssignment : a
),
}
}))
// Reihenfolge am Server aktualisieren
if (position != null) {
const updated = get().assignments[String(dayId)] || []
const orderedIds = updated.map(a => a.id)
try { await assignmentsApi.reorder(tripId, dayId, orderedIds) } catch {}
}
return data.assignment
} catch (err) {
set(state => ({
assignments: {
...state.assignments,
[String(dayId)]: state.assignments[String(dayId)].filter(a => a.id !== tempId),
}
}))
throw new Error(err.response?.data?.error || 'Fehler beim Zuweisen des Ortes')
}
},
removeAssignment: async (tripId, dayId, assignmentId) => {
const prevAssignments = get().assignments
set(state => ({
assignments: {
...state.assignments,
[String(dayId)]: state.assignments[String(dayId)].filter(a => a.id !== assignmentId),
}
}))
try {
await assignmentsApi.delete(tripId, dayId, assignmentId)
} catch (err) {
set({ assignments: prevAssignments })
throw new Error(err.response?.data?.error || 'Fehler beim Entfernen der Zuweisung')
}
},
reorderAssignments: async (tripId, dayId, orderedIds) => {
const prevAssignments = get().assignments
const dayItems = get().assignments[String(dayId)] || []
const reordered = orderedIds.map((id, idx) => {
const item = dayItems.find(a => a.id === id)
return item ? { ...item, order_index: idx } : null
}).filter(Boolean)
set(state => ({
assignments: {
...state.assignments,
[String(dayId)]: reordered,
}
}))
try {
await assignmentsApi.reorder(tripId, dayId, orderedIds)
} catch (err) {
set({ assignments: prevAssignments })
throw new Error(err.response?.data?.error || 'Fehler beim Neuanordnen')
}
},
moveAssignment: async (tripId, assignmentId, fromDayId, toDayId, toOrderIndex = null) => {
const state = get()
const prevAssignments = state.assignments
const assignment = (state.assignments[String(fromDayId)] || []).find(a => a.id === assignmentId)
if (!assignment) return
const toItems = (state.assignments[String(toDayId)] || []).slice().sort((a, b) => a.order_index - b.order_index)
const insertAt = toOrderIndex !== null ? toOrderIndex : toItems.length
// Build new order for target day with item inserted at correct position
const newToItems = [...toItems]
newToItems.splice(insertAt, 0, { ...assignment, day_id: parseInt(toDayId) })
newToItems.forEach((a, i) => { a.order_index = i })
set(s => ({
assignments: {
...s.assignments,
[String(fromDayId)]: s.assignments[String(fromDayId)].filter(a => a.id !== assignmentId),
[String(toDayId)]: newToItems,
}
}))
try {
await assignmentsApi.move(tripId, assignmentId, toDayId, insertAt)
if (newToItems.length > 1) {
await assignmentsApi.reorder(tripId, toDayId, newToItems.map(a => a.id))
}
} catch (err) {
set({ assignments: prevAssignments })
throw new Error(err.response?.data?.error || 'Fehler beim Verschieben der Zuweisung')
}
},
moveDayNote: async (tripId, fromDayId, toDayId, noteId, sort_order = 9999) => {
const state = get()
const note = (state.dayNotes[String(fromDayId)] || []).find(n => n.id === noteId)
if (!note) return
// Optimistic: remove from old day
set(s => ({
dayNotes: {
...s.dayNotes,
[String(fromDayId)]: (s.dayNotes[String(fromDayId)] || []).filter(n => n.id !== noteId),
}
}))
try {
await dayNotesApi.delete(tripId, fromDayId, noteId)
const result = await dayNotesApi.create(tripId, toDayId, {
text: note.text, time: note.time, icon: note.icon, sort_order,
})
set(s => ({
dayNotes: {
...s.dayNotes,
[String(toDayId)]: [...(s.dayNotes[String(toDayId)] || []), result.note],
}
}))
} catch (err) {
// Rollback
set(s => ({
dayNotes: {
...s.dayNotes,
[String(fromDayId)]: [...(s.dayNotes[String(fromDayId)] || []), note],
}
}))
throw new Error(err.response?.data?.error || 'Fehler beim Verschieben der Notiz')
}
},
setAssignments: (assignments) => {
set({ assignments })
},
// Packing
addPackingItem: async (tripId, data) => {
try {
const result = await packingApi.create(tripId, data)
set(state => ({ packingItems: [...state.packingItems, result.item] }))
return result.item
} catch (err) {
throw new Error(err.response?.data?.error || 'Fehler beim Hinzufügen des Artikels')
}
},
updatePackingItem: async (tripId, id, data) => {
try {
const result = await packingApi.update(tripId, id, data)
set(state => ({
packingItems: state.packingItems.map(item => item.id === id ? result.item : item)
}))
return result.item
} catch (err) {
throw new Error(err.response?.data?.error || 'Fehler beim Aktualisieren des Artikels')
}
},
deletePackingItem: async (tripId, id) => {
const prev = get().packingItems
set(state => ({ packingItems: state.packingItems.filter(item => item.id !== id) }))
try {
await packingApi.delete(tripId, id)
} catch (err) {
set({ packingItems: prev })
throw new Error(err.response?.data?.error || 'Fehler beim Löschen des Artikels')
}
},
togglePackingItem: async (tripId, id, checked) => {
set(state => ({
packingItems: state.packingItems.map(item =>
item.id === id ? { ...item, checked: checked ? 1 : 0 } : item
)
}))
try {
await packingApi.update(tripId, id, { checked })
} catch (err) {
set(state => ({
packingItems: state.packingItems.map(item =>
item.id === id ? { ...item, checked: checked ? 0 : 1 } : item
)
}))
}
},
// Days
updateDayNotes: async (tripId, dayId, notes) => {
try {
await daysApi.update(tripId, dayId, { notes })
set(state => ({
days: state.days.map(d => d.id === parseInt(dayId) ? { ...d, notes } : d)
}))
} catch (err) {
throw new Error(err.response?.data?.error || 'Fehler beim Aktualisieren der Notizen')
}
},
updateDayTitle: async (tripId, dayId, title) => {
try {
await daysApi.update(tripId, dayId, { title })
set(state => ({
days: state.days.map(d => d.id === parseInt(dayId) ? { ...d, title } : d)
}))
} catch (err) {
throw new Error(err.response?.data?.error || 'Fehler beim Aktualisieren des Tagesnamens')
}
},
// Tags and categories
addTag: async (data) => {
try {
const result = await tagsApi.create(data)
set(state => ({ tags: [...state.tags, result.tag] }))
return result.tag
} catch (err) {
throw new Error(err.response?.data?.error || 'Fehler beim Erstellen des Tags')
}
},
addCategory: async (data) => {
try {
const result = await categoriesApi.create(data)
set(state => ({ categories: [...state.categories, result.category] }))
return result.category
} catch (err) {
throw new Error(err.response?.data?.error || 'Fehler beim Erstellen der Kategorie')
}
},
// Update trip
updateTrip: async (tripId, data) => {
try {
const result = await tripsApi.update(tripId, data)
set({ trip: result.trip })
const daysData = await daysApi.list(tripId)
const assignmentsMap = {}
const dayNotesMap = {}
for (const day of daysData.days) {
assignmentsMap[String(day.id)] = day.assignments || []
dayNotesMap[String(day.id)] = day.notes_items || []
}
set({ days: daysData.days, assignments: assignmentsMap, dayNotes: dayNotesMap })
return result.trip
} catch (err) {
throw new Error(err.response?.data?.error || 'Fehler beim Aktualisieren der Reise')
}
},
// Budget
loadBudgetItems: async (tripId) => {
try {
const data = await budgetApi.list(tripId)
set({ budgetItems: data.items })
} catch (err) {
console.error('Failed to load budget items:', err)
}
},
addBudgetItem: async (tripId, data) => {
try {
const result = await budgetApi.create(tripId, data)
set(state => ({ budgetItems: [...state.budgetItems, result.item] }))
return result.item
} catch (err) {
throw new Error(err.response?.data?.error || 'Fehler beim Hinzufügen des Budget-Eintrags')
}
},
updateBudgetItem: async (tripId, id, data) => {
try {
const result = await budgetApi.update(tripId, id, data)
set(state => ({
budgetItems: state.budgetItems.map(item => item.id === id ? result.item : item)
}))
return result.item
} catch (err) {
throw new Error(err.response?.data?.error || 'Fehler beim Aktualisieren des Budget-Eintrags')
}
},
deleteBudgetItem: async (tripId, id) => {
const prev = get().budgetItems
set(state => ({ budgetItems: state.budgetItems.filter(item => item.id !== id) }))
try {
await budgetApi.delete(tripId, id)
} catch (err) {
set({ budgetItems: prev })
throw new Error(err.response?.data?.error || 'Fehler beim Löschen des Budget-Eintrags')
}
},
// Files
loadFiles: async (tripId) => {
try {
const data = await filesApi.list(tripId)
set({ files: data.files })
} catch (err) {
console.error('Failed to load files:', err)
}
},
addFile: async (tripId, formData) => {
try {
const data = await filesApi.upload(tripId, formData)
set(state => ({ files: [data.file, ...state.files] }))
return data.file
} catch (err) {
throw new Error(err.response?.data?.error || 'Fehler beim Hochladen der Datei')
}
},
deleteFile: async (tripId, id) => {
try {
await filesApi.delete(tripId, id)
set(state => ({ files: state.files.filter(f => f.id !== id) }))
} catch (err) {
throw new Error(err.response?.data?.error || 'Fehler beim Löschen der Datei')
}
},
// Reservations
loadReservations: async (tripId) => {
try {
const data = await reservationsApi.list(tripId)
set({ reservations: data.reservations })
} catch (err) {
console.error('Failed to load reservations:', err)
}
},
addReservation: async (tripId, data) => {
try {
const result = await reservationsApi.create(tripId, data)
set(state => ({ reservations: [result.reservation, ...state.reservations] }))
return result.reservation
} catch (err) {
throw new Error(err.response?.data?.error || 'Fehler beim Erstellen der Reservierung')
}
},
updateReservation: async (tripId, id, data) => {
try {
const result = await reservationsApi.update(tripId, id, data)
set(state => ({
reservations: state.reservations.map(r => r.id === id ? result.reservation : r)
}))
return result.reservation
} catch (err) {
throw new Error(err.response?.data?.error || 'Fehler beim Aktualisieren der Reservierung')
}
},
toggleReservationStatus: async (tripId, id) => {
const prev = get().reservations
const current = prev.find(r => r.id === id)
if (!current) return
const newStatus = current.status === 'confirmed' ? 'pending' : 'confirmed'
set(state => ({
reservations: state.reservations.map(r => r.id === id ? { ...r, status: newStatus } : r)
}))
try {
await reservationsApi.update(tripId, id, { status: newStatus })
} catch {
set({ reservations: prev })
}
},
deleteReservation: async (tripId, id) => {
try {
await reservationsApi.delete(tripId, id)
set(state => ({ reservations: state.reservations.filter(r => r.id !== id) }))
} catch (err) {
throw new Error(err.response?.data?.error || 'Fehler beim Löschen der Reservierung')
}
},
// Day Notes
addDayNote: async (tripId, dayId, data) => {
try {
const result = await dayNotesApi.create(tripId, dayId, data)
set(state => ({
dayNotes: {
...state.dayNotes,
[String(dayId)]: [...(state.dayNotes[String(dayId)] || []), result.note],
}
}))
return result.note
} catch (err) {
throw new Error(err.response?.data?.error || 'Fehler beim Hinzufügen der Notiz')
}
},
updateDayNote: async (tripId, dayId, id, data) => {
try {
const result = await dayNotesApi.update(tripId, dayId, id, data)
set(state => ({
dayNotes: {
...state.dayNotes,
[String(dayId)]: (state.dayNotes[String(dayId)] || []).map(n => n.id === id ? result.note : n),
}
}))
return result.note
} catch (err) {
throw new Error(err.response?.data?.error || 'Fehler beim Aktualisieren der Notiz')
}
},
deleteDayNote: async (tripId, dayId, id) => {
const prev = get().dayNotes
set(state => ({
dayNotes: {
...state.dayNotes,
[String(dayId)]: (state.dayNotes[String(dayId)] || []).filter(n => n.id !== id),
}
}))
try {
await dayNotesApi.delete(tripId, dayId, id)
} catch (err) {
set({ dayNotes: prev })
throw new Error(err.response?.data?.error || 'Fehler beim Löschen der Notiz')
}
},
}))

43
client/tailwind.config.js Normal file
View File

@@ -0,0 +1,43 @@
/** @type {import('tailwindcss').Config} */
export default {
darkMode: 'class',
content: [
"./index.html",
"./src/**/*.{js,jsx,ts,tsx}",
],
theme: {
extend: {
colors: {
primary: {
50: '#eef2ff',
100: '#e0e7ff',
200: '#c7d2fe',
300: '#a5b4fc',
400: '#818cf8',
500: '#6366f1',
600: '#4f46e5',
700: '#4338ca',
800: '#3730a3',
900: '#312e81',
950: '#1e1b4b',
},
planner: {
day: '#f8fafc',
dayBorder: '#e2e8f0',
dayHeader: '#1e293b',
sidebar: '#ffffff',
sidebarBorder: '#f1f5f9',
overlay: 'rgba(15, 23, 42, 0.4)',
dragActive: '#eef2ff',
dragOver: '#c7d2fe',
},
},
boxShadow: {
'day-column': '0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06)',
'place-card': '0 1px 2px 0 rgba(0, 0, 0, 0.05)',
'drag-overlay': '0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)',
},
},
},
plugins: [],
}

19
client/vite.config.js Normal file
View File

@@ -0,0 +1,19 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:3001',
changeOrigin: true,
},
'/uploads': {
target: 'http://localhost:3001',
changeOrigin: true,
}
}
}
})

21
docker-compose.yml Normal file
View File

@@ -0,0 +1,21 @@
services:
app:
build: .
container_name: nomad
ports:
- "3000:3000"
environment:
- NODE_ENV=production
- JWT_SECRET=${JWT_SECRET:-change-me-to-a-long-random-string}
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-http://localhost:3000}
- PORT=3000
volumes:
- ./data:/app/data
- ./uploads:/app/uploads
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/auth/me"]
interval: 30s
timeout: 10s
retries: 3
start_period: 15s

3
server/.env.example Normal file
View File

@@ -0,0 +1,3 @@
PORT=3001
JWT_SECRET=your-super-secret-jwt-key-change-in-production
NODE_ENV=development

2133
server/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

25
server/package.json Normal file
View File

@@ -0,0 +1,25 @@
{
"name": "nomad-server",
"version": "2.0.0",
"main": "src/index.js",
"scripts": {
"start": "node --experimental-sqlite src/index.js",
"dev": "nodemon src/index.js"
},
"dependencies": {
"archiver": "^6.0.1",
"bcryptjs": "^2.4.3",
"cors": "^2.8.5",
"dotenv": "^16.4.1",
"express": "^4.18.3",
"jsonwebtoken": "^9.0.2",
"multer": "^1.4.5-lts.1",
"node-cron": "^4.2.1",
"node-fetch": "^2.7.0",
"unzipper": "^0.12.3",
"uuid": "^9.0.0"
},
"devDependencies": {
"nodemon": "^3.1.0"
}
}

21
server/reset-admin.js Normal file
View File

@@ -0,0 +1,21 @@
const path = require('path');
const { DatabaseSync } = require('node:sqlite');
const bcrypt = require('bcryptjs');
const dbPath = path.join(__dirname, 'data/travel.db');
const db = new DatabaseSync(dbPath);
const hash = bcrypt.hashSync('admin123', 10);
const existing = db.prepare('SELECT id FROM users WHERE email = ?').get('admin@admin.com');
if (existing) {
db.prepare('UPDATE users SET password_hash = ?, role = ? WHERE email = ?')
.run(hash, 'admin', 'admin@admin.com');
console.log('✓ Admin-Passwort zurückgesetzt: admin@admin.com / admin123');
} else {
db.prepare('INSERT INTO users (username, email, password_hash, role) VALUES (?, ?, ?, ?)')
.run('admin', 'admin@admin.com', hash, 'admin');
console.log('✓ Admin-User erstellt: admin@admin.com / admin123');
}
db.close();

14
server/src/config.js Normal file
View File

@@ -0,0 +1,14 @@
const crypto = require('crypto');
let JWT_SECRET = process.env.JWT_SECRET;
if (!JWT_SECRET) {
if (process.env.NODE_ENV === 'production') {
console.error('FATAL: JWT_SECRET environment variable is required in production.');
process.exit(1);
}
JWT_SECRET = crypto.randomBytes(32).toString('hex');
console.warn('WARNING: No JWT_SECRET set — using auto-generated secret. Sessions will reset on server restart.');
}
module.exports = { JWT_SECRET };

361
server/src/db/database.js Normal file
View File

@@ -0,0 +1,361 @@
const { DatabaseSync } = require('node:sqlite');
const path = require('path');
const fs = require('fs');
const bcrypt = require('bcryptjs');
const dataDir = path.join(__dirname, '../../data');
if (!fs.existsSync(dataDir)) {
fs.mkdirSync(dataDir, { recursive: true });
}
const dbPath = path.join(dataDir, 'travel.db');
let _db = null;
function initDb() {
if (_db) {
try { _db.exec('PRAGMA wal_checkpoint(TRUNCATE)'); } catch (e) {}
try { _db.close(); } catch (e) {}
_db = null;
}
_db = new DatabaseSync(dbPath);
_db.exec('PRAGMA journal_mode = WAL');
_db.exec('PRAGMA foreign_keys = ON');
// Create all tables
_db.exec(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
email TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user',
maps_api_key TEXT,
unsplash_api_key TEXT,
openweather_api_key TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS settings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
key TEXT NOT NULL,
value TEXT,
UNIQUE(user_id, key)
);
CREATE TABLE IF NOT EXISTS trips (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
title TEXT NOT NULL,
description TEXT,
start_date TEXT,
end_date TEXT,
currency TEXT DEFAULT 'EUR',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS days (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
day_number INTEGER NOT NULL,
date TEXT,
notes TEXT,
UNIQUE(trip_id, day_number)
);
CREATE TABLE IF NOT EXISTS categories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
color TEXT DEFAULT '#6366f1',
icon TEXT DEFAULT '📍',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS tags (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
color TEXT DEFAULT '#10b981',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS places (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
name TEXT NOT NULL,
description TEXT,
lat REAL,
lng REAL,
address TEXT,
category_id INTEGER REFERENCES categories(id) ON DELETE SET NULL,
price REAL,
currency TEXT,
reservation_status TEXT DEFAULT 'none',
reservation_notes TEXT,
reservation_datetime TEXT,
place_time TEXT,
duration_minutes INTEGER DEFAULT 60,
notes TEXT,
image_url TEXT,
google_place_id TEXT,
website TEXT,
phone TEXT,
transport_mode TEXT DEFAULT 'walking',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS place_tags (
place_id INTEGER NOT NULL REFERENCES places(id) ON DELETE CASCADE,
tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (place_id, tag_id)
);
CREATE TABLE IF NOT EXISTS day_assignments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
day_id INTEGER NOT NULL REFERENCES days(id) ON DELETE CASCADE,
place_id INTEGER NOT NULL REFERENCES places(id) ON DELETE CASCADE,
order_index INTEGER DEFAULT 0,
notes TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS packing_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
name TEXT NOT NULL,
checked INTEGER DEFAULT 0,
category TEXT,
sort_order INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS photos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
day_id INTEGER REFERENCES days(id) ON DELETE SET NULL,
place_id INTEGER REFERENCES places(id) ON DELETE SET NULL,
filename TEXT NOT NULL,
original_name TEXT NOT NULL,
file_size INTEGER,
mime_type TEXT,
caption TEXT,
taken_at TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS trip_files (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
place_id INTEGER REFERENCES places(id) ON DELETE SET NULL,
filename TEXT NOT NULL,
original_name TEXT NOT NULL,
file_size INTEGER,
mime_type TEXT,
description TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS reservations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
day_id INTEGER REFERENCES days(id) ON DELETE SET NULL,
place_id INTEGER REFERENCES places(id) ON DELETE SET NULL,
title TEXT NOT NULL,
reservation_time TEXT,
location TEXT,
confirmation_number TEXT,
notes TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS trip_members (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
invited_by INTEGER REFERENCES users(id),
added_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(trip_id, user_id)
);
CREATE TABLE IF NOT EXISTS day_notes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
day_id INTEGER NOT NULL REFERENCES days(id) ON DELETE CASCADE,
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
text TEXT NOT NULL,
time TEXT,
icon TEXT DEFAULT '📝',
sort_order REAL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS app_settings (
key TEXT PRIMARY KEY,
value TEXT
);
CREATE TABLE IF NOT EXISTS budget_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
category TEXT NOT NULL DEFAULT 'Sonstiges',
name TEXT NOT NULL,
total_price REAL NOT NULL DEFAULT 0,
persons INTEGER DEFAULT NULL,
days INTEGER DEFAULT NULL,
note TEXT,
sort_order INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
`);
// Migrations
const migrations = [
`ALTER TABLE users ADD COLUMN unsplash_api_key TEXT`,
`ALTER TABLE users ADD COLUMN openweather_api_key TEXT`,
`ALTER TABLE places ADD COLUMN duration_minutes INTEGER DEFAULT 60`,
`ALTER TABLE places ADD COLUMN notes TEXT`,
`ALTER TABLE places ADD COLUMN image_url TEXT`,
`ALTER TABLE places ADD COLUMN transport_mode TEXT DEFAULT 'walking'`,
`ALTER TABLE days ADD COLUMN title TEXT`,
`ALTER TABLE reservations ADD COLUMN status TEXT DEFAULT 'pending'`,
`ALTER TABLE trip_files ADD COLUMN reservation_id INTEGER REFERENCES reservations(id) ON DELETE SET NULL`,
`ALTER TABLE reservations ADD COLUMN type TEXT DEFAULT 'other'`,
`ALTER TABLE trips ADD COLUMN cover_image TEXT`,
`ALTER TABLE day_notes ADD COLUMN icon TEXT DEFAULT '📝'`,
`ALTER TABLE trips ADD COLUMN is_archived INTEGER DEFAULT 0`,
`ALTER TABLE categories ADD COLUMN user_id INTEGER REFERENCES users(id) ON DELETE SET NULL`,
`ALTER TABLE users ADD COLUMN avatar TEXT`,
];
// Recreate budget_items to allow NULL persons (SQLite can't ALTER NOT NULL)
try {
const hasNotNull = _db.prepare("SELECT sql FROM sqlite_master WHERE name = 'budget_items'").get()
if (hasNotNull?.sql?.includes('NOT NULL DEFAULT 1')) {
_db.exec(`
CREATE TABLE budget_items_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
category TEXT NOT NULL DEFAULT 'Sonstiges',
name TEXT NOT NULL,
total_price REAL NOT NULL DEFAULT 0,
persons INTEGER DEFAULT NULL,
days INTEGER DEFAULT NULL,
note TEXT,
sort_order INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
INSERT INTO budget_items_new SELECT * FROM budget_items;
DROP TABLE budget_items;
ALTER TABLE budget_items_new RENAME TO budget_items;
`)
}
} catch (e) { /* table doesn't exist yet or already migrated */ }
for (const sql of migrations) {
try { _db.exec(sql); } catch (e) { /* column already exists */ }
}
// First registered user becomes admin — no default admin seed needed
// Seed: default categories
try {
const existingCats = _db.prepare('SELECT COUNT(*) as count FROM categories').get();
if (existingCats.count === 0) {
const defaultCategories = [
{ name: 'Hotel', color: '#3b82f6', icon: '🏨' },
{ name: 'Restaurant', color: '#ef4444', icon: '🍽️' },
{ name: 'Sehenswürdigkeit', color: '#8b5cf6', icon: '🏛️' },
{ name: 'Shopping', color: '#f59e0b', icon: '🛍️' },
{ name: 'Transport', color: '#6b7280', icon: '🚌' },
{ name: 'Aktivität', color: '#10b981', icon: '🎯' },
{ name: 'Bar/Café', color: '#f97316', icon: '☕' },
{ name: 'Strand', color: '#06b6d4', icon: '🏖️' },
{ name: 'Natur', color: '#84cc16', icon: '🌿' },
{ name: 'Sonstiges', color: '#6366f1', icon: '📍' },
];
const insertCat = _db.prepare('INSERT INTO categories (name, color, icon) VALUES (?, ?, ?)');
for (const cat of defaultCategories) insertCat.run(cat.name, cat.color, cat.icon);
console.log('Default categories seeded');
}
} catch (err) {
console.error('Error seeding categories:', err.message);
}
}
// Initialize on module load
initDb();
// Proxy so all route modules always use the current _db instance
// without needing a server restart after reinitialize()
const db = new Proxy({}, {
get(_, prop) {
const val = _db[prop];
return typeof val === 'function' ? val.bind(_db) : val;
},
set(_, prop, val) {
_db[prop] = val;
return true;
},
});
function closeDb() {
if (_db) {
try { _db.exec('PRAGMA wal_checkpoint(TRUNCATE)'); } catch (e) {}
try { _db.close(); } catch (e) {}
_db = null;
console.log('[DB] Database connection closed');
}
}
function reinitialize() {
console.log('[DB] Reinitializing database connection after restore...');
// initDb handles close + reopen, but if closeDb was already called, _db is null
if (_db) closeDb();
initDb();
console.log('[DB] Database reinitialized successfully');
}
function getPlaceWithTags(placeId) {
const place = _db.prepare(`
SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon
FROM places p
LEFT JOIN categories c ON p.category_id = c.id
WHERE p.id = ?
`).get(placeId);
if (!place) return null;
const tags = _db.prepare(`
SELECT t.* FROM tags t
JOIN place_tags pt ON t.id = pt.tag_id
WHERE pt.place_id = ?
`).all(placeId);
return {
...place,
category: place.category_id ? {
id: place.category_id,
name: place.category_name,
color: place.category_color,
icon: place.category_icon,
} : null,
tags,
};
}
function canAccessTrip(tripId, userId) {
return _db.prepare(`
SELECT t.id, t.user_id FROM trips t
LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ?
WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)
`).get(userId, tripId, userId);
}
function isOwner(tripId, userId) {
return !!_db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId);
}
module.exports = { db, closeDb, reinitialize, getPlaceWithTags, canAccessTrip, isOwner };

108
server/src/index.js Normal file
View File

@@ -0,0 +1,108 @@
require('dotenv').config();
const express = require('express');
const cors = require('cors');
const path = require('path');
const fs = require('fs');
const app = express();
// Create upload directories on startup
const uploadsDir = path.join(__dirname, '../uploads');
const photosDir = path.join(uploadsDir, 'photos');
const filesDir = path.join(uploadsDir, 'files');
const coversDir = path.join(uploadsDir, 'covers');
const backupsDir = path.join(__dirname, '../data/backups');
const tmpDir = path.join(__dirname, '../data/tmp');
[uploadsDir, photosDir, filesDir, coversDir, backupsDir, tmpDir].forEach(dir => {
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
});
// Middleware
const allowedOrigins = process.env.ALLOWED_ORIGINS
? process.env.ALLOWED_ORIGINS.split(',')
: ['http://localhost:5173', 'http://localhost:3000'];
app.use(cors({
origin: (origin, callback) => {
if (!origin || allowedOrigins.includes(origin)) callback(null, true);
else callback(new Error('Not allowed by CORS'));
},
credentials: true
}));
app.use(express.json());
// Security headers
app.use((req, res, next) => {
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('X-Frame-Options', 'DENY');
res.setHeader('X-XSS-Protection', '1; mode=block');
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
next();
});
app.use(express.urlencoded({ extended: true }));
// Serve uploaded files
app.use('/uploads', express.static(path.join(__dirname, '../uploads')));
// Routes
const authRoutes = require('./routes/auth');
const tripsRoutes = require('./routes/trips');
const daysRoutes = require('./routes/days');
const placesRoutes = require('./routes/places');
const assignmentsRoutes = require('./routes/assignments');
const packingRoutes = require('./routes/packing');
const tagsRoutes = require('./routes/tags');
const categoriesRoutes = require('./routes/categories');
const adminRoutes = require('./routes/admin');
const mapsRoutes = require('./routes/maps');
const filesRoutes = require('./routes/files');
const reservationsRoutes = require('./routes/reservations');
const dayNotesRoutes = require('./routes/dayNotes');
const weatherRoutes = require('./routes/weather');
const settingsRoutes = require('./routes/settings');
const budgetRoutes = require('./routes/budget');
const backupRoutes = require('./routes/backup');
app.use('/api/auth', authRoutes);
app.use('/api/trips', tripsRoutes);
app.use('/api/trips/:tripId/days', daysRoutes);
app.use('/api/trips/:tripId/places', placesRoutes);
app.use('/api/trips/:tripId/packing', packingRoutes);
app.use('/api/trips/:tripId/files', filesRoutes);
app.use('/api/trips/:tripId/budget', budgetRoutes);
app.use('/api/trips/:tripId/reservations', reservationsRoutes);
app.use('/api/trips/:tripId/days/:dayId/notes', dayNotesRoutes);
app.use('/api', assignmentsRoutes);
app.use('/api/tags', tagsRoutes);
app.use('/api/categories', categoriesRoutes);
app.use('/api/admin', adminRoutes);
app.use('/api/maps', mapsRoutes);
app.use('/api/weather', weatherRoutes);
app.use('/api/settings', settingsRoutes);
app.use('/api/backup', backupRoutes);
// Serve static files in production
if (process.env.NODE_ENV === 'production') {
const publicPath = path.join(__dirname, '../public');
app.use(express.static(publicPath));
app.get('*', (req, res) => {
res.sendFile(path.join(publicPath, 'index.html'));
});
}
// Global error handler
app.use((err, req, res, next) => {
console.error('Unhandled error:', err);
res.status(500).json({ error: 'Internal server error' });
});
const scheduler = require('./scheduler');
const PORT = process.env.PORT || 3001;
app.listen(PORT, () => {
console.log(`NOMAD API running on port ${PORT}`);
console.log(`Environment: ${process.env.NODE_ENV || 'development'}`);
scheduler.start();
});
module.exports = app;

View File

@@ -0,0 +1,56 @@
const jwt = require('jsonwebtoken');
const { db } = require('../db/database');
const { JWT_SECRET } = require('../config');
const authenticate = (req, res, next) => {
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1];
if (!token) {
return res.status(401).json({ error: 'Access token required' });
}
try {
const decoded = jwt.verify(token, JWT_SECRET);
const user = db.prepare(
'SELECT id, username, email, role, maps_api_key, unsplash_api_key, openweather_api_key FROM users WHERE id = ?'
).get(decoded.id);
if (!user) {
return res.status(401).json({ error: 'User not found' });
}
req.user = user;
next();
} catch (err) {
return res.status(401).json({ error: 'Invalid or expired token' });
}
};
const optionalAuth = (req, res, next) => {
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1];
if (!token) {
req.user = null;
return next();
}
try {
const decoded = jwt.verify(token, JWT_SECRET);
const user = db.prepare(
'SELECT id, username, email, role, maps_api_key, unsplash_api_key, openweather_api_key FROM users WHERE id = ?'
).get(decoded.id);
req.user = user || null;
} catch (err) {
req.user = null;
}
next();
};
const adminOnly = (req, res, next) => {
if (!req.user || req.user.role !== 'admin') {
return res.status(403).json({ error: 'Admin access required' });
}
next();
};
module.exports = { authenticate, optionalAuth, adminOnly };

View File

@@ -0,0 +1,82 @@
const express = require('express');
const bcrypt = require('bcryptjs');
const { db } = require('../db/database');
const { authenticate, adminOnly } = require('../middleware/auth');
const router = express.Router();
// All admin routes require authentication and admin role
router.use(authenticate, adminOnly);
// GET /api/admin/users
router.get('/users', (req, res) => {
const users = db.prepare(
'SELECT id, username, email, role, maps_api_key, unsplash_api_key, openweather_api_key, created_at, updated_at FROM users ORDER BY created_at DESC'
).all();
res.json({ users });
});
// PUT /api/admin/users/:id
router.put('/users/:id', (req, res) => {
const { username, email, role, password } = req.body;
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.params.id);
if (!user) return res.status(404).json({ error: 'Benutzer nicht gefunden' });
if (role && !['user', 'admin'].includes(role)) {
return res.status(400).json({ error: 'Ungültige Rolle' });
}
if (username && username !== user.username) {
const conflict = db.prepare('SELECT id FROM users WHERE username = ? AND id != ?').get(username, req.params.id);
if (conflict) return res.status(409).json({ error: 'Benutzername bereits vergeben' });
}
if (email && email !== user.email) {
const conflict = db.prepare('SELECT id FROM users WHERE email = ? AND id != ?').get(email, req.params.id);
if (conflict) return res.status(409).json({ error: 'E-Mail bereits vergeben' });
}
const passwordHash = password ? bcrypt.hashSync(password, 10) : null;
db.prepare(`
UPDATE users SET
username = COALESCE(?, username),
email = COALESCE(?, email),
role = COALESCE(?, role),
password_hash = COALESCE(?, password_hash),
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`).run(username || null, email || null, role || null, passwordHash, req.params.id);
const updated = db.prepare(
'SELECT id, username, email, role, created_at, updated_at FROM users WHERE id = ?'
).get(req.params.id);
res.json({ user: updated });
});
// DELETE /api/admin/users/:id
router.delete('/users/:id', (req, res) => {
if (parseInt(req.params.id) === req.user.id) {
return res.status(400).json({ error: 'Eigenes Konto kann nicht gelöscht werden' });
}
const user = db.prepare('SELECT id FROM users WHERE id = ?').get(req.params.id);
if (!user) return res.status(404).json({ error: 'Benutzer nicht gefunden' });
db.prepare('DELETE FROM users WHERE id = ?').run(req.params.id);
res.json({ success: true });
});
// GET /api/admin/stats
router.get('/stats', (req, res) => {
const totalUsers = db.prepare('SELECT COUNT(*) as count FROM users').get().count;
const totalTrips = db.prepare('SELECT COUNT(*) as count FROM trips').get().count;
const totalPlaces = db.prepare('SELECT COUNT(*) as count FROM places').get().count;
const totalPhotos = db.prepare('SELECT COUNT(*) as count FROM photos').get().count;
const totalFiles = db.prepare('SELECT COUNT(*) as count FROM trip_files').get().count;
res.json({ totalUsers, totalTrips, totalPlaces, totalPhotos, totalFiles });
});
module.exports = router;

View File

@@ -0,0 +1,235 @@
const express = require('express');
const { db, canAccessTrip } = require('../db/database');
const { authenticate } = require('../middleware/auth');
const router = express.Router({ mergeParams: true });
function verifyTripOwnership(tripId, userId) {
return canAccessTrip(tripId, userId);
}
function getAssignmentWithPlace(assignmentId) {
const a = db.prepare(`
SELECT da.*, p.id as place_id, p.name as place_name, p.description as place_description,
p.lat, p.lng, p.address, p.category_id, p.price, p.currency as place_currency,
p.reservation_status, p.reservation_notes, p.reservation_datetime, p.place_time, p.duration_minutes, p.notes as place_notes,
p.image_url, p.transport_mode, p.google_place_id, p.website, p.phone,
c.name as category_name, c.color as category_color, c.icon as category_icon
FROM day_assignments da
JOIN places p ON da.place_id = p.id
LEFT JOIN categories c ON p.category_id = c.id
WHERE da.id = ?
`).get(assignmentId);
if (!a) return null;
const tags = db.prepare(`
SELECT t.* FROM tags t
JOIN place_tags pt ON t.id = pt.tag_id
WHERE pt.place_id = ?
`).all(a.place_id);
return {
id: a.id,
day_id: a.day_id,
order_index: a.order_index,
notes: a.notes,
created_at: a.created_at,
place: {
id: a.place_id,
name: a.place_name,
description: a.place_description,
lat: a.lat,
lng: a.lng,
address: a.address,
category_id: a.category_id,
price: a.price,
currency: a.place_currency,
reservation_status: a.reservation_status,
reservation_notes: a.reservation_notes,
reservation_datetime: a.reservation_datetime,
place_time: a.place_time,
duration_minutes: a.duration_minutes,
notes: a.place_notes,
image_url: a.image_url,
transport_mode: a.transport_mode,
google_place_id: a.google_place_id,
website: a.website,
phone: a.phone,
category: a.category_id ? {
id: a.category_id,
name: a.category_name,
color: a.category_color,
icon: a.category_icon,
} : null,
tags,
}
};
}
// GET /api/trips/:tripId/days/:dayId/assignments
router.get('/trips/:tripId/days/:dayId/assignments', authenticate, (req, res) => {
const { tripId, dayId } = req.params;
const trip = verifyTripOwnership(tripId, req.user.id);
if (!trip) return res.status(404).json({ error: 'Reise nicht gefunden' });
const day = db.prepare('SELECT id FROM days WHERE id = ? AND trip_id = ?').get(dayId, tripId);
if (!day) return res.status(404).json({ error: 'Tag nicht gefunden' });
const assignments = db.prepare(`
SELECT da.*, p.id as place_id, p.name as place_name, p.description as place_description,
p.lat, p.lng, p.address, p.category_id, p.price, p.currency as place_currency,
p.reservation_status, p.reservation_notes, p.reservation_datetime, p.place_time, p.duration_minutes, p.notes as place_notes,
p.image_url, p.transport_mode, p.google_place_id, p.website, p.phone,
c.name as category_name, c.color as category_color, c.icon as category_icon
FROM day_assignments da
JOIN places p ON da.place_id = p.id
LEFT JOIN categories c ON p.category_id = c.id
WHERE da.day_id = ?
ORDER BY da.order_index ASC, da.created_at ASC
`).all(dayId);
const result = assignments.map(a => {
const tags = db.prepare(`
SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?
`).all(a.place_id);
return {
id: a.id,
day_id: a.day_id,
order_index: a.order_index,
notes: a.notes,
created_at: a.created_at,
place: {
id: a.place_id,
name: a.place_name,
description: a.place_description,
lat: a.lat,
lng: a.lng,
address: a.address,
category_id: a.category_id,
price: a.price,
currency: a.place_currency,
reservation_status: a.reservation_status,
reservation_notes: a.reservation_notes,
reservation_datetime: a.reservation_datetime,
place_time: a.place_time,
duration_minutes: a.duration_minutes,
notes: a.place_notes,
image_url: a.image_url,
transport_mode: a.transport_mode,
google_place_id: a.google_place_id,
website: a.website,
phone: a.phone,
category: a.category_id ? {
id: a.category_id,
name: a.category_name,
color: a.category_color,
icon: a.category_icon,
} : null,
tags,
}
};
});
res.json({ assignments: result });
});
// POST /api/trips/:tripId/days/:dayId/assignments
router.post('/trips/:tripId/days/:dayId/assignments', authenticate, (req, res) => {
const { tripId, dayId } = req.params;
const { place_id, notes } = req.body;
const trip = verifyTripOwnership(tripId, req.user.id);
if (!trip) return res.status(404).json({ error: 'Reise nicht gefunden' });
const day = db.prepare('SELECT id FROM days WHERE id = ? AND trip_id = ?').get(dayId, tripId);
if (!day) return res.status(404).json({ error: 'Tag nicht gefunden' });
const place = db.prepare('SELECT id FROM places WHERE id = ? AND trip_id = ?').get(place_id, tripId);
if (!place) return res.status(404).json({ error: 'Ort nicht gefunden' });
// Check for duplicate
const existing = db.prepare('SELECT id FROM day_assignments WHERE day_id = ? AND place_id = ?').get(dayId, place_id);
if (existing) return res.status(409).json({ error: 'Ort ist bereits diesem Tag zugewiesen' });
const maxOrder = db.prepare('SELECT MAX(order_index) as max FROM day_assignments WHERE day_id = ?').get(dayId);
const orderIndex = (maxOrder.max !== null ? maxOrder.max : -1) + 1;
const result = db.prepare(
'INSERT INTO day_assignments (day_id, place_id, order_index, notes) VALUES (?, ?, ?, ?)'
).run(dayId, place_id, orderIndex, notes || null);
const assignment = getAssignmentWithPlace(result.lastInsertRowid);
res.status(201).json({ assignment });
});
// DELETE /api/trips/:tripId/days/:dayId/assignments/:id
router.delete('/trips/:tripId/days/:dayId/assignments/:id', authenticate, (req, res) => {
const { tripId, dayId, id } = req.params;
const trip = verifyTripOwnership(tripId, req.user.id);
if (!trip) return res.status(404).json({ error: 'Reise nicht gefunden' });
const assignment = db.prepare(
'SELECT da.id FROM day_assignments da JOIN days d ON da.day_id = d.id WHERE da.id = ? AND da.day_id = ? AND d.trip_id = ?'
).get(id, dayId, tripId);
if (!assignment) return res.status(404).json({ error: 'Zuweisung nicht gefunden' });
db.prepare('DELETE FROM day_assignments WHERE id = ?').run(id);
res.json({ success: true });
});
// PUT /api/trips/:tripId/days/:dayId/assignments/reorder
router.put('/trips/:tripId/days/:dayId/assignments/reorder', authenticate, (req, res) => {
const { tripId, dayId } = req.params;
const { orderedIds } = req.body;
const trip = verifyTripOwnership(tripId, req.user.id);
if (!trip) return res.status(404).json({ error: 'Reise nicht gefunden' });
const day = db.prepare('SELECT id FROM days WHERE id = ? AND trip_id = ?').get(dayId, tripId);
if (!day) return res.status(404).json({ error: 'Tag nicht gefunden' });
const update = db.prepare('UPDATE day_assignments SET order_index = ? WHERE id = ? AND day_id = ?');
db.exec('BEGIN');
try {
orderedIds.forEach((id, index) => {
update.run(index, id, dayId);
});
db.exec('COMMIT');
} catch (e) {
db.exec('ROLLBACK');
throw e;
}
res.json({ success: true });
});
// PUT /api/trips/:tripId/assignments/:id/move
router.put('/trips/:tripId/assignments/:id/move', authenticate, (req, res) => {
const { tripId, id } = req.params;
const { new_day_id, order_index } = req.body;
const trip = verifyTripOwnership(tripId, req.user.id);
if (!trip) return res.status(404).json({ error: 'Reise nicht gefunden' });
const assignment = db.prepare(`
SELECT da.* FROM day_assignments da
JOIN days d ON da.day_id = d.id
WHERE da.id = ? AND d.trip_id = ?
`).get(id, tripId);
if (!assignment) return res.status(404).json({ error: 'Zuweisung nicht gefunden' });
const newDay = db.prepare('SELECT id FROM days WHERE id = ? AND trip_id = ?').get(new_day_id, tripId);
if (!newDay) return res.status(404).json({ error: 'Zieltag nicht gefunden' });
db.prepare('UPDATE day_assignments SET day_id = ?, order_index = ? WHERE id = ?').run(new_day_id, order_index || 0, id);
const updated = getAssignmentWithPlace(id);
res.json({ assignment: updated });
});
module.exports = router;

390
server/src/routes/auth.js Normal file
View File

@@ -0,0 +1,390 @@
const express = require('express');
const bcrypt = require('bcryptjs');
const jwt = require('jsonwebtoken');
const multer = require('multer');
const path = require('path');
const fs = require('fs');
const { v4: uuid } = require('uuid');
const fetch = require('node-fetch');
const { db } = require('../db/database');
const { authenticate } = require('../middleware/auth');
const router = express.Router();
const { JWT_SECRET } = require('../config');
const avatarDir = path.join(__dirname, '../../uploads/avatars');
if (!fs.existsSync(avatarDir)) fs.mkdirSync(avatarDir, { recursive: true });
const avatarStorage = multer.diskStorage({
destination: (req, file, cb) => cb(null, avatarDir),
filename: (req, file, cb) => cb(null, uuid() + path.extname(file.originalname))
});
const avatarUpload = multer({ storage: avatarStorage, limits: { fileSize: 5 * 1024 * 1024 }, fileFilter: (req, file, cb) => {
if (file.mimetype.startsWith('image/')) cb(null, true);
else cb(new Error('Only images allowed'));
}});
// Simple rate limiter
const loginAttempts = new Map();
function rateLimiter(maxAttempts, windowMs) {
return (req, res, next) => {
const key = req.ip;
const now = Date.now();
const record = loginAttempts.get(key);
if (record && record.count >= maxAttempts && now - record.first < windowMs) {
return res.status(429).json({ error: 'Too many attempts. Please try again later.' });
}
if (!record || now - record.first >= windowMs) {
loginAttempts.set(key, { count: 1, first: now });
} else {
record.count++;
}
next();
};
}
const authLimiter = rateLimiter(10, 15 * 60 * 1000); // 10 attempts per 15 minutes
function avatarUrl(user) {
return user.avatar ? `/uploads/avatars/${user.avatar}` : null;
}
function generateToken(user) {
return jwt.sign(
{ id: user.id, username: user.username, email: user.email, role: user.role },
JWT_SECRET,
{ expiresIn: '24h' }
);
}
// GET /api/auth/app-config (public — no auth needed)
router.get('/app-config', (req, res) => {
const userCount = db.prepare('SELECT COUNT(*) as count FROM users').get().count;
const setting = db.prepare("SELECT value FROM app_settings WHERE key = 'allow_registration'").get();
const allowRegistration = userCount === 0 || (setting?.value ?? 'true') === 'true';
res.json({ allow_registration: allowRegistration, has_users: userCount > 0 });
});
// POST /api/auth/register
router.post('/register', authLimiter, (req, res) => {
const { username, email, password } = req.body;
// Check if registration is allowed
const userCount = db.prepare('SELECT COUNT(*) as count FROM users').get().count;
if (userCount > 0) {
const setting = db.prepare("SELECT value FROM app_settings WHERE key = 'allow_registration'").get();
if (setting?.value === 'false') {
return res.status(403).json({ error: 'Registration is disabled. Contact your administrator.' });
}
}
if (!username || !email || !password) {
return res.status(400).json({ error: 'Username, email and password are required' });
}
if (password.length < 8) {
return res.status(400).json({ error: 'Password must be at least 8 characters' });
}
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
return res.status(400).json({ error: 'Invalid email format' });
}
const existingUser = db.prepare('SELECT id FROM users WHERE email = ? OR username = ?').get(email, username);
if (existingUser) {
return res.status(409).json({ error: 'A user with this email or username already exists' });
}
const password_hash = bcrypt.hashSync(password, 10);
// First user becomes admin
const isFirstUser = userCount === 0;
const role = isFirstUser ? 'admin' : 'user';
try {
const result = db.prepare(
'INSERT INTO users (username, email, password_hash, role) VALUES (?, ?, ?, ?)'
).run(username, email, password_hash, role);
const user = { id: result.lastInsertRowid, username, email, role, avatar: null };
const token = generateToken(user);
res.status(201).json({ token, user: { ...user, avatar_url: null } });
} catch (err) {
res.status(500).json({ error: 'Fehler beim Erstellen des Benutzers' });
}
});
// POST /api/auth/login
router.post('/login', authLimiter, (req, res) => {
const { email, password } = req.body;
if (!email || !password) {
return res.status(400).json({ error: 'E-Mail und Passwort sind erforderlich' });
}
const user = db.prepare('SELECT * FROM users WHERE email = ?').get(email);
if (!user) {
return res.status(401).json({ error: 'Ungültige E-Mail oder Passwort' });
}
const validPassword = bcrypt.compareSync(password, user.password_hash);
if (!validPassword) {
return res.status(401).json({ error: 'Ungültige E-Mail oder Passwort' });
}
const token = generateToken(user);
const { password_hash, ...userWithoutPassword } = user;
res.json({ token, user: { ...userWithoutPassword, avatar_url: avatarUrl(user) } });
});
// GET /api/auth/me
router.get('/me', authenticate, (req, res) => {
const user = db.prepare(
'SELECT id, username, email, role, maps_api_key, openweather_api_key, avatar, created_at FROM users WHERE id = ?'
).get(req.user.id);
if (!user) {
return res.status(404).json({ error: 'Benutzer nicht gefunden' });
}
res.json({ user: { ...user, avatar_url: avatarUrl(user) } });
});
// PUT /api/auth/me/maps-key
router.put('/me/maps-key', authenticate, (req, res) => {
const { maps_api_key } = req.body;
db.prepare(
'UPDATE users SET maps_api_key = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?'
).run(maps_api_key || null, req.user.id);
res.json({ success: true, maps_api_key: maps_api_key || null });
});
// PUT /api/auth/me/api-keys
router.put('/me/api-keys', authenticate, (req, res) => {
const { maps_api_key, openweather_api_key } = req.body;
db.prepare(
'UPDATE users SET maps_api_key = ?, openweather_api_key = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?'
).run(
maps_api_key !== undefined ? (maps_api_key || null) : req.user.maps_api_key,
openweather_api_key !== undefined ? (openweather_api_key || null) : req.user.openweather_api_key,
req.user.id
);
const updated = db.prepare(
'SELECT id, username, email, role, maps_api_key, openweather_api_key, avatar FROM users WHERE id = ?'
).get(req.user.id);
res.json({ success: true, user: { ...updated, avatar_url: avatarUrl(updated) } });
});
// PUT /api/auth/me/settings
router.put('/me/settings', authenticate, (req, res) => {
const { maps_api_key, openweather_api_key, username, email } = req.body;
const updates = [];
const params = [];
if (maps_api_key !== undefined) { updates.push('maps_api_key = ?'); params.push(maps_api_key || null); }
if (openweather_api_key !== undefined) { updates.push('openweather_api_key = ?'); params.push(openweather_api_key || null); }
if (username !== undefined) { updates.push('username = ?'); params.push(username); }
if (email !== undefined) { updates.push('email = ?'); params.push(email); }
if (updates.length > 0) {
updates.push('updated_at = CURRENT_TIMESTAMP');
params.push(req.user.id);
db.prepare(`UPDATE users SET ${updates.join(', ')} WHERE id = ?`).run(...params);
}
const updated = db.prepare(
'SELECT id, username, email, role, maps_api_key, openweather_api_key, avatar FROM users WHERE id = ?'
).get(req.user.id);
res.json({ success: true, user: { ...updated, avatar_url: avatarUrl(updated) } });
});
// GET /api/auth/me/settings
router.get('/me/settings', authenticate, (req, res) => {
const user = db.prepare(
'SELECT maps_api_key, openweather_api_key FROM users WHERE id = ?'
).get(req.user.id);
res.json({ settings: user });
});
// POST /api/auth/avatar — upload avatar
router.post('/avatar', authenticate, avatarUpload.single('avatar'), (req, res) => {
if (!req.file) return res.status(400).json({ error: 'No image uploaded' });
const current = db.prepare('SELECT avatar FROM users WHERE id = ?').get(req.user.id);
if (current && current.avatar) {
const oldPath = path.join(avatarDir, current.avatar);
if (fs.existsSync(oldPath)) fs.unlinkSync(oldPath);
}
const filename = req.file.filename;
db.prepare('UPDATE users SET avatar = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(filename, req.user.id);
const updated = db.prepare('SELECT id, username, email, role, avatar FROM users WHERE id = ?').get(req.user.id);
res.json({ success: true, avatar_url: avatarUrl(updated) });
});
// DELETE /api/auth/avatar — remove avatar
router.delete('/avatar', authenticate, (req, res) => {
const current = db.prepare('SELECT avatar FROM users WHERE id = ?').get(req.user.id);
if (current && current.avatar) {
const filePath = path.join(avatarDir, current.avatar);
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
}
db.prepare('UPDATE users SET avatar = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(req.user.id);
res.json({ success: true });
});
// GET /api/auth/users — list all users (for sharing/inviting)
router.get('/users', authenticate, (req, res) => {
const users = db.prepare(
'SELECT id, username, avatar FROM users WHERE id != ? ORDER BY username ASC'
).all(req.user.id);
res.json({ users: users.map(u => ({ ...u, avatar_url: avatarUrl(u) })) });
});
// GET /api/auth/validate-keys (admin only)
router.get('/validate-keys', authenticate, async (req, res) => {
const user = db.prepare('SELECT role, maps_api_key, openweather_api_key FROM users WHERE id = ?').get(req.user.id);
if (user?.role !== 'admin') return res.status(403).json({ error: 'Admin access required' });
const result = { maps: false, weather: false };
// Test Google Maps Places API
if (user.maps_api_key) {
try {
const mapsRes = await fetch(
`https://places.googleapis.com/v1/places:searchText`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Goog-Api-Key': user.maps_api_key,
'X-Goog-FieldMask': 'places.displayName',
},
body: JSON.stringify({ textQuery: 'test' }),
}
);
result.maps = mapsRes.status === 200;
} catch (err) {
result.maps = false;
}
}
// Test OpenWeatherMap API
if (user.openweather_api_key) {
try {
const weatherRes = await fetch(
`https://api.openweathermap.org/data/2.5/weather?q=London&appid=${user.openweather_api_key}`
);
result.weather = weatherRes.status === 200;
} catch (err) {
result.weather = false;
}
}
res.json(result);
});
// PUT /api/auth/app-settings (admin only)
router.put('/app-settings', authenticate, (req, res) => {
const user = db.prepare('SELECT role FROM users WHERE id = ?').get(req.user.id);
if (user?.role !== 'admin') return res.status(403).json({ error: 'Admin access required' });
const { allow_registration } = req.body;
if (allow_registration !== undefined) {
db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allow_registration', ?)").run(String(allow_registration));
}
res.json({ success: true });
});
// GET /api/auth/travel-stats — aggregated travel statistics for current user
router.get('/travel-stats', authenticate, (req, res) => {
const userId = req.user.id;
// Get all places from user's trips (owned + shared)
const places = db.prepare(`
SELECT DISTINCT p.address, p.lat, p.lng
FROM places p
JOIN trips t ON p.trip_id = t.id
LEFT JOIN trip_members tm ON t.id = tm.trip_id
WHERE t.user_id = ? OR tm.user_id = ?
`).all(userId, userId);
// Get trip count + total days
const tripStats = db.prepare(`
SELECT COUNT(DISTINCT t.id) as trips,
COUNT(DISTINCT d.id) as days
FROM trips t
LEFT JOIN days d ON t.id = d.id
LEFT JOIN trip_members tm ON t.id = tm.trip_id
WHERE (t.user_id = ? OR tm.user_id = ?) AND t.is_archived = 0
`).get(userId, userId);
// Known country names (EN + DE)
const KNOWN_COUNTRIES = new Set([
'Japan', 'Germany', 'Deutschland', 'France', 'Frankreich', 'Italy', 'Italien', 'Spain', 'Spanien',
'United States', 'USA', 'United Kingdom', 'UK', 'Thailand', 'Australia', 'Australien',
'Canada', 'Kanada', 'Mexico', 'Mexiko', 'Brazil', 'Brasilien', 'China', 'India', 'Indien',
'South Korea', 'Südkorea', 'Indonesia', 'Indonesien', 'Turkey', 'Türkei', 'Türkiye',
'Greece', 'Griechenland', 'Portugal', 'Netherlands', 'Niederlande', 'Belgium', 'Belgien',
'Switzerland', 'Schweiz', 'Austria', 'Österreich', 'Sweden', 'Schweden', 'Norway', 'Norwegen',
'Denmark', 'Dänemark', 'Finland', 'Finnland', 'Poland', 'Polen', 'Czech Republic', 'Tschechien',
'Czechia', 'Hungary', 'Ungarn', 'Croatia', 'Kroatien', 'Romania', 'Rumänien',
'Ireland', 'Irland', 'Iceland', 'Island', 'New Zealand', 'Neuseeland',
'Singapore', 'Singapur', 'Malaysia', 'Vietnam', 'Philippines', 'Philippinen',
'Egypt', 'Ägypten', 'Morocco', 'Marokko', 'South Africa', 'Südafrika', 'Kenya', 'Kenia',
'Argentina', 'Argentinien', 'Chile', 'Colombia', 'Kolumbien', 'Peru',
'Russia', 'Russland', 'United Arab Emirates', 'UAE', 'Vereinigte Arabische Emirate',
'Israel', 'Jordan', 'Jordanien', 'Taiwan', 'Hong Kong', 'Hongkong',
'Cuba', 'Kuba', 'Costa Rica', 'Panama', 'Ecuador', 'Bolivia', 'Bolivien', 'Uruguay', 'Paraguay',
'Luxembourg', 'Luxemburg', 'Malta', 'Cyprus', 'Zypern', 'Estonia', 'Estland',
'Latvia', 'Lettland', 'Lithuania', 'Litauen', 'Slovakia', 'Slowakei', 'Slovenia', 'Slowenien',
'Bulgaria', 'Bulgarien', 'Serbia', 'Serbien', 'Montenegro', 'Albania', 'Albanien',
'Sri Lanka', 'Nepal', 'Cambodia', 'Kambodscha', 'Laos', 'Myanmar', 'Mongolia', 'Mongolei',
'Saudi Arabia', 'Saudi-Arabien', 'Qatar', 'Katar', 'Oman', 'Bahrain', 'Kuwait',
'Tanzania', 'Tansania', 'Ethiopia', 'Äthiopien', 'Nigeria', 'Ghana', 'Tunisia', 'Tunesien',
'Dominican Republic', 'Dominikanische Republik', 'Jamaica', 'Jamaika',
'Ukraine', 'Georgia', 'Georgien', 'Armenia', 'Armenien', 'Pakistan', 'Bangladesh', 'Bangladesch',
'Senegal', 'Mozambique', 'Mosambik', 'Moldova', 'Moldawien', 'Belarus', 'Weißrussland',
]);
// Extract countries from addresses — only accept known country names
const countries = new Set();
const cities = new Set();
const coords = [];
places.forEach(p => {
if (p.lat && p.lng) coords.push({ lat: p.lat, lng: p.lng });
if (p.address) {
const parts = p.address.split(',').map(s => s.trim().replace(/\d{3,}/g, '').trim());
for (const part of parts) {
if (KNOWN_COUNTRIES.has(part)) { countries.add(part); break; }
}
// City: first part that's not the country and looks like a name (Latin chars, > 2 chars)
const cityPart = parts.find(s => !KNOWN_COUNTRIES.has(s) && /^[A-Za-zÀ-ÿ\s-]{2,}$/.test(s));
if (cityPart) cities.add(cityPart);
}
});
res.json({
countries: [...countries],
cities: [...cities],
coords,
totalTrips: tripStats?.trips || 0,
totalDays: tripStats?.days || 0,
totalPlaces: places.length,
});
});
module.exports = router;

233
server/src/routes/backup.js Normal file
View File

@@ -0,0 +1,233 @@
const express = require('express');
const archiver = require('archiver');
const unzipper = require('unzipper');
const multer = require('multer');
const path = require('path');
const fs = require('fs');
const { authenticate, adminOnly } = require('../middleware/auth');
const scheduler = require('../scheduler');
const { db, closeDb, reinitialize } = require('../db/database');
const router = express.Router();
// All backup routes require admin
router.use(authenticate, adminOnly);
const dataDir = path.join(__dirname, '../../data');
const backupsDir = path.join(dataDir, 'backups');
const uploadsDir = path.join(__dirname, '../../uploads');
function ensureBackupsDir() {
if (!fs.existsSync(backupsDir)) fs.mkdirSync(backupsDir, { recursive: true });
}
function formatSize(bytes) {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
// GET /api/backup/list
router.get('/list', (req, res) => {
ensureBackupsDir();
try {
const files = fs.readdirSync(backupsDir)
.filter(f => f.endsWith('.zip'))
.map(filename => {
const filePath = path.join(backupsDir, filename);
const stat = fs.statSync(filePath);
return {
filename,
size: stat.size,
sizeText: formatSize(stat.size),
created_at: stat.birthtime.toISOString(),
};
})
.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
res.json({ backups: files });
} catch (err) {
res.status(500).json({ error: 'Fehler beim Laden der Backups' });
}
});
// POST /api/backup/create
router.post('/create', async (req, res) => {
ensureBackupsDir();
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
const filename = `backup-${timestamp}.zip`;
const outputPath = path.join(backupsDir, filename);
try {
// Flush WAL to main DB file before archiving so all data is captured
try { db.exec('PRAGMA wal_checkpoint(TRUNCATE)'); } catch (e) {}
await new Promise((resolve, reject) => {
const output = fs.createWriteStream(outputPath);
const archive = archiver('zip', { zlib: { level: 9 } });
output.on('close', resolve);
archive.on('error', reject);
archive.pipe(output);
// Add database
const dbPath = path.join(dataDir, 'travel.db');
if (fs.existsSync(dbPath)) {
archive.file(dbPath, { name: 'travel.db' });
}
// Add uploads directory
if (fs.existsSync(uploadsDir)) {
archive.directory(uploadsDir, 'uploads');
}
archive.finalize();
});
const stat = fs.statSync(outputPath);
res.json({
success: true,
backup: {
filename,
size: stat.size,
sizeText: formatSize(stat.size),
created_at: stat.birthtime.toISOString(),
}
});
} catch (err) {
console.error('Backup error:', err);
if (fs.existsSync(outputPath)) fs.unlinkSync(outputPath);
res.status(500).json({ error: 'Fehler beim Erstellen des Backups' });
}
});
// GET /api/backup/download/:filename
router.get('/download/:filename', (req, res) => {
const { filename } = req.params;
// Security: prevent path traversal
if (!/^backup-[\w\-]+\.zip$/.test(filename)) {
return res.status(400).json({ error: 'Invalid filename' });
}
const filePath = path.join(backupsDir, filename);
if (!fs.existsSync(filePath)) {
return res.status(404).json({ error: 'Backup nicht gefunden' });
}
res.download(filePath, filename);
});
// Helper: restore from a zip file path
async function restoreFromZip(zipPath, res) {
const extractDir = path.join(dataDir, `restore-${Date.now()}`);
try {
await fs.createReadStream(zipPath)
.pipe(unzipper.Extract({ path: extractDir }))
.promise();
const extractedDb = path.join(extractDir, 'travel.db');
if (!fs.existsSync(extractedDb)) {
fs.rmSync(extractDir, { recursive: true, force: true });
return res.status(400).json({ error: 'Ungültiges Backup: travel.db nicht gefunden' });
}
// Step 1: close DB connection BEFORE touching the file (required on Windows)
closeDb();
// Step 2: remove WAL/SHM and overwrite DB file
const dbDest = path.join(dataDir, 'travel.db');
for (const ext of ['', '-wal', '-shm']) {
try { fs.unlinkSync(dbDest + ext); } catch (e) {}
}
fs.copyFileSync(extractedDb, dbDest);
// Step 3: restore uploads
const extractedUploads = path.join(extractDir, 'uploads');
if (fs.existsSync(extractedUploads)) {
if (fs.existsSync(uploadsDir)) fs.rmSync(uploadsDir, { recursive: true, force: true });
fs.cpSync(extractedUploads, uploadsDir, { recursive: true });
}
fs.rmSync(extractDir, { recursive: true, force: true });
// Step 4: reopen DB with restored data
reinitialize();
res.json({ success: true });
} catch (err) {
console.error('Restore error:', err);
if (fs.existsSync(extractDir)) fs.rmSync(extractDir, { recursive: true, force: true });
if (!res.headersSent) res.status(500).json({ error: err.message || 'Fehler beim Wiederherstellen' });
}
}
// POST /api/backup/restore/:filename - restore from stored backup
router.post('/restore/:filename', async (req, res) => {
const { filename } = req.params;
if (!/^backup-[\w\-]+\.zip$/.test(filename)) {
return res.status(400).json({ error: 'Invalid filename' });
}
const zipPath = path.join(backupsDir, filename);
if (!fs.existsSync(zipPath)) {
return res.status(404).json({ error: 'Backup nicht gefunden' });
}
await restoreFromZip(zipPath, res);
});
// POST /api/backup/upload-restore - upload a zip and restore
const uploadTmp = multer({
dest: path.join(dataDir, 'tmp/'),
fileFilter: (req, file, cb) => {
if (file.originalname.endsWith('.zip')) cb(null, true);
else cb(new Error('Nur ZIP-Dateien erlaubt'));
},
limits: { fileSize: 500 * 1024 * 1024 },
});
router.post('/upload-restore', uploadTmp.single('backup'), async (req, res) => {
if (!req.file) return res.status(400).json({ error: 'Keine Datei hochgeladen' });
const zipPath = req.file.path;
await restoreFromZip(zipPath, res);
if (fs.existsSync(zipPath)) fs.unlinkSync(zipPath);
});
// GET /api/backup/auto-settings
router.get('/auto-settings', (req, res) => {
res.json({ settings: scheduler.loadSettings() });
});
// PUT /api/backup/auto-settings
router.put('/auto-settings', (req, res) => {
const { enabled, interval, keep_days } = req.body;
const settings = {
enabled: !!enabled,
interval: scheduler.VALID_INTERVALS.includes(interval) ? interval : 'daily',
keep_days: Number.isInteger(keep_days) && keep_days >= 0 ? keep_days : 7,
};
scheduler.saveSettings(settings);
scheduler.start();
res.json({ settings });
});
// DELETE /api/backup/:filename
router.delete('/:filename', (req, res) => {
const { filename } = req.params;
if (!/^backup-[\w\-]+\.zip$/.test(filename)) {
return res.status(400).json({ error: 'Invalid filename' });
}
const filePath = path.join(backupsDir, filename);
if (!fs.existsSync(filePath)) {
return res.status(404).json({ error: 'Backup nicht gefunden' });
}
fs.unlinkSync(filePath);
res.json({ success: true });
});
module.exports = router;

105
server/src/routes/budget.js Normal file
View File

@@ -0,0 +1,105 @@
const express = require('express');
const { db, canAccessTrip } = require('../db/database');
const { authenticate } = require('../middleware/auth');
const router = express.Router({ mergeParams: true });
function verifyTripOwnership(tripId, userId) {
return canAccessTrip(tripId, userId);
}
// GET /api/trips/:tripId/budget
router.get('/', authenticate, (req, res) => {
const { tripId } = req.params;
const trip = verifyTripOwnership(tripId, req.user.id);
if (!trip) return res.status(404).json({ error: 'Reise nicht gefunden' });
const items = db.prepare(
'SELECT * FROM budget_items WHERE trip_id = ? ORDER BY category ASC, created_at ASC'
).all(tripId);
res.json({ items });
});
// POST /api/trips/:tripId/budget
router.post('/', authenticate, (req, res) => {
const { tripId } = req.params;
const { category, name, total_price, persons, days, note } = req.body;
const trip = verifyTripOwnership(tripId, req.user.id);
if (!trip) return res.status(404).json({ error: 'Reise nicht gefunden' });
if (!name) return res.status(400).json({ error: 'Name ist erforderlich' });
const maxOrder = db.prepare('SELECT MAX(sort_order) as max FROM budget_items WHERE trip_id = ?').get(tripId);
const sortOrder = (maxOrder.max !== null ? maxOrder.max : -1) + 1;
const result = db.prepare(
'INSERT INTO budget_items (trip_id, category, name, total_price, persons, days, note, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
).run(
tripId,
category || 'Sonstiges',
name,
total_price || 0,
persons != null ? persons : null,
days !== undefined && days !== null ? days : null,
note || null,
sortOrder
);
const item = db.prepare('SELECT * FROM budget_items WHERE id = ?').get(result.lastInsertRowid);
res.status(201).json({ item });
});
// PUT /api/trips/:tripId/budget/:id
router.put('/:id', authenticate, (req, res) => {
const { tripId, id } = req.params;
const { category, name, total_price, persons, days, note, sort_order } = req.body;
const trip = verifyTripOwnership(tripId, req.user.id);
if (!trip) return res.status(404).json({ error: 'Reise nicht gefunden' });
const item = db.prepare('SELECT * FROM budget_items WHERE id = ? AND trip_id = ?').get(id, tripId);
if (!item) return res.status(404).json({ error: 'Budget-Eintrag nicht gefunden' });
db.prepare(`
UPDATE budget_items SET
category = COALESCE(?, category),
name = COALESCE(?, name),
total_price = CASE WHEN ? IS NOT NULL THEN ? ELSE total_price END,
persons = CASE WHEN ? IS NOT NULL THEN ? ELSE persons END,
days = CASE WHEN ? THEN ? ELSE days END,
note = CASE WHEN ? THEN ? ELSE note END,
sort_order = CASE WHEN ? IS NOT NULL THEN ? ELSE sort_order END
WHERE id = ?
`).run(
category || null,
name || null,
total_price !== undefined ? 1 : null, total_price !== undefined ? total_price : 0,
persons !== undefined ? 1 : null, persons !== undefined ? persons : null,
days !== undefined ? 1 : 0, days !== undefined ? days : null,
note !== undefined ? 1 : 0, note !== undefined ? note : null,
sort_order !== undefined ? 1 : null, sort_order !== undefined ? sort_order : 0,
id
);
const updated = db.prepare('SELECT * FROM budget_items WHERE id = ?').get(id);
res.json({ item: updated });
});
// DELETE /api/trips/:tripId/budget/:id
router.delete('/:id', authenticate, (req, res) => {
const { tripId, id } = req.params;
const trip = verifyTripOwnership(tripId, req.user.id);
if (!trip) return res.status(404).json({ error: 'Reise nicht gefunden' });
const item = db.prepare('SELECT id FROM budget_items WHERE id = ? AND trip_id = ?').get(id, tripId);
if (!item) return res.status(404).json({ error: 'Budget-Eintrag nicht gefunden' });
db.prepare('DELETE FROM budget_items WHERE id = ?').run(id);
res.json({ success: true });
});
module.exports = router;

View File

@@ -0,0 +1,58 @@
const express = require('express');
const { db } = require('../db/database');
const { authenticate, adminOnly } = require('../middleware/auth');
const router = express.Router();
// GET /api/categories - public to all authenticated users
router.get('/', authenticate, (req, res) => {
const categories = db.prepare(
'SELECT * FROM categories ORDER BY name ASC'
).all();
res.json({ categories });
});
// POST /api/categories - admin only
router.post('/', authenticate, adminOnly, (req, res) => {
const { name, color, icon } = req.body;
if (!name) return res.status(400).json({ error: 'Kategoriename ist erforderlich' });
const result = db.prepare(
'INSERT INTO categories (name, color, icon, user_id) VALUES (?, ?, ?, ?)'
).run(name, color || '#6366f1', icon || '📍', req.user.id);
const category = db.prepare('SELECT * FROM categories WHERE id = ?').get(result.lastInsertRowid);
res.status(201).json({ category });
});
// PUT /api/categories/:id - admin only
router.put('/:id', authenticate, adminOnly, (req, res) => {
const { name, color, icon } = req.body;
const category = db.prepare('SELECT * FROM categories WHERE id = ?').get(req.params.id);
if (!category) return res.status(404).json({ error: 'Kategorie nicht gefunden' });
db.prepare(`
UPDATE categories SET
name = COALESCE(?, name),
color = COALESCE(?, color),
icon = COALESCE(?, icon)
WHERE id = ?
`).run(name || null, color || null, icon || null, req.params.id);
const updated = db.prepare('SELECT * FROM categories WHERE id = ?').get(req.params.id);
res.json({ category: updated });
});
// DELETE /api/categories/:id - admin only
router.delete('/:id', authenticate, adminOnly, (req, res) => {
const category = db.prepare('SELECT * FROM categories WHERE id = ?').get(req.params.id);
if (!category) return res.status(404).json({ error: 'Kategorie nicht gefunden' });
db.prepare('DELETE FROM categories WHERE id = ?').run(req.params.id);
res.json({ success: true });
});
module.exports = router;

View File

@@ -0,0 +1,77 @@
const express = require('express');
const { db, canAccessTrip } = require('../db/database');
const { authenticate } = require('../middleware/auth');
const router = express.Router({ mergeParams: true });
function verifyAccess(tripId, userId) {
return canAccessTrip(tripId, userId);
}
// GET /api/trips/:tripId/days/:dayId/notes
router.get('/', authenticate, (req, res) => {
const { tripId, dayId } = req.params;
if (!verifyAccess(tripId, req.user.id)) return res.status(404).json({ error: 'Reise nicht gefunden' });
const notes = db.prepare(
'SELECT * FROM day_notes WHERE day_id = ? AND trip_id = ? ORDER BY sort_order ASC, created_at ASC'
).all(dayId, tripId);
res.json({ notes });
});
// POST /api/trips/:tripId/days/:dayId/notes
router.post('/', authenticate, (req, res) => {
const { tripId, dayId } = req.params;
if (!verifyAccess(tripId, req.user.id)) return res.status(404).json({ error: 'Reise nicht gefunden' });
const day = db.prepare('SELECT id FROM days WHERE id = ? AND trip_id = ?').get(dayId, tripId);
if (!day) return res.status(404).json({ error: 'Tag nicht gefunden' });
const { text, time, icon, sort_order } = req.body;
if (!text?.trim()) return res.status(400).json({ error: 'Text erforderlich' });
const result = db.prepare(
'INSERT INTO day_notes (day_id, trip_id, text, time, icon, sort_order) VALUES (?, ?, ?, ?, ?, ?)'
).run(dayId, tripId, text.trim(), time || null, icon || '📝', sort_order ?? 9999);
const note = db.prepare('SELECT * FROM day_notes WHERE id = ?').get(result.lastInsertRowid);
res.status(201).json({ note });
});
// PUT /api/trips/:tripId/days/:dayId/notes/:id
router.put('/:id', authenticate, (req, res) => {
const { tripId, dayId, id } = req.params;
if (!verifyAccess(tripId, req.user.id)) return res.status(404).json({ error: 'Reise nicht gefunden' });
const note = db.prepare('SELECT * FROM day_notes WHERE id = ? AND day_id = ? AND trip_id = ?').get(id, dayId, tripId);
if (!note) return res.status(404).json({ error: 'Notiz nicht gefunden' });
const { text, time, icon, sort_order } = req.body;
db.prepare(
'UPDATE day_notes SET text = ?, time = ?, icon = ?, sort_order = ? WHERE id = ?'
).run(
text !== undefined ? text.trim() : note.text,
time !== undefined ? time : note.time,
icon !== undefined ? icon : note.icon,
sort_order !== undefined ? sort_order : note.sort_order,
id
);
const updated = db.prepare('SELECT * FROM day_notes WHERE id = ?').get(id);
res.json({ note: updated });
});
// DELETE /api/trips/:tripId/days/:dayId/notes/:id
router.delete('/:id', authenticate, (req, res) => {
const { tripId, dayId, id } = req.params;
if (!verifyAccess(tripId, req.user.id)) return res.status(404).json({ error: 'Reise nicht gefunden' });
const note = db.prepare('SELECT id FROM day_notes WHERE id = ? AND day_id = ? AND trip_id = ?').get(id, dayId, tripId);
if (!note) return res.status(404).json({ error: 'Notiz nicht gefunden' });
db.prepare('DELETE FROM day_notes WHERE id = ?').run(id);
res.json({ success: true });
});
module.exports = router;

155
server/src/routes/days.js Normal file
View File

@@ -0,0 +1,155 @@
const express = require('express');
const { db, canAccessTrip } = require('../db/database');
const { authenticate } = require('../middleware/auth');
const router = express.Router({ mergeParams: true });
function verifyTripOwnership(tripId, userId) {
return canAccessTrip(tripId, userId);
}
function getAssignmentsForDay(dayId) {
const assignments = db.prepare(`
SELECT da.*, p.id as place_id, p.name as place_name, p.description as place_description,
p.lat, p.lng, p.address, p.category_id, p.price, p.currency as place_currency,
p.reservation_status, p.reservation_notes, p.reservation_datetime, p.place_time, p.duration_minutes, p.notes as place_notes,
p.image_url, p.transport_mode, p.google_place_id, p.website, p.phone,
c.name as category_name, c.color as category_color, c.icon as category_icon
FROM day_assignments da
JOIN places p ON da.place_id = p.id
LEFT JOIN categories c ON p.category_id = c.id
WHERE da.day_id = ?
ORDER BY da.order_index ASC, da.created_at ASC
`).all(dayId);
return assignments.map(a => {
const tags = db.prepare(`
SELECT t.* FROM tags t
JOIN place_tags pt ON t.id = pt.tag_id
WHERE pt.place_id = ?
`).all(a.place_id);
return {
id: a.id,
day_id: a.day_id,
order_index: a.order_index,
notes: a.notes,
created_at: a.created_at,
place: {
id: a.place_id,
name: a.place_name,
description: a.place_description,
lat: a.lat,
lng: a.lng,
address: a.address,
category_id: a.category_id,
price: a.price,
currency: a.place_currency,
reservation_status: a.reservation_status,
reservation_notes: a.reservation_notes,
reservation_datetime: a.reservation_datetime,
place_time: a.place_time,
duration_minutes: a.duration_minutes,
notes: a.place_notes,
image_url: a.image_url,
transport_mode: a.transport_mode,
google_place_id: a.google_place_id,
website: a.website,
phone: a.phone,
category: a.category_id ? {
id: a.category_id,
name: a.category_name,
color: a.category_color,
icon: a.category_icon,
} : null,
tags,
}
};
});
}
// GET /api/trips/:tripId/days
router.get('/', authenticate, (req, res) => {
const { tripId } = req.params;
const trip = verifyTripOwnership(tripId, req.user.id);
if (!trip) {
return res.status(404).json({ error: 'Reise nicht gefunden' });
}
const days = db.prepare('SELECT * FROM days WHERE trip_id = ? ORDER BY day_number ASC').all(tripId);
const daysWithAssignments = days.map(day => ({
...day,
assignments: getAssignmentsForDay(day.id),
notes_items: db.prepare(
'SELECT * FROM day_notes WHERE day_id = ? ORDER BY sort_order ASC, created_at ASC'
).all(day.id),
}));
res.json({ days: daysWithAssignments });
});
// POST /api/trips/:tripId/days
router.post('/', authenticate, (req, res) => {
const { tripId } = req.params;
const trip = verifyTripOwnership(tripId, req.user.id);
if (!trip) {
return res.status(404).json({ error: 'Reise nicht gefunden' });
}
const { date, notes } = req.body;
const maxDay = db.prepare('SELECT MAX(day_number) as max FROM days WHERE trip_id = ?').get(tripId);
const dayNumber = (maxDay.max || 0) + 1;
const result = db.prepare(
'INSERT INTO days (trip_id, day_number, date, notes) VALUES (?, ?, ?, ?)'
).run(tripId, dayNumber, date || null, notes || null);
const day = db.prepare('SELECT * FROM days WHERE id = ?').get(result.lastInsertRowid);
res.status(201).json({ day: { ...day, assignments: [] } });
});
// PUT /api/trips/:tripId/days/:id
router.put('/:id', authenticate, (req, res) => {
const { tripId, id } = req.params;
const trip = verifyTripOwnership(tripId, req.user.id);
if (!trip) {
return res.status(404).json({ error: 'Reise nicht gefunden' });
}
const day = db.prepare('SELECT * FROM days WHERE id = ? AND trip_id = ?').get(id, tripId);
if (!day) {
return res.status(404).json({ error: 'Tag nicht gefunden' });
}
const { notes, title } = req.body;
db.prepare('UPDATE days SET notes = ?, title = ? WHERE id = ?').run(notes || null, title !== undefined ? title : day.title, id);
const updatedDay = db.prepare('SELECT * FROM days WHERE id = ?').get(id);
res.json({ day: { ...updatedDay, assignments: getAssignmentsForDay(id) } });
});
// DELETE /api/trips/:tripId/days/:id
router.delete('/:id', authenticate, (req, res) => {
const { tripId, id } = req.params;
const trip = verifyTripOwnership(tripId, req.user.id);
if (!trip) {
return res.status(404).json({ error: 'Reise nicht gefunden' });
}
const day = db.prepare('SELECT * FROM days WHERE id = ? AND trip_id = ?').get(id, tripId);
if (!day) {
return res.status(404).json({ error: 'Tag nicht gefunden' });
}
db.prepare('DELETE FROM days WHERE id = ?').run(id);
res.json({ success: true });
});
module.exports = router;

163
server/src/routes/files.js Normal file
View File

@@ -0,0 +1,163 @@
const express = require('express');
const multer = require('multer');
const path = require('path');
const fs = require('fs');
const { v4: uuidv4 } = require('uuid');
const { db, canAccessTrip } = require('../db/database');
const { authenticate } = require('../middleware/auth');
const router = express.Router({ mergeParams: true });
const filesDir = path.join(__dirname, '../../uploads/files');
const storage = multer.diskStorage({
destination: (req, file, cb) => {
if (!fs.existsSync(filesDir)) fs.mkdirSync(filesDir, { recursive: true });
cb(null, filesDir);
},
filename: (req, file, cb) => {
const ext = path.extname(file.originalname);
cb(null, `${uuidv4()}${ext}`);
},
});
const upload = multer({
storage,
limits: { fileSize: 50 * 1024 * 1024 }, // 50MB
fileFilter: (req, file, cb) => {
const allowed = [
'application/pdf',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'text/plain',
'text/csv',
];
if (allowed.includes(file.mimetype) || file.mimetype.startsWith('image/')) {
cb(null, true);
} else {
cb(new Error('Dateityp nicht erlaubt'));
}
},
});
function verifyTripOwnership(tripId, userId) {
return canAccessTrip(tripId, userId);
}
function formatFile(file) {
return {
...file,
url: `/uploads/files/${file.filename}`,
};
}
// GET /api/trips/:tripId/files
router.get('/', authenticate, (req, res) => {
const { tripId } = req.params;
const trip = verifyTripOwnership(tripId, req.user.id);
if (!trip) return res.status(404).json({ error: 'Reise nicht gefunden' });
const files = db.prepare(`
SELECT f.*, r.title as reservation_title
FROM trip_files f
LEFT JOIN reservations r ON f.reservation_id = r.id
WHERE f.trip_id = ?
ORDER BY f.created_at DESC
`).all(tripId);
res.json({ files: files.map(formatFile) });
});
// POST /api/trips/:tripId/files
router.post('/', authenticate, upload.single('file'), (req, res) => {
const { tripId } = req.params;
const { place_id, description, reservation_id } = req.body;
const trip = verifyTripOwnership(tripId, req.user.id);
if (!trip) {
if (req.file) fs.unlinkSync(req.file.path);
return res.status(404).json({ error: 'Reise nicht gefunden' });
}
if (!req.file) {
return res.status(400).json({ error: 'Keine Datei hochgeladen' });
}
const result = db.prepare(`
INSERT INTO trip_files (trip_id, place_id, reservation_id, filename, original_name, file_size, mime_type, description)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`).run(
tripId,
place_id || null,
reservation_id || null,
req.file.filename,
req.file.originalname,
req.file.size,
req.file.mimetype,
description || null
);
const file = db.prepare(`
SELECT f.*, r.title as reservation_title
FROM trip_files f
LEFT JOIN reservations r ON f.reservation_id = r.id
WHERE f.id = ?
`).get(result.lastInsertRowid);
res.status(201).json({ file: formatFile(file) });
});
// PUT /api/trips/:tripId/files/:id
router.put('/:id', authenticate, (req, res) => {
const { tripId, id } = req.params;
const { description, place_id, reservation_id } = req.body;
const trip = verifyTripOwnership(tripId, req.user.id);
if (!trip) return res.status(404).json({ error: 'Reise nicht gefunden' });
const file = db.prepare('SELECT * FROM trip_files WHERE id = ? AND trip_id = ?').get(id, tripId);
if (!file) return res.status(404).json({ error: 'Datei nicht gefunden' });
db.prepare(`
UPDATE trip_files SET
description = COALESCE(?, description),
place_id = ?,
reservation_id = ?
WHERE id = ?
`).run(
description !== undefined ? description : file.description,
place_id !== undefined ? (place_id || null) : file.place_id,
reservation_id !== undefined ? (reservation_id || null) : file.reservation_id,
id
);
const updated = db.prepare(`
SELECT f.*, r.title as reservation_title
FROM trip_files f
LEFT JOIN reservations r ON f.reservation_id = r.id
WHERE f.id = ?
`).get(id);
res.json({ file: formatFile(updated) });
});
// DELETE /api/trips/:tripId/files/:id
router.delete('/:id', authenticate, (req, res) => {
const { tripId, id } = req.params;
const trip = verifyTripOwnership(tripId, req.user.id);
if (!trip) return res.status(404).json({ error: 'Reise nicht gefunden' });
const file = db.prepare('SELECT * FROM trip_files WHERE id = ? AND trip_id = ?').get(id, tripId);
if (!file) return res.status(404).json({ error: 'Datei nicht gefunden' });
const filePath = path.join(filesDir, file.filename);
if (fs.existsSync(filePath)) {
try { fs.unlinkSync(filePath); } catch (e) { console.error('Error deleting file:', e); }
}
db.prepare('DELETE FROM trip_files WHERE id = ?').run(id);
res.json({ success: true });
});
module.exports = router;

166
server/src/routes/maps.js Normal file
View File

@@ -0,0 +1,166 @@
const express = require('express');
const fetch = require('node-fetch');
const { db } = require('../db/database');
const { authenticate } = require('../middleware/auth');
const router = express.Router();
// In-memory photo cache: placeId → { photoUrl, attribution, fetchedAt }
const photoCache = new Map();
const PHOTO_TTL = 12 * 60 * 60 * 1000; // 12 hours
// POST /api/maps/search
router.post('/search', authenticate, async (req, res) => {
const { query } = req.body;
if (!query) return res.status(400).json({ error: 'Suchanfrage ist erforderlich' });
const user = db.prepare('SELECT maps_api_key FROM users WHERE id = ?').get(req.user.id);
if (!user || !user.maps_api_key) {
return res.status(400).json({ error: 'Google Maps API-Schlüssel nicht konfiguriert. Bitte in den Einstellungen hinzufügen.' });
}
try {
const response = await fetch('https://places.googleapis.com/v1/places:searchText', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Goog-Api-Key': user.maps_api_key,
'X-Goog-FieldMask': 'places.id,places.displayName,places.formattedAddress,places.location,places.rating,places.websiteUri,places.nationalPhoneNumber,places.types',
},
body: JSON.stringify({ textQuery: query, languageCode: req.query.lang || 'en' }),
});
const data = await response.json();
if (!response.ok) {
return res.status(response.status).json({ error: data.error?.message || 'Google Places API Fehler' });
}
const places = (data.places || []).map(p => ({
google_place_id: p.id,
name: p.displayName?.text || '',
address: p.formattedAddress || '',
lat: p.location?.latitude || null,
lng: p.location?.longitude || null,
rating: p.rating || null,
website: p.websiteUri || null,
phone: p.nationalPhoneNumber || null,
}));
res.json({ places });
} catch (err) {
console.error('Maps search error:', err);
res.status(500).json({ error: 'Fehler bei der Google Places Suche' });
}
});
// GET /api/maps/details/:placeId
router.get('/details/:placeId', authenticate, async (req, res) => {
const { placeId } = req.params;
const user = db.prepare('SELECT maps_api_key FROM users WHERE id = ?').get(req.user.id);
if (!user || !user.maps_api_key) {
return res.status(400).json({ error: 'Google Maps API-Schlüssel nicht konfiguriert' });
}
try {
const lang = req.query.lang || 'de'
const response = await fetch(`https://places.googleapis.com/v1/places/${placeId}?languageCode=${lang}`, {
method: 'GET',
headers: {
'X-Goog-Api-Key': user.maps_api_key,
'X-Goog-FieldMask': 'id,displayName,formattedAddress,location,rating,userRatingCount,websiteUri,nationalPhoneNumber,regularOpeningHours,googleMapsUri,reviews,editorialSummary',
},
});
const data = await response.json();
if (!response.ok) {
return res.status(response.status).json({ error: data.error?.message || 'Google Places API Fehler' });
}
const place = {
google_place_id: data.id,
name: data.displayName?.text || '',
address: data.formattedAddress || '',
lat: data.location?.latitude || null,
lng: data.location?.longitude || null,
rating: data.rating || null,
rating_count: data.userRatingCount || null,
website: data.websiteUri || null,
phone: data.nationalPhoneNumber || null,
opening_hours: data.regularOpeningHours?.weekdayDescriptions || null,
open_now: data.regularOpeningHours?.openNow ?? null,
google_maps_url: data.googleMapsUri || null,
summary: data.editorialSummary?.text || null,
reviews: (data.reviews || []).slice(0, 5).map(r => ({
author: r.authorAttribution?.displayName || null,
rating: r.rating || null,
text: r.text?.text || null,
time: r.relativePublishTimeDescription || null,
photo: r.authorAttribution?.photoUri || null,
})),
};
res.json({ place });
} catch (err) {
console.error('Maps details error:', err);
res.status(500).json({ error: 'Fehler beim Abrufen der Ortsdetails' });
}
});
// GET /api/maps/place-photo/:placeId
// Proxies a Google Places photo (hides API key from client). Returns { photoUrl, attribution }.
router.get('/place-photo/:placeId', authenticate, async (req, res) => {
const { placeId } = req.params;
// Check TTL cache
const cached = photoCache.get(placeId);
if (cached && Date.now() - cached.fetchedAt < PHOTO_TTL) {
return res.json({ photoUrl: cached.photoUrl, attribution: cached.attribution });
}
const user = db.prepare('SELECT maps_api_key FROM users WHERE id = ?').get(req.user.id);
if (!user?.maps_api_key) {
return res.status(400).json({ error: 'Google Maps API-Schlüssel nicht konfiguriert' });
}
try {
// Fetch place details to get photo reference
const detailsRes = await fetch(`https://places.googleapis.com/v1/places/${placeId}`, {
headers: {
'X-Goog-Api-Key': user.maps_api_key,
'X-Goog-FieldMask': 'photos',
},
});
const details = await detailsRes.json();
if (!details.photos?.length) {
return res.status(404).json({ error: 'Kein Foto verfügbar' });
}
const photo = details.photos[0];
const photoName = photo.name;
const attribution = photo.authorAttributions?.[0]?.displayName || null;
// Fetch the media URL (skipHttpRedirect returns JSON with photoUri)
const mediaRes = await fetch(
`https://places.googleapis.com/v1/${photoName}/media?maxHeightPx=600&key=${user.maps_api_key}&skipHttpRedirect=true`
);
const mediaData = await mediaRes.json();
const photoUrl = mediaData.photoUri;
if (!photoUrl) {
return res.status(404).json({ error: 'Foto-URL nicht verfügbar' });
}
photoCache.set(placeId, { photoUrl, attribution, fetchedAt: Date.now() });
res.json({ photoUrl, attribution });
} catch (err) {
console.error('Place photo error:', err);
res.status(500).json({ error: 'Fehler beim Abrufen des Fotos' });
}
});
module.exports = router;

View File

@@ -0,0 +1,108 @@
const express = require('express');
const { db, canAccessTrip } = require('../db/database');
const { authenticate } = require('../middleware/auth');
const router = express.Router({ mergeParams: true });
function verifyTripOwnership(tripId, userId) {
return canAccessTrip(tripId, userId);
}
// GET /api/trips/:tripId/packing
router.get('/', authenticate, (req, res) => {
const { tripId } = req.params;
const trip = verifyTripOwnership(tripId, req.user.id);
if (!trip) return res.status(404).json({ error: 'Reise nicht gefunden' });
const items = db.prepare(
'SELECT * FROM packing_items WHERE trip_id = ? ORDER BY sort_order ASC, created_at ASC'
).all(tripId);
res.json({ items });
});
// POST /api/trips/:tripId/packing
router.post('/', authenticate, (req, res) => {
const { tripId } = req.params;
const { name, category, checked } = req.body;
const trip = verifyTripOwnership(tripId, req.user.id);
if (!trip) return res.status(404).json({ error: 'Reise nicht gefunden' });
if (!name) return res.status(400).json({ error: 'Artikelname ist erforderlich' });
const maxOrder = db.prepare('SELECT MAX(sort_order) as max FROM packing_items WHERE trip_id = ?').get(tripId);
const sortOrder = (maxOrder.max !== null ? maxOrder.max : -1) + 1;
const result = db.prepare(
'INSERT INTO packing_items (trip_id, name, checked, category, sort_order) VALUES (?, ?, ?, ?, ?)'
).run(tripId, name, checked ? 1 : 0, category || 'Allgemein', sortOrder);
const item = db.prepare('SELECT * FROM packing_items WHERE id = ?').get(result.lastInsertRowid);
res.status(201).json({ item });
});
// PUT /api/trips/:tripId/packing/:id
router.put('/:id', authenticate, (req, res) => {
const { tripId, id } = req.params;
const { name, checked, category } = req.body;
const trip = verifyTripOwnership(tripId, req.user.id);
if (!trip) return res.status(404).json({ error: 'Reise nicht gefunden' });
const item = db.prepare('SELECT * FROM packing_items WHERE id = ? AND trip_id = ?').get(id, tripId);
if (!item) return res.status(404).json({ error: 'Artikel nicht gefunden' });
db.prepare(`
UPDATE packing_items SET
name = COALESCE(?, name),
checked = CASE WHEN ? IS NOT NULL THEN ? ELSE checked END,
category = COALESCE(?, category)
WHERE id = ?
`).run(
name || null,
checked !== undefined ? 1 : null,
checked ? 1 : 0,
category || null,
id
);
const updated = db.prepare('SELECT * FROM packing_items WHERE id = ?').get(id);
res.json({ item: updated });
});
// DELETE /api/trips/:tripId/packing/:id
router.delete('/:id', authenticate, (req, res) => {
const { tripId, id } = req.params;
const trip = verifyTripOwnership(tripId, req.user.id);
if (!trip) return res.status(404).json({ error: 'Reise nicht gefunden' });
const item = db.prepare('SELECT id FROM packing_items WHERE id = ? AND trip_id = ?').get(id, tripId);
if (!item) return res.status(404).json({ error: 'Artikel nicht gefunden' });
db.prepare('DELETE FROM packing_items WHERE id = ?').run(id);
res.json({ success: true });
});
// PUT /api/trips/:tripId/packing/reorder
router.put('/reorder', authenticate, (req, res) => {
const { tripId } = req.params;
const { orderedIds } = req.body;
const trip = verifyTripOwnership(tripId, req.user.id);
if (!trip) return res.status(404).json({ error: 'Reise nicht gefunden' });
const update = db.prepare('UPDATE packing_items SET sort_order = ? WHERE id = ? AND trip_id = ?');
const updateMany = db.transaction((ids) => {
ids.forEach((id, index) => {
update.run(index, id, tripId);
});
});
updateMany(orderedIds);
res.json({ success: true });
});
module.exports = router;

165
server/src/routes/photos.js Normal file
View File

@@ -0,0 +1,165 @@
const express = require('express');
const multer = require('multer');
const path = require('path');
const fs = require('fs');
const { v4: uuidv4 } = require('uuid');
const { db, canAccessTrip } = require('../db/database');
const { authenticate } = require('../middleware/auth');
const router = express.Router({ mergeParams: true });
const photosDir = path.join(__dirname, '../../uploads/photos');
const storage = multer.diskStorage({
destination: (req, file, cb) => {
if (!fs.existsSync(photosDir)) fs.mkdirSync(photosDir, { recursive: true });
cb(null, photosDir);
},
filename: (req, file, cb) => {
const ext = path.extname(file.originalname);
cb(null, `${uuidv4()}${ext}`);
},
});
const upload = multer({
storage,
limits: { fileSize: 10 * 1024 * 1024 }, // 10MB
fileFilter: (req, file, cb) => {
if (file.mimetype.startsWith('image/')) {
cb(null, true);
} else {
cb(new Error('Nur Bilddateien sind erlaubt'));
}
},
});
function formatPhoto(photo) {
return {
...photo,
url: `/uploads/photos/${photo.filename}`,
};
}
// GET /api/trips/:tripId/photos
router.get('/', authenticate, (req, res) => {
const { tripId } = req.params;
const { day_id, place_id } = req.query;
const trip = canAccessTrip(tripId, req.user.id);
if (!trip) return res.status(404).json({ error: 'Reise nicht gefunden' });
let query = 'SELECT * FROM photos WHERE trip_id = ?';
const params = [tripId];
if (day_id) {
query += ' AND day_id = ?';
params.push(day_id);
}
if (place_id) {
query += ' AND place_id = ?';
params.push(place_id);
}
query += ' ORDER BY created_at DESC';
const photos = db.prepare(query).all(...params);
res.json({ photos: photos.map(formatPhoto) });
});
// POST /api/trips/:tripId/photos
router.post('/', authenticate, upload.array('photos', 20), (req, res) => {
const { tripId } = req.params;
const { day_id, place_id, caption } = req.body;
const trip = canAccessTrip(tripId, req.user.id);
if (!trip) {
// Delete uploaded files on auth failure
if (req.files) req.files.forEach(f => fs.unlinkSync(f.path));
return res.status(404).json({ error: 'Reise nicht gefunden' });
}
if (!req.files || req.files.length === 0) {
return res.status(400).json({ error: 'Keine Dateien hochgeladen' });
}
const insertPhoto = db.prepare(`
INSERT INTO photos (trip_id, day_id, place_id, filename, original_name, file_size, mime_type, caption)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`);
const photos = [];
db.exec('BEGIN');
try {
for (const file of req.files) {
const result = insertPhoto.run(
tripId,
day_id || null,
place_id || null,
file.filename,
file.originalname,
file.size,
file.mimetype,
caption || null
);
const photo = db.prepare('SELECT * FROM photos WHERE id = ?').get(result.lastInsertRowid);
photos.push(formatPhoto(photo));
}
db.exec('COMMIT');
} catch (e) {
db.exec('ROLLBACK');
throw e;
}
res.status(201).json({ photos });
});
// PUT /api/trips/:tripId/photos/:id
router.put('/:id', authenticate, (req, res) => {
const { tripId, id } = req.params;
const { caption, day_id, place_id } = req.body;
const trip = canAccessTrip(tripId, req.user.id);
if (!trip) return res.status(404).json({ error: 'Reise nicht gefunden' });
const photo = db.prepare('SELECT * FROM photos WHERE id = ? AND trip_id = ?').get(id, tripId);
if (!photo) return res.status(404).json({ error: 'Foto nicht gefunden' });
db.prepare(`
UPDATE photos SET
caption = COALESCE(?, caption),
day_id = ?,
place_id = ?
WHERE id = ?
`).run(
caption !== undefined ? caption : photo.caption,
day_id !== undefined ? (day_id || null) : photo.day_id,
place_id !== undefined ? (place_id || null) : photo.place_id,
id
);
const updated = db.prepare('SELECT * FROM photos WHERE id = ?').get(id);
res.json({ photo: formatPhoto(updated) });
});
// DELETE /api/trips/:tripId/photos/:id
router.delete('/:id', authenticate, (req, res) => {
const { tripId, id } = req.params;
const trip = canAccessTrip(tripId, req.user.id);
if (!trip) return res.status(404).json({ error: 'Reise nicht gefunden' });
const photo = db.prepare('SELECT * FROM photos WHERE id = ? AND trip_id = ?').get(id, tripId);
if (!photo) return res.status(404).json({ error: 'Foto nicht gefunden' });
// Delete file
const filePath = path.join(photosDir, photo.filename);
if (fs.existsSync(filePath)) {
try { fs.unlinkSync(filePath); } catch (e) { console.error('Error deleting photo file:', e); }
}
db.prepare('DELETE FROM photos WHERE id = ?').run(id);
res.json({ success: true });
});
module.exports = router;

281
server/src/routes/places.js Normal file
View File

@@ -0,0 +1,281 @@
const express = require('express');
const fetch = require('node-fetch');
const { db, getPlaceWithTags, canAccessTrip } = require('../db/database');
const { authenticate } = require('../middleware/auth');
const router = express.Router({ mergeParams: true });
function verifyTripOwnership(tripId, userId) {
return canAccessTrip(tripId, userId);
}
// GET /api/trips/:tripId/places
router.get('/', authenticate, (req, res) => {
const { tripId } = req.params;
const { search, category, tag } = req.query;
const trip = verifyTripOwnership(tripId, req.user.id);
if (!trip) {
return res.status(404).json({ error: 'Reise nicht gefunden' });
}
let query = `
SELECT DISTINCT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon
FROM places p
LEFT JOIN categories c ON p.category_id = c.id
WHERE p.trip_id = ?
`;
const params = [tripId];
if (search) {
query += ' AND (p.name LIKE ? OR p.address LIKE ? OR p.description LIKE ?)';
const searchParam = `%${search}%`;
params.push(searchParam, searchParam, searchParam);
}
if (category) {
query += ' AND p.category_id = ?';
params.push(category);
}
if (tag) {
query += ' AND p.id IN (SELECT place_id FROM place_tags WHERE tag_id = ?)';
params.push(tag);
}
query += ' ORDER BY p.created_at DESC';
const places = db.prepare(query).all(...params);
const placesWithTags = places.map(p => {
const tags = db.prepare(`
SELECT t.* FROM tags t
JOIN place_tags pt ON t.id = pt.tag_id
WHERE pt.place_id = ?
`).all(p.id);
return {
...p,
category: p.category_id ? {
id: p.category_id,
name: p.category_name,
color: p.category_color,
icon: p.category_icon,
} : null,
tags,
};
});
res.json({ places: placesWithTags });
});
// POST /api/trips/:tripId/places
router.post('/', authenticate, (req, res) => {
const { tripId } = req.params;
const trip = verifyTripOwnership(tripId, req.user.id);
if (!trip) {
return res.status(404).json({ error: 'Reise nicht gefunden' });
}
const {
name, description, lat, lng, address, category_id, price, currency,
reservation_status, reservation_notes, reservation_datetime, place_time,
duration_minutes, notes, image_url, google_place_id, website, phone,
transport_mode, tags = []
} = req.body;
if (!name) {
return res.status(400).json({ error: 'Ortsname ist erforderlich' });
}
const result = db.prepare(`
INSERT INTO places (trip_id, name, description, lat, lng, address, category_id, price, currency,
reservation_status, reservation_notes, reservation_datetime, place_time,
duration_minutes, notes, image_url, google_place_id, website, phone, transport_mode)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
tripId, name, description || null, lat || null, lng || null, address || null,
category_id || null, price || null, currency || null,
reservation_status || 'none', reservation_notes || null, reservation_datetime || null,
place_time || null, duration_minutes || 60, notes || null, image_url || null,
google_place_id || null, website || null, phone || null, transport_mode || 'walking'
);
const placeId = result.lastInsertRowid;
if (tags && tags.length > 0) {
const insertTag = db.prepare('INSERT OR IGNORE INTO place_tags (place_id, tag_id) VALUES (?, ?)');
for (const tagId of tags) {
insertTag.run(placeId, tagId);
}
}
const place = getPlaceWithTags(placeId);
res.status(201).json({ place });
});
// GET /api/trips/:tripId/places/:id
router.get('/:id', authenticate, (req, res) => {
const { tripId, id } = req.params;
const trip = verifyTripOwnership(tripId, req.user.id);
if (!trip) {
return res.status(404).json({ error: 'Reise nicht gefunden' });
}
const placeCheck = db.prepare('SELECT id FROM places WHERE id = ? AND trip_id = ?').get(id, tripId);
if (!placeCheck) {
return res.status(404).json({ error: 'Ort nicht gefunden' });
}
const place = getPlaceWithTags(id);
res.json({ place });
});
// GET /api/trips/:tripId/places/:id/image - fetch image from Unsplash
router.get('/:id/image', authenticate, async (req, res) => {
const { tripId, id } = req.params;
const trip = verifyTripOwnership(tripId, req.user.id);
if (!trip) {
return res.status(404).json({ error: 'Reise nicht gefunden' });
}
const place = db.prepare('SELECT * FROM places WHERE id = ? AND trip_id = ?').get(id, tripId);
if (!place) {
return res.status(404).json({ error: 'Ort nicht gefunden' });
}
const user = db.prepare('SELECT unsplash_api_key FROM users WHERE id = ?').get(req.user.id);
if (!user || !user.unsplash_api_key) {
return res.status(400).json({ error: 'Kein Unsplash API-Schlüssel konfiguriert' });
}
try {
const query = encodeURIComponent(place.name + (place.address ? ' ' + place.address : ''));
const response = await fetch(
`https://api.unsplash.com/search/photos?query=${query}&per_page=5&client_id=${user.unsplash_api_key}`
);
const data = await response.json();
if (!response.ok) {
return res.status(response.status).json({ error: data.errors?.[0] || 'Unsplash API Fehler' });
}
const photos = (data.results || []).map(p => ({
id: p.id,
url: p.urls?.regular,
thumb: p.urls?.thumb,
description: p.description || p.alt_description,
photographer: p.user?.name,
link: p.links?.html,
}));
res.json({ photos });
} catch (err) {
console.error('Unsplash error:', err);
res.status(500).json({ error: 'Fehler beim Suchen des Bildes' });
}
});
// PUT /api/trips/:tripId/places/:id
router.put('/:id', authenticate, (req, res) => {
const { tripId, id } = req.params;
const trip = verifyTripOwnership(tripId, req.user.id);
if (!trip) {
return res.status(404).json({ error: 'Reise nicht gefunden' });
}
const existingPlace = db.prepare('SELECT * FROM places WHERE id = ? AND trip_id = ?').get(id, tripId);
if (!existingPlace) {
return res.status(404).json({ error: 'Ort nicht gefunden' });
}
const {
name, description, lat, lng, address, category_id, price, currency,
reservation_status, reservation_notes, reservation_datetime, place_time,
duration_minutes, notes, image_url, google_place_id, website, phone,
transport_mode, tags
} = req.body;
db.prepare(`
UPDATE places SET
name = COALESCE(?, name),
description = ?,
lat = ?,
lng = ?,
address = ?,
category_id = ?,
price = ?,
currency = COALESCE(?, currency),
reservation_status = COALESCE(?, reservation_status),
reservation_notes = ?,
reservation_datetime = ?,
place_time = ?,
duration_minutes = COALESCE(?, duration_minutes),
notes = ?,
image_url = ?,
google_place_id = ?,
website = ?,
phone = ?,
transport_mode = COALESCE(?, transport_mode),
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`).run(
name || null,
description !== undefined ? description : existingPlace.description,
lat !== undefined ? lat : existingPlace.lat,
lng !== undefined ? lng : existingPlace.lng,
address !== undefined ? address : existingPlace.address,
category_id !== undefined ? category_id : existingPlace.category_id,
price !== undefined ? price : existingPlace.price,
currency || null,
reservation_status || null,
reservation_notes !== undefined ? reservation_notes : existingPlace.reservation_notes,
reservation_datetime !== undefined ? reservation_datetime : existingPlace.reservation_datetime,
place_time !== undefined ? place_time : existingPlace.place_time,
duration_minutes || null,
notes !== undefined ? notes : existingPlace.notes,
image_url !== undefined ? image_url : existingPlace.image_url,
google_place_id !== undefined ? google_place_id : existingPlace.google_place_id,
website !== undefined ? website : existingPlace.website,
phone !== undefined ? phone : existingPlace.phone,
transport_mode || null,
id
);
if (tags !== undefined) {
db.prepare('DELETE FROM place_tags WHERE place_id = ?').run(id);
if (tags.length > 0) {
const insertTag = db.prepare('INSERT OR IGNORE INTO place_tags (place_id, tag_id) VALUES (?, ?)');
for (const tagId of tags) {
insertTag.run(id, tagId);
}
}
}
const place = getPlaceWithTags(id);
res.json({ place });
});
// DELETE /api/trips/:tripId/places/:id
router.delete('/:id', authenticate, (req, res) => {
const { tripId, id } = req.params;
const trip = verifyTripOwnership(tripId, req.user.id);
if (!trip) {
return res.status(404).json({ error: 'Reise nicht gefunden' });
}
const place = db.prepare('SELECT id FROM places WHERE id = ? AND trip_id = ?').get(id, tripId);
if (!place) {
return res.status(404).json({ error: 'Ort nicht gefunden' });
}
db.prepare('DELETE FROM places WHERE id = ?').run(id);
res.json({ success: true });
});
module.exports = router;

View File

@@ -0,0 +1,128 @@
const express = require('express');
const { db, canAccessTrip } = require('../db/database');
const { authenticate } = require('../middleware/auth');
const router = express.Router({ mergeParams: true });
function verifyTripOwnership(tripId, userId) {
return canAccessTrip(tripId, userId);
}
// GET /api/trips/:tripId/reservations
router.get('/', authenticate, (req, res) => {
const { tripId } = req.params;
const trip = verifyTripOwnership(tripId, req.user.id);
if (!trip) return res.status(404).json({ error: 'Reise nicht gefunden' });
const reservations = db.prepare(`
SELECT r.*, d.day_number, p.name as place_name
FROM reservations r
LEFT JOIN days d ON r.day_id = d.id
LEFT JOIN places p ON r.place_id = p.id
WHERE r.trip_id = ?
ORDER BY r.reservation_time ASC, r.created_at ASC
`).all(tripId);
res.json({ reservations });
});
// POST /api/trips/:tripId/reservations
router.post('/', authenticate, (req, res) => {
const { tripId } = req.params;
const { title, reservation_time, location, confirmation_number, notes, day_id, place_id, status, type } = req.body;
const trip = verifyTripOwnership(tripId, req.user.id);
if (!trip) return res.status(404).json({ error: 'Reise nicht gefunden' });
if (!title) return res.status(400).json({ error: 'Titel ist erforderlich' });
const result = db.prepare(`
INSERT INTO reservations (trip_id, day_id, place_id, title, reservation_time, location, confirmation_number, notes, status, type)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
tripId,
day_id || null,
place_id || null,
title,
reservation_time || null,
location || null,
confirmation_number || null,
notes || null,
status || 'pending',
type || 'other'
);
const reservation = db.prepare(`
SELECT r.*, d.day_number, p.name as place_name
FROM reservations r
LEFT JOIN days d ON r.day_id = d.id
LEFT JOIN places p ON r.place_id = p.id
WHERE r.id = ?
`).get(result.lastInsertRowid);
res.status(201).json({ reservation });
});
// PUT /api/trips/:tripId/reservations/:id
router.put('/:id', authenticate, (req, res) => {
const { tripId, id } = req.params;
const { title, reservation_time, location, confirmation_number, notes, day_id, place_id, status, type } = req.body;
const trip = verifyTripOwnership(tripId, req.user.id);
if (!trip) return res.status(404).json({ error: 'Reise nicht gefunden' });
const reservation = db.prepare('SELECT * FROM reservations WHERE id = ? AND trip_id = ?').get(id, tripId);
if (!reservation) return res.status(404).json({ error: 'Reservierung nicht gefunden' });
db.prepare(`
UPDATE reservations SET
title = COALESCE(?, title),
reservation_time = ?,
location = ?,
confirmation_number = ?,
notes = ?,
day_id = ?,
place_id = ?,
status = COALESCE(?, status),
type = COALESCE(?, type)
WHERE id = ?
`).run(
title || null,
reservation_time !== undefined ? (reservation_time || null) : reservation.reservation_time,
location !== undefined ? (location || null) : reservation.location,
confirmation_number !== undefined ? (confirmation_number || null) : reservation.confirmation_number,
notes !== undefined ? (notes || null) : reservation.notes,
day_id !== undefined ? (day_id || null) : reservation.day_id,
place_id !== undefined ? (place_id || null) : reservation.place_id,
status || null,
type || null,
id
);
const updated = db.prepare(`
SELECT r.*, d.day_number, p.name as place_name
FROM reservations r
LEFT JOIN days d ON r.day_id = d.id
LEFT JOIN places p ON r.place_id = p.id
WHERE r.id = ?
`).get(id);
res.json({ reservation: updated });
});
// DELETE /api/trips/:tripId/reservations/:id
router.delete('/:id', authenticate, (req, res) => {
const { tripId, id } = req.params;
const trip = verifyTripOwnership(tripId, req.user.id);
if (!trip) return res.status(404).json({ error: 'Reise nicht gefunden' });
const reservation = db.prepare('SELECT id FROM reservations WHERE id = ? AND trip_id = ?').get(id, tripId);
if (!reservation) return res.status(404).json({ error: 'Reservierung nicht gefunden' });
db.prepare('DELETE FROM reservations WHERE id = ?').run(id);
res.json({ success: true });
});
module.exports = router;

View File

@@ -0,0 +1,65 @@
const express = require('express');
const { db } = require('../db/database');
const { authenticate } = require('../middleware/auth');
const router = express.Router();
// GET /api/settings - return all settings for user
router.get('/', authenticate, (req, res) => {
const rows = db.prepare('SELECT key, value FROM settings WHERE user_id = ?').all(req.user.id);
const settings = {};
for (const row of rows) {
try {
settings[row.key] = JSON.parse(row.value);
} catch {
settings[row.key] = row.value;
}
}
res.json({ settings });
});
// PUT /api/settings - upsert single setting
router.put('/', authenticate, (req, res) => {
const { key, value } = req.body;
if (!key) return res.status(400).json({ error: 'Schlüssel ist erforderlich' });
const serialized = typeof value === 'object' ? JSON.stringify(value) : String(value !== undefined ? value : '');
db.prepare(`
INSERT INTO settings (user_id, key, value) VALUES (?, ?, ?)
ON CONFLICT(user_id, key) DO UPDATE SET value = excluded.value
`).run(req.user.id, key, serialized);
res.json({ success: true, key, value });
});
// POST /api/settings/bulk - upsert multiple settings
router.post('/bulk', authenticate, (req, res) => {
const { settings } = req.body;
if (!settings || typeof settings !== 'object') {
return res.status(400).json({ error: 'Einstellungen-Objekt ist erforderlich' });
}
const upsert = db.prepare(`
INSERT INTO settings (user_id, key, value) VALUES (?, ?, ?)
ON CONFLICT(user_id, key) DO UPDATE SET value = excluded.value
`);
try {
db.exec('BEGIN');
for (const [key, value] of Object.entries(settings)) {
const serialized = typeof value === 'object' ? JSON.stringify(value) : String(value !== undefined ? value : '');
upsert.run(req.user.id, key, serialized);
}
db.exec('COMMIT');
} catch (err) {
db.exec('ROLLBACK');
return res.status(500).json({ error: 'Fehler beim Speichern der Einstellungen', detail: err.message });
}
res.json({ success: true, updated: Object.keys(settings).length });
});
module.exports = router;

52
server/src/routes/tags.js Normal file
View File

@@ -0,0 +1,52 @@
const express = require('express');
const { db } = require('../db/database');
const { authenticate } = require('../middleware/auth');
const router = express.Router();
// GET /api/tags
router.get('/', authenticate, (req, res) => {
const tags = db.prepare(
'SELECT * FROM tags WHERE user_id = ? ORDER BY name ASC'
).all(req.user.id);
res.json({ tags });
});
// POST /api/tags
router.post('/', authenticate, (req, res) => {
const { name, color } = req.body;
if (!name) return res.status(400).json({ error: 'Tag-Name ist erforderlich' });
const result = db.prepare(
'INSERT INTO tags (user_id, name, color) VALUES (?, ?, ?)'
).run(req.user.id, name, color || '#10b981');
const tag = db.prepare('SELECT * FROM tags WHERE id = ?').get(result.lastInsertRowid);
res.status(201).json({ tag });
});
// PUT /api/tags/:id
router.put('/:id', authenticate, (req, res) => {
const { name, color } = req.body;
const tag = db.prepare('SELECT * FROM tags WHERE id = ? AND user_id = ?').get(req.params.id, req.user.id);
if (!tag) return res.status(404).json({ error: 'Tag nicht gefunden' });
db.prepare('UPDATE tags SET name = COALESCE(?, name), color = COALESCE(?, color) WHERE id = ?')
.run(name || null, color || null, req.params.id);
const updated = db.prepare('SELECT * FROM tags WHERE id = ?').get(req.params.id);
res.json({ tag: updated });
});
// DELETE /api/tags/:id
router.delete('/:id', authenticate, (req, res) => {
const tag = db.prepare('SELECT * FROM tags WHERE id = ? AND user_id = ?').get(req.params.id, req.user.id);
if (!tag) return res.status(404).json({ error: 'Tag nicht gefunden' });
db.prepare('DELETE FROM tags WHERE id = ?').run(req.params.id);
res.json({ success: true });
});
module.exports = router;

235
server/src/routes/trips.js Normal file
View File

@@ -0,0 +1,235 @@
const express = require('express');
const multer = require('multer');
const path = require('path');
const fs = require('fs');
const { v4: uuidv4 } = require('uuid');
const { db, canAccessTrip, isOwner } = require('../db/database');
const { authenticate } = require('../middleware/auth');
const router = express.Router();
const coversDir = path.join(__dirname, '../../uploads/covers');
const coverStorage = multer.diskStorage({
destination: (req, file, cb) => {
if (!fs.existsSync(coversDir)) fs.mkdirSync(coversDir, { recursive: true });
cb(null, coversDir);
},
filename: (req, file, cb) => {
const ext = path.extname(file.originalname);
cb(null, `${uuidv4()}${ext}`);
},
});
const uploadCover = multer({
storage: coverStorage,
limits: { fileSize: 20 * 1024 * 1024 },
fileFilter: (req, file, cb) => {
if (file.mimetype.startsWith('image/')) cb(null, true);
else cb(new Error('Nur Bilder erlaubt'));
},
});
const TRIP_SELECT = `
SELECT t.*,
(SELECT COUNT(*) FROM days d WHERE d.trip_id = t.id) as day_count,
(SELECT COUNT(*) FROM places p WHERE p.trip_id = t.id) as place_count,
CASE WHEN t.user_id = :userId THEN 1 ELSE 0 END as is_owner,
u.username as owner_username,
(SELECT COUNT(*) FROM trip_members tm WHERE tm.trip_id = t.id) as shared_count
FROM trips t
JOIN users u ON u.id = t.user_id
`;
function generateDays(tripId, startDate, endDate) {
db.prepare('DELETE FROM days WHERE trip_id = ?').run(tripId);
if (!startDate || !endDate) {
const insert = db.prepare('INSERT INTO days (trip_id, day_number, date) VALUES (?, ?, NULL)');
for (let i = 1; i <= 7; i++) insert.run(tripId, i);
return;
}
const start = new Date(startDate);
const end = new Date(endDate);
const numDays = Math.min(Math.floor((end - start) / 86400000) + 1, 90);
const insert = db.prepare('INSERT INTO days (trip_id, day_number, date) VALUES (?, ?, ?)');
for (let i = 0; i < numDays; i++) {
const d = new Date(start);
d.setDate(start.getDate() + i);
insert.run(tripId, i + 1, d.toISOString().split('T')[0]);
}
}
// GET /api/trips — active or archived, includes shared trips
router.get('/', authenticate, (req, res) => {
const archived = req.query.archived === '1' ? 1 : 0;
const userId = req.user.id;
const trips = db.prepare(`
${TRIP_SELECT}
LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = :userId
WHERE (t.user_id = :userId OR m.user_id IS NOT NULL) AND t.is_archived = :archived
ORDER BY t.created_at DESC
`).all({ userId, archived });
res.json({ trips });
});
// POST /api/trips
router.post('/', authenticate, (req, res) => {
const { title, description, start_date, end_date, currency } = req.body;
if (!title) return res.status(400).json({ error: 'Titel ist erforderlich' });
if (start_date && end_date && new Date(end_date) < new Date(start_date))
return res.status(400).json({ error: 'Enddatum muss nach dem Startdatum liegen' });
const result = db.prepare(`
INSERT INTO trips (user_id, title, description, start_date, end_date, currency)
VALUES (?, ?, ?, ?, ?, ?)
`).run(req.user.id, title, description || null, start_date || null, end_date || null, currency || 'EUR');
const tripId = result.lastInsertRowid;
generateDays(tripId, start_date, end_date);
const trip = db.prepare(`${TRIP_SELECT} WHERE t.id = :tripId`).get({ userId: req.user.id, tripId });
res.status(201).json({ trip });
});
// GET /api/trips/:id
router.get('/:id', authenticate, (req, res) => {
const userId = req.user.id;
const trip = db.prepare(`
${TRIP_SELECT}
LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = :userId
WHERE t.id = :tripId AND (t.user_id = :userId OR m.user_id IS NOT NULL)
`).get({ userId, tripId: req.params.id });
if (!trip) return res.status(404).json({ error: 'Reise nicht gefunden' });
res.json({ trip });
});
// PUT /api/trips/:id — all members can edit; archive/cover owner-only
router.put('/:id', authenticate, (req, res) => {
const access = canAccessTrip(req.params.id, req.user.id);
if (!access) return res.status(404).json({ error: 'Reise nicht gefunden' });
const ownerOnly = req.body.is_archived !== undefined || req.body.cover_image !== undefined;
if (ownerOnly && !isOwner(req.params.id, req.user.id))
return res.status(403).json({ error: 'Nur der Eigentümer kann diese Einstellung ändern' });
const trip = db.prepare('SELECT * FROM trips WHERE id = ?').get(req.params.id);
const { title, description, start_date, end_date, currency, is_archived, cover_image } = req.body;
if (start_date && end_date && new Date(end_date) < new Date(start_date))
return res.status(400).json({ error: 'Enddatum muss nach dem Startdatum liegen' });
const newTitle = title || trip.title;
const newDesc = description !== undefined ? description : trip.description;
const newStart = start_date !== undefined ? start_date : trip.start_date;
const newEnd = end_date !== undefined ? end_date : trip.end_date;
const newCurrency = currency || trip.currency;
const newArchived = is_archived !== undefined ? (is_archived ? 1 : 0) : trip.is_archived;
const newCover = cover_image !== undefined ? cover_image : trip.cover_image;
db.prepare(`
UPDATE trips SET title=?, description=?, start_date=?, end_date=?,
currency=?, is_archived=?, cover_image=?, updated_at=CURRENT_TIMESTAMP
WHERE id=?
`).run(newTitle, newDesc, newStart || null, newEnd || null, newCurrency, newArchived, newCover, req.params.id);
if (newStart !== trip.start_date || newEnd !== trip.end_date)
generateDays(req.params.id, newStart, newEnd);
const updatedTrip = db.prepare(`${TRIP_SELECT} WHERE t.id = :tripId`).get({ userId: req.user.id, tripId: req.params.id });
res.json({ trip: updatedTrip });
});
// POST /api/trips/:id/cover
router.post('/:id/cover', authenticate, uploadCover.single('cover'), (req, res) => {
if (!isOwner(req.params.id, req.user.id))
return res.status(403).json({ error: 'Nur der Eigentümer kann das Titelbild ändern' });
const trip = db.prepare('SELECT * FROM trips WHERE id = ?').get(req.params.id);
if (!trip) return res.status(404).json({ error: 'Reise nicht gefunden' });
if (!req.file) return res.status(400).json({ error: 'Kein Bild hochgeladen' });
if (trip.cover_image) {
const oldPath = path.join(__dirname, '../../', trip.cover_image.replace(/^\//, ''));
if (fs.existsSync(oldPath)) fs.unlinkSync(oldPath);
}
const coverUrl = `/uploads/covers/${req.file.filename}`;
db.prepare('UPDATE trips SET cover_image=?, updated_at=CURRENT_TIMESTAMP WHERE id=?').run(coverUrl, req.params.id);
res.json({ cover_image: coverUrl });
});
// DELETE /api/trips/:id — owner only
router.delete('/:id', authenticate, (req, res) => {
if (!isOwner(req.params.id, req.user.id))
return res.status(403).json({ error: 'Nur der Eigentümer kann die Reise löschen' });
db.prepare('DELETE FROM trips WHERE id = ?').run(req.params.id);
res.json({ success: true });
});
// ── Member Management ────────────────────────────────────────────────────────
// GET /api/trips/:id/members
router.get('/:id/members', authenticate, (req, res) => {
if (!canAccessTrip(req.params.id, req.user.id))
return res.status(404).json({ error: 'Reise nicht gefunden' });
const trip = db.prepare('SELECT user_id FROM trips WHERE id = ?').get(req.params.id);
const members = db.prepare(`
SELECT u.id, u.username, u.email, u.avatar,
CASE WHEN u.id = ? THEN 'owner' ELSE 'member' END as role,
m.added_at,
ib.username as invited_by_username
FROM trip_members m
JOIN users u ON u.id = m.user_id
LEFT JOIN users ib ON ib.id = m.invited_by
WHERE m.trip_id = ?
ORDER BY m.added_at ASC
`).all(trip.user_id, req.params.id);
const owner = db.prepare('SELECT id, username, email, avatar FROM users WHERE id = ?').get(trip.user_id);
res.json({
owner: { ...owner, role: 'owner', avatar_url: owner.avatar ? `/uploads/avatars/${owner.avatar}` : null },
members: members.map(m => ({ ...m, avatar_url: m.avatar ? `/uploads/avatars/${m.avatar}` : null })),
current_user_id: req.user.id,
});
});
// POST /api/trips/:id/members — add by email or username
router.post('/:id/members', authenticate, (req, res) => {
if (!canAccessTrip(req.params.id, req.user.id))
return res.status(404).json({ error: 'Reise nicht gefunden' });
const { identifier } = req.body; // email or username
if (!identifier) return res.status(400).json({ error: 'E-Mail oder Benutzername erforderlich' });
const target = db.prepare(
'SELECT id, username, email, avatar FROM users WHERE email = ? OR username = ?'
).get(identifier.trim(), identifier.trim());
if (!target) return res.status(404).json({ error: 'Benutzer nicht gefunden' });
const trip = db.prepare('SELECT user_id FROM trips WHERE id = ?').get(req.params.id);
if (target.id === trip.user_id)
return res.status(400).json({ error: 'Der Eigentümer der Reise ist bereits Mitglied' });
const existing = db.prepare('SELECT id FROM trip_members WHERE trip_id = ? AND user_id = ?').get(req.params.id, target.id);
if (existing) return res.status(400).json({ error: 'Benutzer hat bereits Zugriff' });
db.prepare('INSERT INTO trip_members (trip_id, user_id, invited_by) VALUES (?, ?, ?)').run(req.params.id, target.id, req.user.id);
res.status(201).json({ member: { ...target, role: 'member', avatar_url: target.avatar ? `/uploads/avatars/${target.avatar}` : null } });
});
// DELETE /api/trips/:id/members/:userId — owner removes anyone; member removes self
router.delete('/:id/members/:userId', authenticate, (req, res) => {
if (!canAccessTrip(req.params.id, req.user.id))
return res.status(404).json({ error: 'Reise nicht gefunden' });
const targetId = parseInt(req.params.userId);
const isSelf = targetId === req.user.id;
if (!isSelf && !isOwner(req.params.id, req.user.id))
return res.status(403).json({ error: 'Keine Berechtigung' });
db.prepare('DELETE FROM trip_members WHERE trip_id = ? AND user_id = ?').run(req.params.id, targetId);
res.json({ success: true });
});
module.exports = router;

View File

@@ -0,0 +1,85 @@
const express = require('express');
const fetch = require('node-fetch');
const { db } = require('../db/database');
const { authenticate } = require('../middleware/auth');
const router = express.Router();
function formatItem(item) {
return {
temp: Math.round(item.main.temp),
feels_like: Math.round(item.main.feels_like),
humidity: item.main.humidity,
main: item.weather[0]?.main || '',
description: item.weather[0]?.description || '',
icon: item.weather[0]?.icon || '',
};
}
// GET /api/weather?lat=&lng=&date=&units=metric
router.get('/', authenticate, async (req, res) => {
const { lat, lng, date, units = 'metric' } = req.query;
if (!lat || !lng) {
return res.status(400).json({ error: 'Breiten- und Längengrad sind erforderlich' });
}
const user = db.prepare('SELECT openweather_api_key FROM users WHERE id = ?').get(req.user.id);
if (!user || !user.openweather_api_key) {
return res.status(400).json({ error: 'Kein API-Schlüssel konfiguriert' });
}
const key = user.openweather_api_key;
try {
// If a date is requested, try the 5-day forecast first
if (date) {
const targetDate = new Date(date);
const now = new Date();
const diffDays = (targetDate - now) / (1000 * 60 * 60 * 24);
// Within 5-day forecast window
if (diffDays >= -1 && diffDays <= 5) {
const url = `https://api.openweathermap.org/data/2.5/forecast?lat=${lat}&lon=${lng}&appid=${key}&units=${units}&lang=de`;
const response = await fetch(url);
const data = await response.json();
if (!response.ok) {
return res.status(response.status).json({ error: data.message || 'OpenWeatherMap API Fehler' });
}
const filtered = (data.list || []).filter(item => {
const itemDate = new Date(item.dt * 1000);
return itemDate.toDateString() === targetDate.toDateString();
});
if (filtered.length > 0) {
const midday = filtered.find(item => {
const hour = new Date(item.dt * 1000).getHours();
return hour >= 11 && hour <= 14;
}) || filtered[0];
return res.json(formatItem(midday));
}
}
// Outside forecast window — no data available
return res.json({ error: 'no_forecast' });
}
// No date — return current weather
const url = `https://api.openweathermap.org/data/2.5/weather?lat=${lat}&lon=${lng}&appid=${key}&units=${units}&lang=de`;
const response = await fetch(url);
const data = await response.json();
if (!response.ok) {
return res.status(response.status).json({ error: data.message || 'OpenWeatherMap API Fehler' });
}
res.json(formatItem(data));
} catch (err) {
console.error('Weather error:', err);
res.status(500).json({ error: 'Fehler beim Abrufen der Wetterdaten' });
}
});
module.exports = router;

105
server/src/scheduler.js Normal file
View File

@@ -0,0 +1,105 @@
const cron = require('node-cron');
const archiver = require('archiver');
const path = require('path');
const fs = require('fs');
const dataDir = path.join(__dirname, '../data');
const backupsDir = path.join(dataDir, 'backups');
const uploadsDir = path.join(__dirname, '../uploads');
const settingsFile = path.join(dataDir, 'backup-settings.json');
const CRON_EXPRESSIONS = {
hourly: '0 * * * *',
daily: '0 2 * * *',
weekly: '0 2 * * 0',
monthly: '0 2 1 * *',
};
const VALID_INTERVALS = Object.keys(CRON_EXPRESSIONS);
let currentTask = null;
function loadSettings() {
try {
if (fs.existsSync(settingsFile)) {
return JSON.parse(fs.readFileSync(settingsFile, 'utf8'));
}
} catch (e) {}
return { enabled: false, interval: 'daily', keep_days: 7 };
}
function saveSettings(settings) {
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
fs.writeFileSync(settingsFile, JSON.stringify(settings, null, 2));
}
async function runBackup() {
if (!fs.existsSync(backupsDir)) fs.mkdirSync(backupsDir, { recursive: true });
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
const filename = `auto-backup-${timestamp}.zip`;
const outputPath = path.join(backupsDir, filename);
try {
// Flush WAL to main DB file before archiving
try { const { db } = require('./db/database'); db.exec('PRAGMA wal_checkpoint(TRUNCATE)'); } catch (e) {}
await new Promise((resolve, reject) => {
const output = fs.createWriteStream(outputPath);
const archive = archiver('zip', { zlib: { level: 9 } });
output.on('close', resolve);
archive.on('error', reject);
archive.pipe(output);
const dbPath = path.join(dataDir, 'travel.db');
if (fs.existsSync(dbPath)) archive.file(dbPath, { name: 'travel.db' });
if (fs.existsSync(uploadsDir)) archive.directory(uploadsDir, 'uploads');
archive.finalize();
});
console.log(`[Auto-Backup] Erstellt: ${filename}`);
} catch (err) {
console.error('[Auto-Backup] Fehler:', err.message);
if (fs.existsSync(outputPath)) fs.unlinkSync(outputPath);
return;
}
const settings = loadSettings();
if (settings.keep_days > 0) {
cleanupOldBackups(settings.keep_days);
}
}
function cleanupOldBackups(keepDays) {
try {
const cutoff = Date.now() - keepDays * 24 * 60 * 60 * 1000;
const files = fs.readdirSync(backupsDir).filter(f => f.endsWith('.zip'));
for (const file of files) {
const filePath = path.join(backupsDir, file);
const stat = fs.statSync(filePath);
if (stat.birthtimeMs < cutoff) {
fs.unlinkSync(filePath);
console.log(`[Auto-Backup] Altes Backup gelöscht: ${file}`);
}
}
} catch (err) {
console.error('[Auto-Backup] Bereinigungsfehler:', err.message);
}
}
function start() {
if (currentTask) {
currentTask.stop();
currentTask = null;
}
const settings = loadSettings();
if (!settings.enabled) {
console.log('[Auto-Backup] Deaktiviert');
return;
}
const expression = CRON_EXPRESSIONS[settings.interval] || CRON_EXPRESSIONS.daily;
currentTask = cron.schedule(expression, runBackup);
console.log(`[Auto-Backup] Geplant: ${settings.interval} (${expression}), Aufbewahrung: ${settings.keep_days === 0 ? 'immer' : settings.keep_days + ' Tage'}`);
}
module.exports = { start, loadSettings, saveSettings, VALID_INTERVALS };