mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-21 14:21:46 +00:00
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:
@@ -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' })
|
||||
}
|
||||
@@ -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`
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user