more fixes
This commit is contained in:
232
resources/js/components/profile/ProfileCoverEditor.jsx
Normal file
232
resources/js/components/profile/ProfileCoverEditor.jsx
Normal file
@@ -0,0 +1,232 @@
|
||||
import React, { useMemo, useRef, useState } from 'react'
|
||||
|
||||
function clamp(value, min, max) {
|
||||
return Math.min(max, Math.max(min, value))
|
||||
}
|
||||
|
||||
export default function ProfileCoverEditor({
|
||||
isOpen,
|
||||
onClose,
|
||||
coverUrl,
|
||||
coverPosition,
|
||||
onCoverUpdated,
|
||||
onCoverRemoved,
|
||||
}) {
|
||||
const previewRef = useRef(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [removing, setRemoving] = useState(false)
|
||||
const [position, setPosition] = useState(coverPosition ?? 50)
|
||||
|
||||
const csrfToken = useMemo(
|
||||
() => document.querySelector('meta[name="csrf-token"]')?.content ?? '',
|
||||
[]
|
||||
)
|
||||
|
||||
if (!isOpen) {
|
||||
return null
|
||||
}
|
||||
|
||||
const updatePositionFromPointer = (clientY) => {
|
||||
const el = previewRef.current
|
||||
if (!el) return
|
||||
const rect = el.getBoundingClientRect()
|
||||
if (rect.height <= 0) return
|
||||
const normalized = ((clientY - rect.top) / rect.height) * 100
|
||||
setPosition(Math.round(clamp(normalized, 0, 100)))
|
||||
}
|
||||
|
||||
const handlePointerDown = (event) => {
|
||||
updatePositionFromPointer(event.clientY)
|
||||
|
||||
const onMove = (moveEvent) => updatePositionFromPointer(moveEvent.clientY)
|
||||
const onUp = () => {
|
||||
window.removeEventListener('pointermove', onMove)
|
||||
window.removeEventListener('pointerup', onUp)
|
||||
}
|
||||
|
||||
window.addEventListener('pointermove', onMove)
|
||||
window.addEventListener('pointerup', onUp)
|
||||
}
|
||||
|
||||
const handleUpload = async (event) => {
|
||||
const file = event.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
setUploading(true)
|
||||
try {
|
||||
const body = new FormData()
|
||||
body.append('cover', file)
|
||||
|
||||
const response = await fetch('/api/profile/cover/upload', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': csrfToken,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body,
|
||||
})
|
||||
|
||||
const payload = await response.json().catch(() => ({}))
|
||||
if (!response.ok) {
|
||||
throw new Error(payload?.message || payload?.error || 'Cover upload failed.')
|
||||
}
|
||||
|
||||
const nextPosition = Number.isFinite(payload.cover_position) ? payload.cover_position : 50
|
||||
setPosition(nextPosition)
|
||||
onCoverUpdated(payload.cover_url, nextPosition)
|
||||
} catch (error) {
|
||||
window.alert(error?.message || 'Cover upload failed.')
|
||||
} finally {
|
||||
setUploading(false)
|
||||
event.target.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const handleSavePosition = async () => {
|
||||
if (!coverUrl) return
|
||||
|
||||
setSaving(true)
|
||||
try {
|
||||
const response = await fetch('/api/profile/cover/position', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': csrfToken,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ position }),
|
||||
})
|
||||
|
||||
const payload = await response.json().catch(() => ({}))
|
||||
if (!response.ok) {
|
||||
throw new Error(payload?.message || payload?.error || 'Could not save position.')
|
||||
}
|
||||
|
||||
onCoverUpdated(coverUrl, payload.cover_position ?? position)
|
||||
onClose()
|
||||
} catch (error) {
|
||||
window.alert(error?.message || 'Could not save position.')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemove = async () => {
|
||||
if (!coverUrl) return
|
||||
|
||||
setRemoving(true)
|
||||
try {
|
||||
const response = await fetch('/api/profile/cover', {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': csrfToken,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
const payload = await response.json().catch(() => ({}))
|
||||
if (!response.ok) {
|
||||
throw new Error(payload?.message || payload?.error || 'Could not remove cover.')
|
||||
}
|
||||
|
||||
setPosition(payload.cover_position ?? 50)
|
||||
onCoverRemoved()
|
||||
onClose()
|
||||
} catch (error) {
|
||||
window.alert(error?.message || 'Could not remove cover.')
|
||||
} finally {
|
||||
setRemoving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4">
|
||||
<div className="w-full max-w-3xl rounded-2xl border border-white/10 bg-[#0d1524] shadow-2xl">
|
||||
<div className="flex items-center justify-between border-b border-white/10 px-5 py-4">
|
||||
<h3 className="text-lg font-semibold text-white">Edit Cover</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-lg p-2 text-slate-400 hover:bg-white/10 hover:text-white"
|
||||
aria-label="Close cover editor"
|
||||
>
|
||||
<i className="fa-solid fa-xmark" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 p-5">
|
||||
<div className="rounded-xl border border-dashed border-slate-600/70 bg-slate-900/50 p-3">
|
||||
<label className="inline-flex cursor-pointer items-center gap-2 rounded-lg bg-sky-600 px-4 py-2 text-sm font-medium text-white hover:bg-sky-500">
|
||||
<i className="fa-solid fa-upload" />
|
||||
{uploading ? 'Uploading...' : 'Upload Cover'}
|
||||
<input
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
className="hidden"
|
||||
onChange={handleUpload}
|
||||
disabled={uploading}
|
||||
/>
|
||||
</label>
|
||||
<p className="mt-2 text-xs text-slate-400">Allowed: JPG, PNG, WEBP. Max 5MB. Recommended: 1920x480.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="mb-2 text-sm text-slate-300">Drag vertically to reposition the cover.</p>
|
||||
<div
|
||||
ref={previewRef}
|
||||
onPointerDown={handlePointerDown}
|
||||
className="relative h-44 w-full cursor-ns-resize overflow-hidden rounded-xl border border-white/10 bg-[#101a2a]"
|
||||
style={{
|
||||
background: coverUrl
|
||||
? `url('${coverUrl}') center ${position}% / cover no-repeat`
|
||||
: 'linear-gradient(135deg, #0f1724 0%, #151e2e 50%, #090f1a 100%)',
|
||||
}}
|
||||
>
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-[#0f1724]/70 to-[#0f1724]/30" />
|
||||
<div
|
||||
className="pointer-events-none absolute left-0 right-0 border-t border-dashed border-sky-400/80"
|
||||
style={{ top: `${position}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center justify-between text-xs text-slate-400">
|
||||
<span>Position</span>
|
||||
<span>{position}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 border-t border-white/10 px-5 py-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRemove}
|
||||
disabled={removing || !coverUrl}
|
||||
className="inline-flex items-center gap-2 rounded-lg border border-red-400/30 px-4 py-2 text-sm font-medium text-red-300 hover:bg-red-500/10 disabled:opacity-50"
|
||||
>
|
||||
<i className={`fa-solid ${removing ? 'fa-circle-notch fa-spin' : 'fa-trash'}`} />
|
||||
Remove Cover
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-lg border border-white/15 px-4 py-2 text-sm text-slate-300 hover:bg-white/10"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSavePosition}
|
||||
disabled={saving || !coverUrl}
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-sky-600 px-4 py-2 text-sm font-medium text-white hover:bg-sky-500 disabled:opacity-50"
|
||||
>
|
||||
<i className={`fa-solid ${saving ? 'fa-circle-notch fa-spin' : 'fa-floppy-disk'}`} />
|
||||
Save Position
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user