push newest version
This commit is contained in:
669
frontend/src/pages/Profile.jsx
Normal file
669
frontend/src/pages/Profile.jsx
Normal file
@@ -0,0 +1,669 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
|
||||
const Profile = () => {
|
||||
const { authFetch, user } = useAuth()
|
||||
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 = {
|
||||
...(formData.username && { username: formData.username }),
|
||||
...(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
|
||||
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 Ihren Benutzernamen ein"
|
||||
/>
|
||||
</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
|
||||
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 Ihre E-Mail-Adresse ein"
|
||||
/>
|
||||
</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
|
||||
Reference in New Issue
Block a user