push newest version

This commit is contained in:
2025-11-20 17:59:34 +01:00
parent c0e2df2430
commit 97ccd7bfbf
21 changed files with 3978 additions and 65 deletions

View 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