deleted: backend/spaces.db-shm deleted: backend/spaces.db-wal modified: frontend/src/App.jsx modified: frontend/src/components/Sidebar.jsx new file: frontend/src/hooks/usePermissions.js modified: frontend/src/pages/Home.jsx modified: frontend/src/pages/Profile.jsx modified: frontend/src/pages/SpaceDetail.jsx
686 lines
27 KiB
JavaScript
686 lines
27 KiB
JavaScript
import { useState, useEffect } from 'react'
|
|
import { useAuth } from '../contexts/AuthContext'
|
|
import { usePermissions } from '../hooks/usePermissions'
|
|
|
|
const Profile = () => {
|
|
const { authFetch, user } = useAuth()
|
|
const { isAdmin } = usePermissions()
|
|
const [loading, setLoading] = useState(false)
|
|
const [showSuccessAnimation, setShowSuccessAnimation] = useState(false)
|
|
const [error, setError] = useState('')
|
|
const [success, setSuccess] = useState('')
|
|
const [uploadingAvatar, setUploadingAvatar] = useState(false)
|
|
const [avatarUrl, setAvatarUrl] = useState(null)
|
|
const [showCropModal, setShowCropModal] = useState(false)
|
|
const [selectedFile, setSelectedFile] = useState(null)
|
|
const [cropImage, setCropImage] = useState(null)
|
|
const [imageDimensions, setImageDimensions] = useState({ width: 0, height: 0 })
|
|
const [cropPosition, setCropPosition] = useState({ x: 0, y: 0, size: 200 })
|
|
const [isDragging, setIsDragging] = useState(false)
|
|
const [isResizing, setIsResizing] = useState(false)
|
|
const [dragStart, setDragStart] = useState({ x: 0, y: 0 })
|
|
const [formData, setFormData] = useState({
|
|
username: '',
|
|
email: '',
|
|
oldPassword: '',
|
|
password: '',
|
|
confirmPassword: ''
|
|
})
|
|
|
|
useEffect(() => {
|
|
if (user) {
|
|
setFormData({
|
|
username: user.username || '',
|
|
email: user.email || '',
|
|
oldPassword: '',
|
|
password: '',
|
|
confirmPassword: ''
|
|
})
|
|
// Lade Profilbild
|
|
loadAvatar()
|
|
}
|
|
}, [user])
|
|
|
|
const loadAvatar = async () => {
|
|
if (user?.id) {
|
|
// Versuche Profilbild zu laden, mit Timestamp für Cache-Busting
|
|
const url = `/api/users/${user.id}/avatar?t=${Date.now()}`
|
|
try {
|
|
const response = await authFetch(url)
|
|
if (response.ok) {
|
|
setAvatarUrl(url)
|
|
} else {
|
|
setAvatarUrl(null)
|
|
}
|
|
} catch {
|
|
setAvatarUrl(null)
|
|
}
|
|
} else {
|
|
setAvatarUrl(null)
|
|
}
|
|
}
|
|
|
|
const handleFileSelect = (e) => {
|
|
const file = e.target.files?.[0]
|
|
if (!file) return
|
|
|
|
// Validiere Dateityp
|
|
const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp']
|
|
if (!allowedTypes.includes(file.type)) {
|
|
setError('Nur Bilddateien (JPEG, PNG, GIF, WebP) sind erlaubt')
|
|
return
|
|
}
|
|
|
|
// Validiere Dateigröße (max 10MB)
|
|
if (file.size > 10 * 1024 * 1024) {
|
|
setError('Datei ist zu groß. Maximale Größe: 10MB')
|
|
return
|
|
}
|
|
|
|
setSelectedFile(file)
|
|
|
|
// Lade Bild für Crop-Modal
|
|
const reader = new FileReader()
|
|
reader.onload = (e) => {
|
|
setCropImage(e.target.result)
|
|
setShowCropModal(true)
|
|
// Setze initiale Crop-Position (zentriert)
|
|
const img = new Image()
|
|
img.onload = () => {
|
|
const minSize = Math.min(img.width, img.height)
|
|
const cropSize = Math.min(minSize * 0.8, 400)
|
|
setImageDimensions({ width: img.width, height: img.height })
|
|
setCropPosition({
|
|
x: (img.width - cropSize) / 2,
|
|
y: (img.height - cropSize) / 2,
|
|
size: cropSize
|
|
})
|
|
}
|
|
img.src = e.target.result
|
|
}
|
|
reader.readAsDataURL(file)
|
|
}
|
|
|
|
const handleCropDragStart = (e) => {
|
|
e.preventDefault()
|
|
setIsDragging(true)
|
|
const img = document.getElementById('crop-image')
|
|
if (!img) return
|
|
|
|
const rect = img.getBoundingClientRect()
|
|
const scaleX = imageDimensions.width / rect.width
|
|
const scaleY = imageDimensions.height / rect.height
|
|
|
|
setDragStart({
|
|
x: e.clientX - (cropPosition.x / scaleX + rect.left),
|
|
y: e.clientY - (cropPosition.y / scaleY + rect.top)
|
|
})
|
|
}
|
|
|
|
const handleCropDrag = (e) => {
|
|
if (!isDragging) return
|
|
|
|
const img = document.getElementById('crop-image')
|
|
if (!img) return
|
|
|
|
const rect = img.getBoundingClientRect()
|
|
const scaleX = imageDimensions.width / rect.width
|
|
const scaleY = imageDimensions.height / rect.height
|
|
|
|
const newX = (e.clientX - rect.left - dragStart.x) * scaleX
|
|
const newY = (e.clientY - rect.top - dragStart.y) * scaleY
|
|
|
|
// Begrenze auf Bildgrenzen
|
|
const maxX = imageDimensions.width - cropPosition.size
|
|
const maxY = imageDimensions.height - cropPosition.size
|
|
|
|
setCropPosition(prev => ({
|
|
...prev,
|
|
x: Math.max(0, Math.min(maxX, newX)),
|
|
y: Math.max(0, Math.min(maxY, newY))
|
|
}))
|
|
}
|
|
|
|
const handleCropDragEnd = () => {
|
|
setIsDragging(false)
|
|
}
|
|
|
|
const handleCropResize = (e) => {
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
setIsResizing(true)
|
|
|
|
const img = document.getElementById('crop-image')
|
|
if (!img) return
|
|
|
|
const rect = img.getBoundingClientRect()
|
|
const scale = Math.min(imageDimensions.width / rect.width, imageDimensions.height / rect.height)
|
|
const startY = e.clientY
|
|
const startSize = cropPosition.size
|
|
const startX = cropPosition.x
|
|
const startYPos = cropPosition.y
|
|
|
|
const handleMouseMove = (moveEvent) => {
|
|
const deltaY = (moveEvent.clientY - startY) * scale
|
|
const newSize = Math.max(50, Math.min(
|
|
Math.min(imageDimensions.width, imageDimensions.height),
|
|
startSize - deltaY
|
|
))
|
|
|
|
// Zentriere Crop-Bereich bei Größenänderung
|
|
const maxX = imageDimensions.width - newSize
|
|
const maxY = imageDimensions.height - newSize
|
|
|
|
setCropPosition({
|
|
x: Math.max(0, Math.min(maxX, startX + (startSize - newSize) / 2)),
|
|
y: Math.max(0, Math.min(maxY, startYPos + (startSize - newSize) / 2)),
|
|
size: newSize
|
|
})
|
|
}
|
|
|
|
const handleMouseUp = () => {
|
|
setIsResizing(false)
|
|
document.removeEventListener('mousemove', handleMouseMove)
|
|
document.removeEventListener('mouseup', handleMouseUp)
|
|
}
|
|
|
|
document.addEventListener('mousemove', handleMouseMove)
|
|
document.addEventListener('mouseup', handleMouseUp)
|
|
}
|
|
|
|
const cropImageToCircle = async () => {
|
|
if (!cropImage) return null
|
|
|
|
return new Promise((resolve) => {
|
|
const img = new Image()
|
|
img.onload = () => {
|
|
const canvas = document.createElement('canvas')
|
|
const ctx = canvas.getContext('2d')
|
|
const size = cropPosition.size
|
|
|
|
canvas.width = size
|
|
canvas.height = size
|
|
|
|
// Erstelle kreisförmigen Clip-Pfad
|
|
ctx.beginPath()
|
|
ctx.arc(size / 2, size / 2, size / 2, 0, 2 * Math.PI)
|
|
ctx.clip()
|
|
|
|
// Zeichne zugeschnittenes Bild
|
|
ctx.drawImage(
|
|
img,
|
|
cropPosition.x, cropPosition.y, cropPosition.size, cropPosition.size,
|
|
0, 0, size, size
|
|
)
|
|
|
|
// Konvertiere zu Blob
|
|
canvas.toBlob((blob) => {
|
|
resolve(blob)
|
|
}, 'image/png', 0.95)
|
|
}
|
|
img.src = cropImage
|
|
})
|
|
}
|
|
|
|
const handleCropConfirm = async () => {
|
|
setUploadingAvatar(true)
|
|
setError('')
|
|
setSuccess('')
|
|
|
|
try {
|
|
const croppedBlob = await cropImageToCircle()
|
|
if (!croppedBlob) {
|
|
setError('Fehler beim Zuschneiden des Bildes')
|
|
setUploadingAvatar(false)
|
|
return
|
|
}
|
|
|
|
// Erstelle File aus Blob
|
|
const file = new File([croppedBlob], selectedFile.name, { type: 'image/png' })
|
|
|
|
const formData = new FormData()
|
|
formData.append('avatar', file)
|
|
|
|
const response = await authFetch(`/api/users/${user.id}/avatar`, {
|
|
method: 'POST',
|
|
body: formData,
|
|
})
|
|
|
|
if (response.ok) {
|
|
setSuccess('Profilbild erfolgreich hochgeladen')
|
|
setShowCropModal(false)
|
|
setSelectedFile(null)
|
|
setCropImage(null)
|
|
// Lade Profilbild neu
|
|
loadAvatar()
|
|
} else {
|
|
const errorData = await response.json()
|
|
setError(errorData.error || 'Fehler beim Hochladen des Profilbilds')
|
|
}
|
|
} catch (err) {
|
|
setError('Fehler beim Hochladen des Profilbilds')
|
|
console.error('Error uploading avatar:', err)
|
|
} finally {
|
|
setUploadingAvatar(false)
|
|
}
|
|
}
|
|
|
|
const handleSubmit = async (e) => {
|
|
e.preventDefault()
|
|
setError('')
|
|
setSuccess('')
|
|
setShowSuccessAnimation(false)
|
|
setLoading(true)
|
|
|
|
// Validierung: Passwort-Bestätigung muss übereinstimmen
|
|
if (formData.password && formData.password !== formData.confirmPassword) {
|
|
setError('Die Passwörter stimmen nicht überein')
|
|
setLoading(false)
|
|
return
|
|
}
|
|
|
|
// Validierung: Wenn Passwort geändert wird, muss altes Passwort vorhanden sein
|
|
if (formData.password && !formData.oldPassword) {
|
|
setError('Bitte geben Sie das alte Passwort ein, um das Passwort zu ändern')
|
|
setLoading(false)
|
|
return
|
|
}
|
|
|
|
try {
|
|
const body = {
|
|
// Nur der spezielle Admin-User mit UID 'admin': Username und Email nicht ändern
|
|
// Andere Admin-User können ihre Daten ändern
|
|
...(user?.id !== 'admin' && formData.username && { username: formData.username }),
|
|
...(user?.id !== 'admin' && formData.email && { email: formData.email }),
|
|
...(formData.password && {
|
|
password: formData.password,
|
|
oldPassword: formData.oldPassword
|
|
})
|
|
}
|
|
|
|
const response = await authFetch(`/api/users/${user.id}`, {
|
|
method: 'PUT',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify(body),
|
|
})
|
|
|
|
if (response.ok) {
|
|
setShowSuccessAnimation(true)
|
|
// Warte kurz, damit Animation sichtbar ist
|
|
setTimeout(() => {
|
|
setShowSuccessAnimation(false)
|
|
// Aktualisiere User-Daten im AuthContext
|
|
window.location.reload()
|
|
}, 2000)
|
|
} else {
|
|
const errorData = await response.json()
|
|
setError(errorData.error || 'Fehler beim Aktualisieren des Profils')
|
|
setShowSuccessAnimation(false)
|
|
}
|
|
} catch (err) {
|
|
setError('Fehler beim Aktualisieren des Profils')
|
|
setShowSuccessAnimation(false)
|
|
console.error('Error updating profile:', err)
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
const handleChange = (e) => {
|
|
setFormData({
|
|
...formData,
|
|
[e.target.name]: e.target.value
|
|
})
|
|
// Clear success/error messages when user starts typing
|
|
if (success) setSuccess('')
|
|
if (error) setError('')
|
|
}
|
|
|
|
if (!user) {
|
|
return (
|
|
<div className="p-8 min-h-full bg-gradient-to-r from-slate-700 to-slate-900 flex items-center justify-center">
|
|
<p className="text-slate-300">Lade Profil...</p>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="p-8 min-h-full bg-gradient-to-r from-slate-700 to-slate-900">
|
|
<div className="max-w-4xl mx-auto">
|
|
<div className="mb-8">
|
|
<h1 className="text-4xl font-bold text-white mb-2">Mein Profil</h1>
|
|
<p className="text-lg text-slate-200">
|
|
Verwalten Sie Ihre persönlichen Daten und Einstellungen.
|
|
</p>
|
|
</div>
|
|
|
|
<div className="bg-slate-800/80 backdrop-blur-sm rounded-lg shadow-xl border border-slate-600/50 p-6 mb-6">
|
|
{/* Profilbild */}
|
|
<div className="flex items-center gap-6 mb-8 pb-8 border-b border-slate-700/50">
|
|
<div className="relative">
|
|
{avatarUrl ? (
|
|
<img
|
|
src={avatarUrl}
|
|
alt="Profilbild"
|
|
className="w-24 h-24 rounded-full object-cover border-2 border-slate-600"
|
|
onError={() => {
|
|
// Wenn Bild nicht geladen werden kann, setze avatarUrl auf null
|
|
setAvatarUrl(null)
|
|
}}
|
|
/>
|
|
) : (
|
|
<div className="w-24 h-24 rounded-full bg-slate-700/50 border-2 border-slate-600 flex items-center justify-center">
|
|
<span className="text-4xl text-slate-400">👤</span>
|
|
</div>
|
|
)}
|
|
<label
|
|
className="absolute bottom-0 right-0 w-8 h-8 bg-blue-600 hover:bg-blue-700 rounded-full flex items-center justify-center text-white text-sm transition-colors cursor-pointer shadow-lg"
|
|
title="Profilbild ändern"
|
|
>
|
|
{uploadingAvatar ? (
|
|
<svg className="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
|
</svg>
|
|
) : (
|
|
'📷'
|
|
)}
|
|
<input
|
|
type="file"
|
|
accept="image/jpeg,image/jpg,image/png,image/gif,image/webp"
|
|
onChange={handleFileSelect}
|
|
className="hidden"
|
|
disabled={uploadingAvatar}
|
|
/>
|
|
</label>
|
|
</div>
|
|
<div>
|
|
<h2 className="text-2xl font-semibold text-white mb-1">{user.username}</h2>
|
|
<p className="text-slate-300">{user.email}</p>
|
|
<p className="text-xs text-slate-400 mt-2">
|
|
{avatarUrl ? 'Klicken Sie auf das Kamera-Icon, um Ihr Profilbild zu ändern' : 'Klicken Sie auf das Kamera-Icon, um ein Profilbild hochzuladen'}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Profil bearbeiten Formular */}
|
|
<form onSubmit={handleSubmit} className="space-y-4">
|
|
<div>
|
|
<label htmlFor="username" className="block text-sm font-medium text-slate-200 mb-2">
|
|
Benutzername
|
|
</label>
|
|
<input
|
|
type="text"
|
|
id="username"
|
|
name="username"
|
|
value={formData.username}
|
|
onChange={handleChange}
|
|
required
|
|
disabled={user?.id === 'admin'}
|
|
className={`w-full px-4 py-2 bg-slate-700/50 border border-slate-600 rounded-lg text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
|
|
user?.id === 'admin' ? 'opacity-50 cursor-not-allowed' : ''
|
|
}`}
|
|
placeholder="Geben Sie Ihren Benutzernamen ein"
|
|
/>
|
|
{user?.id === 'admin' && (
|
|
<p className="mt-1 text-xs text-slate-400">Der Benutzername des Admin-Users mit UID 'admin' kann nicht geändert werden</p>
|
|
)}
|
|
</div>
|
|
|
|
<div>
|
|
<label htmlFor="email" className="block text-sm font-medium text-slate-200 mb-2">
|
|
E-Mail
|
|
</label>
|
|
<input
|
|
type="email"
|
|
id="email"
|
|
name="email"
|
|
value={formData.email}
|
|
onChange={handleChange}
|
|
required
|
|
disabled={user?.id === 'admin'}
|
|
className={`w-full px-4 py-2 bg-slate-700/50 border border-slate-600 rounded-lg text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
|
|
user?.id === 'admin' ? 'opacity-50 cursor-not-allowed' : ''
|
|
}`}
|
|
placeholder="Geben Sie Ihre E-Mail-Adresse ein"
|
|
/>
|
|
{user?.id === 'admin' && (
|
|
<p className="mt-1 text-xs text-slate-400">Die E-Mail-Adresse des Admin-Users mit UID 'admin' kann nicht geändert werden</p>
|
|
)}
|
|
</div>
|
|
|
|
<div className="pt-4 border-t border-slate-700/50">
|
|
<h3 className="text-lg font-semibold text-white mb-4">Passwort ändern</h3>
|
|
<p className="text-sm text-slate-400 mb-4">
|
|
Lassen Sie die Felder leer, wenn Sie Ihr Passwort nicht ändern möchten.
|
|
</p>
|
|
|
|
<div className="space-y-4">
|
|
<div>
|
|
<label htmlFor="oldPassword" className="block text-sm font-medium text-slate-200 mb-2">
|
|
Altes Passwort {formData.password && '*'}
|
|
</label>
|
|
<input
|
|
type="password"
|
|
id="oldPassword"
|
|
name="oldPassword"
|
|
value={formData.oldPassword}
|
|
onChange={handleChange}
|
|
required={!!formData.password}
|
|
className="w-full px-4 py-2 bg-slate-700/50 border border-slate-600 rounded-lg text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
|
placeholder="Geben Sie Ihr aktuelles Passwort ein"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label htmlFor="password" className="block text-sm font-medium text-slate-200 mb-2">
|
|
Neues Passwort
|
|
</label>
|
|
<input
|
|
type="password"
|
|
id="password"
|
|
name="password"
|
|
value={formData.password}
|
|
onChange={handleChange}
|
|
className="w-full px-4 py-2 bg-slate-700/50 border border-slate-600 rounded-lg text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
|
placeholder="Geben Sie ein neues Passwort ein"
|
|
/>
|
|
{/* Passwortrichtlinie - nur anzeigen wenn Passwort eingegeben wird */}
|
|
{formData.password && (
|
|
<div className="mt-2 p-3 bg-slate-700/30 border border-slate-600/50 rounded-lg">
|
|
<p className="text-xs font-semibold text-slate-300 mb-2">Passwortrichtlinie:</p>
|
|
<ul className="text-xs text-slate-400 space-y-1">
|
|
<li className={`flex items-center gap-2 ${formData.password.length >= 8 ? 'text-green-400' : ''}`}>
|
|
{formData.password.length >= 8 ? '✓' : '○'} Mindestens 8 Zeichen
|
|
</li>
|
|
<li className={`flex items-center gap-2 ${/[A-Z]/.test(formData.password) ? 'text-green-400' : ''}`}>
|
|
{/[A-Z]/.test(formData.password) ? '✓' : '○'} Mindestens ein Großbuchstabe
|
|
</li>
|
|
<li className={`flex items-center gap-2 ${/[a-z]/.test(formData.password) ? 'text-green-400' : ''}`}>
|
|
{/[a-z]/.test(formData.password) ? '✓' : '○'} Mindestens ein Kleinbuchstabe
|
|
</li>
|
|
<li className={`flex items-center gap-2 ${/[0-9]/.test(formData.password) ? 'text-green-400' : ''}`}>
|
|
{/[0-9]/.test(formData.password) ? '✓' : '○'} Mindestens eine Zahl
|
|
</li>
|
|
<li className={`flex items-center gap-2 ${/[^A-Za-z0-9]/.test(formData.password) ? 'text-green-400' : ''}`}>
|
|
{/[^A-Za-z0-9]/.test(formData.password) ? '✓' : '○'} Mindestens ein Sonderzeichen
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div>
|
|
<label htmlFor="confirmPassword" className="block text-sm font-medium text-slate-200 mb-2">
|
|
Neues Passwort bestätigen {formData.password && '*'}
|
|
</label>
|
|
<input
|
|
type="password"
|
|
id="confirmPassword"
|
|
name="confirmPassword"
|
|
value={formData.confirmPassword}
|
|
onChange={handleChange}
|
|
required={!!formData.password}
|
|
className={`w-full px-4 py-2 bg-slate-700/50 border rounded-lg text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
|
|
formData.confirmPassword && formData.password !== formData.confirmPassword
|
|
? 'border-red-500'
|
|
: formData.confirmPassword && formData.password === formData.confirmPassword
|
|
? 'border-green-500'
|
|
: 'border-slate-600'
|
|
}`}
|
|
placeholder="Bestätigen Sie das neue Passwort"
|
|
/>
|
|
{formData.confirmPassword && formData.password !== formData.confirmPassword && (
|
|
<p className="mt-1 text-xs text-red-400">Die Passwörter stimmen nicht überein</p>
|
|
)}
|
|
{formData.confirmPassword && formData.password === formData.confirmPassword && formData.password && (
|
|
<p className="mt-1 text-xs text-green-400">✓ Passwörter stimmen überein</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{error && (
|
|
<div className="p-3 bg-red-500/20 border border-red-500/50 rounded-lg text-red-300 text-sm">
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
|
|
<div className="flex gap-3 pt-4">
|
|
<button
|
|
type="submit"
|
|
disabled={loading || showSuccessAnimation}
|
|
className="px-6 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-800 disabled:cursor-not-allowed text-white font-semibold rounded-lg transition-colors duration-200 flex items-center justify-center"
|
|
>
|
|
{loading && (
|
|
<>
|
|
<svg className="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
|
</svg>
|
|
Wird gespeichert...
|
|
</>
|
|
)}
|
|
{!loading && 'Profil aktualisieren'}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Success Animation Popup */}
|
|
{showSuccessAnimation && (
|
|
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm z-50 flex items-center justify-center">
|
|
<div className="bg-slate-800 rounded-lg shadow-2xl p-8 flex flex-col items-center">
|
|
<div className="relative w-20 h-20 mb-4">
|
|
{/* Ping Animation */}
|
|
<div className="absolute inset-0 bg-green-500 rounded-full animate-ping opacity-75"></div>
|
|
{/* Checkmark Circle */}
|
|
<div className="relative w-20 h-20 bg-green-500 rounded-full flex items-center justify-center">
|
|
<svg className="w-12 h-12 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="3" d="M5 13l4 4L19 7"></path>
|
|
</svg>
|
|
</div>
|
|
</div>
|
|
<p className="text-xl font-semibold text-white">Profil erfolgreich aktualisiert</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Crop Modal */}
|
|
{showCropModal && cropImage && (
|
|
<div className="fixed inset-0 bg-black/80 backdrop-blur-sm z-50 flex items-center justify-center p-4">
|
|
<div className="bg-slate-800 rounded-lg shadow-2xl max-w-4xl w-full max-h-[90vh] overflow-auto">
|
|
<div className="p-6">
|
|
<h2 className="text-2xl font-bold text-white mb-4">Profilbild zuschneiden</h2>
|
|
<p className="text-slate-300 mb-6">
|
|
Verschieben Sie den Kreis, um den gewünschten Bereich auszuwählen. Ziehen Sie an den Ecken, um die Größe zu ändern.
|
|
</p>
|
|
|
|
<div
|
|
className="relative inline-block"
|
|
onMouseMove={(e) => {
|
|
if (isDragging) handleCropDrag(e)
|
|
if (isResizing) return
|
|
}}
|
|
onMouseUp={handleCropDragEnd}
|
|
onMouseLeave={handleCropDragEnd}
|
|
>
|
|
<img
|
|
id="crop-image"
|
|
src={cropImage}
|
|
alt="Zu schneidendes Bild"
|
|
className="max-w-full h-auto block"
|
|
style={{ maxHeight: '70vh' }}
|
|
draggable={false}
|
|
/>
|
|
|
|
{/* Crop-Bereich (Kreis) */}
|
|
{imageDimensions.width > 0 && (
|
|
<div
|
|
className="absolute border-4 border-blue-500 rounded-full cursor-move"
|
|
style={{
|
|
left: `${(cropPosition.x / imageDimensions.width) * 100}%`,
|
|
top: `${(cropPosition.y / imageDimensions.height) * 100}%`,
|
|
width: `${(cropPosition.size / imageDimensions.width) * 100}%`,
|
|
height: `${(cropPosition.size / imageDimensions.height) * 100}%`,
|
|
aspectRatio: '1 / 1',
|
|
boxShadow: '0 0 0 9999px rgba(0, 0, 0, 0.5)',
|
|
pointerEvents: isResizing ? 'none' : 'auto'
|
|
}}
|
|
onMouseDown={handleCropDragStart}
|
|
>
|
|
{/* Resize-Handles an den Ecken */}
|
|
<div
|
|
className="absolute -bottom-2 -right-2 w-6 h-6 bg-blue-500 rounded-full cursor-nwse-resize border-2 border-white z-10 hover:bg-blue-600"
|
|
onMouseDown={handleCropResize}
|
|
/>
|
|
<div
|
|
className="absolute -top-2 -left-2 w-6 h-6 bg-blue-500 rounded-full cursor-nwse-resize border-2 border-white z-10 hover:bg-blue-600"
|
|
onMouseDown={handleCropResize}
|
|
/>
|
|
<div
|
|
className="absolute -top-2 -right-2 w-6 h-6 bg-blue-500 rounded-full cursor-nesw-resize border-2 border-white z-10 hover:bg-blue-600"
|
|
onMouseDown={handleCropResize}
|
|
/>
|
|
<div
|
|
className="absolute -bottom-2 -left-2 w-6 h-6 bg-blue-500 rounded-full cursor-nesw-resize border-2 border-white z-10 hover:bg-blue-600"
|
|
onMouseDown={handleCropResize}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex gap-3 mt-6">
|
|
<button
|
|
onClick={handleCropConfirm}
|
|
disabled={uploadingAvatar}
|
|
className="px-6 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-800 disabled:cursor-not-allowed text-white font-semibold rounded-lg transition-colors"
|
|
>
|
|
{uploadingAvatar ? 'Wird hochgeladen...' : 'Zuschneiden und hochladen'}
|
|
</button>
|
|
<button
|
|
onClick={() => {
|
|
setShowCropModal(false)
|
|
setSelectedFile(null)
|
|
setCropImage(null)
|
|
}}
|
|
disabled={uploadingAvatar}
|
|
className="px-6 py-2 bg-slate-600 hover:bg-slate-700 text-white font-semibold rounded-lg transition-colors"
|
|
>
|
|
Abbrechen
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default Profile
|