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>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState } from 'react'
|
||||
import { router } from '@inertiajs/react'
|
||||
import ProfileCoverEditor from './ProfileCoverEditor'
|
||||
|
||||
/**
|
||||
* ProfileHero
|
||||
@@ -10,6 +10,9 @@ export default function ProfileHero({ user, profile, isOwner, viewerIsFollowing,
|
||||
const [count, setCount] = useState(followerCount)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [hovering, setHovering] = useState(false)
|
||||
const [editorOpen, setEditorOpen] = useState(false)
|
||||
const [coverUrl, setCoverUrl] = useState(user?.cover_url || heroBgUrl || null)
|
||||
const [coverPosition, setCoverPosition] = useState(Number.isFinite(user?.cover_position) ? user.cover_position : 50)
|
||||
|
||||
const uname = user.username || user.name || 'Unknown'
|
||||
const displayName = user.name || uname
|
||||
@@ -18,6 +21,8 @@ export default function ProfileHero({ user, profile, isOwner, viewerIsFollowing,
|
||||
? new Date(user.created_at).toLocaleDateString('en-US', { month: 'long', year: 'numeric' })
|
||||
: null
|
||||
|
||||
const bio = profile?.bio || profile?.about || ''
|
||||
|
||||
const toggleFollow = async () => {
|
||||
if (loading) return
|
||||
setLoading(true)
|
||||
@@ -39,159 +44,190 @@ export default function ProfileHero({ user, profile, isOwner, viewerIsFollowing,
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative overflow-hidden border-b border-white/10">
|
||||
{/* Cover / hero background */}
|
||||
<div
|
||||
className="w-full"
|
||||
style={{
|
||||
height: 'clamp(160px, 22vw, 260px)',
|
||||
background: heroBgUrl
|
||||
? `url('${heroBgUrl}') center/cover no-repeat`
|
||||
: 'linear-gradient(135deg, #0f1724 0%, #151e2e 50%, #090f1a 100%)',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{/* Overlay */}
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
background: heroBgUrl
|
||||
? 'linear-gradient(to right, rgba(15,23,36,0.95) 0%, rgba(15,23,36,0.75) 50%, rgba(15,23,36,0.45) 100%)'
|
||||
: 'radial-gradient(ellipse at 20% 50%, rgba(77,163,255,.12) 0%, transparent 60%), radial-gradient(ellipse at 80% 20%, rgba(224,122,33,.08) 0%, transparent 50%)',
|
||||
}}
|
||||
/>
|
||||
{/* Nebula grain decoration */}
|
||||
<div className="absolute inset-0 opacity-[0.06] pointer-events-none" style={{ backgroundImage: 'url(/gfx/noise.png)', backgroundSize: '32px' }} />
|
||||
</div>
|
||||
|
||||
{/* Identity block – overlaps cover at bottom */}
|
||||
<div className="max-w-6xl mx-auto px-4">
|
||||
<div className="relative -mt-16 pb-5 flex flex-col sm:flex-row sm:items-end gap-4">
|
||||
|
||||
{/* Avatar */}
|
||||
<div className="shrink-0 z-10">
|
||||
<img
|
||||
src={user.avatar_url || '/default/avatar_default.webp'}
|
||||
alt={`${uname}'s avatar`}
|
||||
className="w-24 h-24 sm:w-28 sm:h-28 rounded-2xl object-cover ring-4 ring-[#0f1724] shadow-xl shadow-black/60"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Name + meta */}
|
||||
<div className="flex-1 min-w-0 pb-1">
|
||||
<h1 className="text-2xl sm:text-3xl font-bold text-white leading-tight">
|
||||
{displayName}
|
||||
</h1>
|
||||
<p className="text-slate-400 text-sm mt-0.5 font-mono">@{uname}</p>
|
||||
<div className="flex flex-wrap items-center gap-3 mt-2 text-xs text-slate-500">
|
||||
{countryName && (
|
||||
<span className="flex items-center gap-1.5">
|
||||
{profile?.country_code && (
|
||||
<img
|
||||
src={`/gfx/flags/shiny/24/${encodeURIComponent(profile.country_code)}.png`}
|
||||
alt={countryName}
|
||||
className="w-4 h-auto rounded-sm"
|
||||
onError={(e) => { e.target.style.display = 'none' }}
|
||||
/>
|
||||
)}
|
||||
{countryName}
|
||||
</span>
|
||||
)}
|
||||
{joinDate && (
|
||||
<span className="flex items-center gap-1">
|
||||
<i className="fa-solid fa-calendar-days fa-fw opacity-60" />
|
||||
Joined {joinDate}
|
||||
</span>
|
||||
)}
|
||||
{profile?.website && (
|
||||
<a
|
||||
href={profile.website.startsWith('http') ? profile.website : `https://${profile.website}`}
|
||||
target="_blank"
|
||||
rel="nofollow noopener noreferrer"
|
||||
className="flex items-center gap-1 text-sky-400 hover:text-sky-300 transition-colors"
|
||||
<>
|
||||
<div className="max-w-6xl mx-auto px-4 pt-4">
|
||||
<div className="relative overflow-hidden rounded-2xl border border-white/10">
|
||||
<div
|
||||
className="w-full h-[180px] md:h-[220px] xl:h-[252px]"
|
||||
style={{
|
||||
background: coverUrl
|
||||
? `url('${coverUrl}') center ${coverPosition}% / cover no-repeat`
|
||||
: 'linear-gradient(140deg, #0f1724 0%, #101a2a 45%, #0a1220 100%)',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{isOwner && (
|
||||
<div className="absolute right-3 top-3 z-20">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditorOpen(true)}
|
||||
className="inline-flex items-center gap-2 rounded-lg border border-white/20 bg-black/40 px-3 py-2 text-xs font-medium text-white hover:bg-black/60"
|
||||
aria-label="Edit cover image"
|
||||
>
|
||||
<i className="fa-solid fa-link fa-fw" />
|
||||
{(() => {
|
||||
try {
|
||||
const url = profile.website.startsWith('http') ? profile.website : `https://${profile.website}`
|
||||
return new URL(url).hostname
|
||||
} catch {
|
||||
return profile.website
|
||||
}
|
||||
})()}
|
||||
</a>
|
||||
<i className="fa-solid fa-image" />
|
||||
Edit Cover
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
background: coverUrl
|
||||
? 'linear-gradient(to bottom, rgba(0,0,0,0.2), rgba(0,0,0,0.62))'
|
||||
: 'radial-gradient(ellipse at 16% 40%, rgba(77,163,255,.18) 0%, transparent 60%), radial-gradient(ellipse at 84% 22%, rgba(224,122,33,.12) 0%, transparent 54%)',
|
||||
}}
|
||||
/>
|
||||
<div className="absolute inset-0 opacity-[0.06] pointer-events-none" style={{ backgroundImage: 'url(/gfx/noise.png)', backgroundSize: '32px' }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative -mt-14 md:-mt-16 pb-4 px-1">
|
||||
<div className="flex flex-col md:flex-row md:items-end gap-4 md:gap-5">
|
||||
<div className="mx-auto md:mx-0 shrink-0 z-10">
|
||||
<img
|
||||
src={user.avatar_url || '/default/avatar_default.webp'}
|
||||
alt={`${uname}'s avatar`}
|
||||
className="w-[104px] h-[104px] md:w-[116px] md:h-[116px] rounded-full object-cover border-2 border-white/15 shadow-[0_0_0_6px_rgba(15,23,36,0.95),0_10px_32px_rgba(0,0,0,0.6)]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0 text-center md:text-left">
|
||||
<h1 className="text-[28px] md:text-[34px] font-bold text-white leading-tight tracking-tight">
|
||||
{displayName}
|
||||
</h1>
|
||||
<p className="text-slate-400 text-sm mt-0.5 font-mono">@{uname}</p>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-center md:justify-start gap-2.5 mt-2 text-xs text-slate-400">
|
||||
{countryName && (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-white/5 border border-white/10 px-2.5 py-1">
|
||||
{profile?.country_code && (
|
||||
<img
|
||||
src={`/gfx/flags/shiny/24/${encodeURIComponent(profile.country_code)}.png`}
|
||||
alt={countryName}
|
||||
className="w-4 h-auto rounded-sm"
|
||||
onError={(e) => { e.target.style.display = 'none' }}
|
||||
/>
|
||||
)}
|
||||
{countryName}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{joinDate && (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-white/5 border border-white/10 px-2.5 py-1">
|
||||
<i className="fa-solid fa-calendar-days fa-fw opacity-70" />
|
||||
Joined {joinDate}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{profile?.website && (
|
||||
<a
|
||||
href={profile.website.startsWith('http') ? profile.website : `https://${profile.website}`}
|
||||
target="_blank"
|
||||
rel="nofollow noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 rounded-full bg-white/5 border border-white/10 px-2.5 py-1 text-sky-300 hover:text-sky-200 hover:bg-white/10 transition-colors"
|
||||
>
|
||||
<i className="fa-solid fa-link fa-fw" />
|
||||
{(() => {
|
||||
try {
|
||||
const url = profile.website.startsWith('http') ? profile.website : `https://${profile.website}`
|
||||
return new URL(url).hostname
|
||||
} catch {
|
||||
return profile.website
|
||||
}
|
||||
})()}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{bio && (
|
||||
<p className="text-sm text-slate-300/90 mt-3 max-w-2xl leading-relaxed line-clamp-2 md:line-clamp-3 mx-auto md:mx-0">
|
||||
{bio}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 flex items-center justify-center md:justify-end gap-2 pb-0.5">
|
||||
{isOwner ? (
|
||||
<>
|
||||
<a
|
||||
href="/dashboard/profile"
|
||||
className="inline-flex items-center gap-2 px-4 py-2.5 rounded-xl text-sm font-medium border border-white/15 text-slate-300 hover:text-white hover:bg-white/5 transition-all"
|
||||
aria-label="Edit profile"
|
||||
>
|
||||
<i className="fa-solid fa-pen fa-fw" />
|
||||
Edit Profile
|
||||
</a>
|
||||
<a
|
||||
href="/studio"
|
||||
className="inline-flex items-center gap-2 px-4 py-2.5 rounded-xl text-sm font-medium bg-sky-600 hover:bg-sky-500 text-white transition-all shadow-lg shadow-sky-900/30"
|
||||
aria-label="Open Studio"
|
||||
>
|
||||
<i className="fa-solid fa-wand-magic-sparkles fa-fw" />
|
||||
Studio
|
||||
</a>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={toggleFollow}
|
||||
onMouseEnter={() => setHovering(true)}
|
||||
onMouseLeave={() => setHovering(false)}
|
||||
disabled={loading}
|
||||
aria-label={following ? 'Unfollow' : 'Follow'}
|
||||
className={`inline-flex items-center gap-2 px-4 py-2.5 rounded-xl text-sm font-medium border transition-all ${
|
||||
following
|
||||
? hovering
|
||||
? 'bg-red-500/10 border-red-400/40 text-red-400'
|
||||
: 'bg-green-500/10 border-green-400/40 text-green-400'
|
||||
: 'bg-sky-500/10 border-sky-400/40 text-sky-400 hover:bg-sky-500/20'
|
||||
}`}
|
||||
>
|
||||
<i className={`fa-solid fa-fw ${
|
||||
loading
|
||||
? 'fa-circle-notch fa-spin'
|
||||
: following
|
||||
? hovering ? 'fa-user-minus' : 'fa-user-check'
|
||||
: 'fa-user-plus'
|
||||
}`} />
|
||||
<span>{following ? (hovering ? 'Unfollow' : 'Following') : 'Follow'}</span>
|
||||
<span className="text-xs opacity-70">{count.toLocaleString()}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
if (navigator.share) {
|
||||
navigator.share({ title: `${displayName} on Skinbase`, url: window.location.href })
|
||||
} else {
|
||||
navigator.clipboard.writeText(window.location.href)
|
||||
}
|
||||
}}
|
||||
aria-label="Share profile"
|
||||
className="p-2.5 rounded-xl border border-white/10 text-slate-400 hover:text-white hover:bg-white/5 transition-all"
|
||||
>
|
||||
<i className="fa-solid fa-share-nodes fa-fw" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="shrink-0 flex items-center gap-2 pb-1">
|
||||
{isOwner ? (
|
||||
<>
|
||||
<a
|
||||
href="/dashboard/profile"
|
||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-medium border border-white/15 text-slate-300 hover:text-white hover:bg-white/5 transition-all"
|
||||
aria-label="Edit profile"
|
||||
>
|
||||
<i className="fa-solid fa-pen fa-fw" />
|
||||
<span className="hidden sm:inline">Edit Profile</span>
|
||||
</a>
|
||||
<a
|
||||
href="/studio"
|
||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-medium bg-sky-600 hover:bg-sky-500 text-white transition-all shadow-lg shadow-sky-900/30"
|
||||
aria-label="Open Studio"
|
||||
>
|
||||
<i className="fa-solid fa-wand-magic-sparkles fa-fw" />
|
||||
<span className="hidden sm:inline">Studio</span>
|
||||
</a>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* Follow button */}
|
||||
<button
|
||||
onClick={toggleFollow}
|
||||
onMouseEnter={() => setHovering(true)}
|
||||
onMouseLeave={() => setHovering(false)}
|
||||
disabled={loading}
|
||||
aria-label={following ? 'Unfollow' : 'Follow'}
|
||||
className={`inline-flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-medium border transition-all ${
|
||||
following
|
||||
? hovering
|
||||
? 'bg-red-500/10 border-red-400/40 text-red-400'
|
||||
: 'bg-green-500/10 border-green-400/40 text-green-400'
|
||||
: 'bg-sky-500/10 border-sky-400/40 text-sky-400 hover:bg-sky-500/20'
|
||||
}`}
|
||||
>
|
||||
<i className={`fa-solid fa-fw ${
|
||||
loading
|
||||
? 'fa-circle-notch fa-spin'
|
||||
: following
|
||||
? hovering ? 'fa-user-minus' : 'fa-user-check'
|
||||
: 'fa-user-plus'
|
||||
}`} />
|
||||
<span>{following ? (hovering ? 'Unfollow' : 'Following') : 'Follow'}</span>
|
||||
<span className="text-xs opacity-60">({count.toLocaleString()})</span>
|
||||
</button>
|
||||
{/* Share */}
|
||||
<button
|
||||
onClick={() => {
|
||||
if (navigator.share) {
|
||||
navigator.share({ title: `${displayName} on Skinbase`, url: window.location.href })
|
||||
} else {
|
||||
navigator.clipboard.writeText(window.location.href)
|
||||
}
|
||||
}}
|
||||
aria-label="Share profile"
|
||||
className="p-2.5 rounded-xl border border-white/10 text-slate-400 hover:text-white hover:bg-white/5 transition-all"
|
||||
>
|
||||
<i className="fa-solid fa-share-nodes fa-fw" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ProfileCoverEditor
|
||||
isOpen={editorOpen}
|
||||
onClose={() => setEditorOpen(false)}
|
||||
coverUrl={coverUrl}
|
||||
coverPosition={coverPosition}
|
||||
onCoverUpdated={(nextUrl, nextPosition) => {
|
||||
setCoverUrl(nextUrl)
|
||||
setCoverPosition(nextPosition)
|
||||
}}
|
||||
onCoverRemoved={() => {
|
||||
setCoverUrl(null)
|
||||
setCoverPosition(50)
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -25,9 +25,9 @@ export default function ProfileStatsRow({ stats, followerCount, onTabChange }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white/3 border-b border-white/10 overflow-x-auto" style={{ background: 'rgba(255,255,255,0.025)' }}>
|
||||
<div className="border-b border-white/10" style={{ background: 'rgba(255,255,255,0.02)' }}>
|
||||
<div className="max-w-6xl mx-auto px-4">
|
||||
<div className="flex gap-1 py-2 min-w-max sm:min-w-0 sm:flex-wrap">
|
||||
<div className="grid grid-cols-3 md:grid-cols-6 gap-2 py-3">
|
||||
{PILLS.map((pill) => (
|
||||
<button
|
||||
key={pill.key}
|
||||
@@ -35,19 +35,19 @@ export default function ProfileStatsRow({ stats, followerCount, onTabChange }) {
|
||||
title={pill.label}
|
||||
disabled={!pill.tab}
|
||||
className={`
|
||||
flex items-center gap-2 px-4 py-2 rounded-lg text-sm transition-all
|
||||
flex flex-col items-center justify-center gap-1 px-2 py-3 rounded-xl text-sm transition-all text-center
|
||||
border border-white/10 bg-white/[0.02]
|
||||
${pill.tab
|
||||
? 'cursor-pointer hover:bg-white/8 hover:text-white text-slate-300 group'
|
||||
: 'cursor-default text-slate-400'
|
||||
? 'cursor-pointer hover:bg-white/[0.06] hover:border-white/20 hover:text-white text-slate-300 group'
|
||||
: 'cursor-default text-slate-400 opacity-90'
|
||||
}
|
||||
`}
|
||||
style={{ background: 'transparent' }}
|
||||
>
|
||||
<i className={`fa-solid ${pill.icon} fa-fw text-xs opacity-60 group-hover:opacity-80`} />
|
||||
<span className="font-bold text-white tabular-nums">
|
||||
<i className={`fa-solid ${pill.icon} fa-fw text-xs ${pill.tab ? 'opacity-70 group-hover:opacity-100' : 'opacity-60'}`} />
|
||||
<span className="font-bold text-white tabular-nums text-base leading-none">
|
||||
{Number(values[pill.key]).toLocaleString()}
|
||||
</span>
|
||||
<span className="text-slate-500 text-xs hidden sm:inline">{pill.label}</span>
|
||||
<span className="text-slate-500 text-[11px] uppercase tracking-wide leading-none">{pill.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useEffect, useRef } from 'react'
|
||||
|
||||
export const TABS = [
|
||||
{ id: 'artworks', label: 'Artworks', icon: 'fa-images' },
|
||||
{ id: 'stories', label: 'Stories', icon: 'fa-feather-pointed' },
|
||||
{ id: 'posts', label: 'Posts', icon: 'fa-newspaper' },
|
||||
{ id: 'collections', label: 'Collections', icon: 'fa-layer-group' },
|
||||
{ id: 'about', label: 'About', icon: 'fa-id-card' },
|
||||
@@ -35,7 +36,7 @@ export default function ProfileTabs({ activeTab, onTabChange }) {
|
||||
aria-label="Profile sections"
|
||||
role="tablist"
|
||||
>
|
||||
<div className="max-w-6xl mx-auto px-3 flex gap-0 min-w-max sm:min-w-0">
|
||||
<div className="max-w-6xl mx-auto px-3 flex gap-1 py-1 min-w-max sm:min-w-0">
|
||||
{TABS.map((tab) => {
|
||||
const isActive = activeTab === tab.id
|
||||
return (
|
||||
@@ -47,16 +48,16 @@ export default function ProfileTabs({ activeTab, onTabChange }) {
|
||||
aria-selected={isActive}
|
||||
aria-controls={`tabpanel-${tab.id}`}
|
||||
className={`
|
||||
relative flex items-center gap-2 px-4 py-3.5 text-sm font-medium whitespace-nowrap
|
||||
relative flex items-center gap-2 px-4 py-3 text-sm font-medium whitespace-nowrap rounded-lg
|
||||
transition-colors duration-150 outline-none
|
||||
focus-visible:ring-2 focus-visible:ring-sky-400/70 rounded-t
|
||||
${isActive
|
||||
? 'text-white'
|
||||
: 'text-slate-400 hover:text-slate-200'
|
||||
? 'text-white bg-white/[0.05]'
|
||||
: 'text-slate-400 hover:text-slate-200 hover:bg-white/[0.03]'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<i className={`fa-solid ${tab.icon} fa-fw text-xs ${isActive ? 'text-sky-400' : ''}`} />
|
||||
<i className={`fa-solid ${tab.icon} fa-fw text-xs ${isActive ? 'text-sky-400' : 'opacity-75'}`} />
|
||||
{tab.label}
|
||||
{/* Active indicator bar */}
|
||||
{isActive && (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useCallback } from 'react'
|
||||
import ArtworkCard from '../../gallery/ArtworkCard'
|
||||
import React, { useState } from 'react'
|
||||
import MasonryGallery from '../../gallery/MasonryGallery'
|
||||
|
||||
const SORT_OPTIONS = [
|
||||
{ value: 'latest', label: 'Latest' },
|
||||
@@ -9,30 +9,6 @@ const SORT_OPTIONS = [
|
||||
{ value: 'favs', label: 'Most Favourited' },
|
||||
]
|
||||
|
||||
function ArtworkSkeleton() {
|
||||
return (
|
||||
<div className="rounded-2xl overflow-hidden bg-white/5 animate-pulse">
|
||||
<div className="aspect-[4/3] bg-white/8" />
|
||||
<div className="p-2 space-y-1.5">
|
||||
<div className="h-3 bg-white/8 rounded w-3/4" />
|
||||
<div className="h-2 bg-white/5 rounded w-1/2" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyState({ username }) {
|
||||
return (
|
||||
<div className="col-span-full flex flex-col items-center justify-center py-20 text-center">
|
||||
<div className="w-20 h-20 rounded-2xl bg-white/5 flex items-center justify-center mb-5 text-slate-500">
|
||||
<i className="fa-solid fa-image text-3xl" />
|
||||
</div>
|
||||
<p className="text-slate-400 font-medium">No artworks yet</p>
|
||||
<p className="text-slate-600 text-sm mt-1">@{username} hasn't uploaded anything yet.</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Featured artworks horizontal scroll strip.
|
||||
*/
|
||||
@@ -40,31 +16,31 @@ function FeaturedStrip({ featuredArtworks }) {
|
||||
if (!featuredArtworks?.length) return null
|
||||
|
||||
return (
|
||||
<div className="mb-6">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-widest text-slate-500 mb-3 flex items-center gap-2">
|
||||
<i className="fa-solid fa-star text-yellow-400 fa-fw" />
|
||||
<div className="mb-7 rounded-2xl border border-white/10 bg-white/[0.02] p-4 md:p-5">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-widest text-slate-400 mb-3 flex items-center gap-2">
|
||||
<i className="fa-solid fa-star text-amber-400 fa-fw" />
|
||||
Featured
|
||||
</h2>
|
||||
<div className="flex gap-3 overflow-x-auto pb-2 scrollbar-hide snap-x snap-mandatory">
|
||||
{featuredArtworks.map((art) => (
|
||||
<div className="flex gap-4 overflow-x-auto pb-2 scrollbar-hide snap-x snap-mandatory">
|
||||
{featuredArtworks.slice(0, 5).map((art) => (
|
||||
<a
|
||||
key={art.id}
|
||||
href={`/art/${art.id}/${slugify(art.name)}`}
|
||||
className="group shrink-0 snap-start w-40 sm:w-48"
|
||||
className="group shrink-0 snap-start w-56 md:w-64"
|
||||
>
|
||||
<div className="overflow-hidden rounded-xl ring-1 ring-white/10 bg-black/30 aspect-[4/3] hover:ring-sky-400/40 transition-all">
|
||||
<div className="overflow-hidden rounded-xl ring-1 ring-white/10 bg-black/30 aspect-[5/3] hover:ring-sky-400/40 transition-all">
|
||||
<img
|
||||
src={art.thumb}
|
||||
alt={art.name}
|
||||
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
|
||||
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-[1.03]"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-slate-400 mt-1.5 truncate group-hover:text-white transition-colors">
|
||||
<p className="text-sm text-slate-300 mt-2 truncate group-hover:text-white transition-colors">
|
||||
{art.name}
|
||||
</p>
|
||||
{art.label && (
|
||||
<p className="text-[10px] text-slate-600 truncate">{art.label}</p>
|
||||
<p className="text-[11px] text-slate-600 truncate">{art.label}</p>
|
||||
)}
|
||||
</a>
|
||||
))}
|
||||
@@ -86,8 +62,6 @@ export default function TabArtworks({ artworks, featuredArtworks, username, isAc
|
||||
const [sort, setSort] = useState('latest')
|
||||
const [items, setItems] = useState(artworks?.data ?? artworks ?? [])
|
||||
const [nextCursor, setNextCursor] = useState(artworks?.next_cursor ?? null)
|
||||
const [loadingMore, setLoadingMore] = useState(false)
|
||||
const [isInitialLoad] = useState(false) // data SSR-loaded
|
||||
|
||||
const handleSort = async (newSort) => {
|
||||
setSort(newSort)
|
||||
@@ -104,23 +78,6 @@ export default function TabArtworks({ artworks, featuredArtworks, username, isAc
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
const loadMore = async () => {
|
||||
if (!nextCursor || loadingMore) return
|
||||
setLoadingMore(true)
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/profile/${encodeURIComponent(username)}/artworks?sort=${sort}&cursor=${encodeURIComponent(nextCursor)}`,
|
||||
{ headers: { Accept: 'application/json' } }
|
||||
)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setItems((prev) => [...prev, ...(data.data ?? data)])
|
||||
setNextCursor(data.next_cursor ?? null)
|
||||
}
|
||||
} catch (_) {}
|
||||
setLoadingMore(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
id="tabpanel-artworks"
|
||||
@@ -151,45 +108,16 @@ export default function TabArtworks({ artworks, featuredArtworks, username, isAc
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Grid */}
|
||||
{isInitialLoad ? (
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">
|
||||
{Array.from({ length: 8 }).map((_, i) => <ArtworkSkeleton key={i} />)}
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="grid grid-cols-1">
|
||||
<EmptyState username={username} />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">
|
||||
{items.map((art, i) => (
|
||||
<ArtworkCard
|
||||
key={art.id ?? i}
|
||||
art={art}
|
||||
loading={i < 8 ? 'eager' : 'lazy'}
|
||||
/>
|
||||
))}
|
||||
{loadingMore && Array.from({ length: 4 }).map((_, i) => <ArtworkSkeleton key={`sk-${i}`} />)}
|
||||
</div>
|
||||
|
||||
{/* Load more */}
|
||||
{nextCursor && (
|
||||
<div className="mt-8 text-center">
|
||||
<button
|
||||
onClick={loadMore}
|
||||
disabled={loadingMore}
|
||||
className="inline-flex items-center gap-2 px-6 py-2.5 rounded-xl bg-white/5 hover:bg-white/10 text-slate-300 text-sm font-medium border border-white/10 transition-all"
|
||||
>
|
||||
{loadingMore
|
||||
? <><i className="fa-solid fa-circle-notch fa-spin fa-fw" /> Loading…</>
|
||||
: <><i className="fa-solid fa-chevron-down fa-fw" /> Load more</>
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{/* Shared masonry gallery component reused from discover/explore */}
|
||||
<MasonryGallery
|
||||
key={`profile-${username}-${sort}`}
|
||||
artworks={items}
|
||||
galleryType="profile"
|
||||
cursorEndpoint={`/api/profile/${encodeURIComponent(username)}/artworks?sort=${encodeURIComponent(sort)}`}
|
||||
initialNextCursor={nextCursor}
|
||||
limit={24}
|
||||
gridClassName="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ export default function TabCollections({ collections }) {
|
||||
</div>
|
||||
<h3 className="text-lg font-bold text-white mb-2">Collections Coming Soon</h3>
|
||||
<p className="text-slate-500 text-sm max-w-sm mx-auto">
|
||||
Group artworks into curated collections. This feature is currently in development.
|
||||
Group your artworks into curated collections.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -14,10 +14,10 @@ function EmptyPostsState({ isOwner, username }) {
|
||||
<p className="text-slate-400 font-medium mb-1">No posts yet</p>
|
||||
{isOwner ? (
|
||||
<p className="text-slate-600 text-sm max-w-xs">
|
||||
Share your thoughts or showcase your artworks. Your first post is a tap away.
|
||||
Share updates or showcase your artworks.
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-slate-600 text-sm">@{username} hasn't posted anything yet.</p>
|
||||
<p className="text-slate-600 text-sm">@{username} has not posted anything yet.</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
49
resources/js/components/profile/tabs/TabStories.jsx
Normal file
49
resources/js/components/profile/tabs/TabStories.jsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import React from 'react'
|
||||
|
||||
export default function TabStories({ stories, username }) {
|
||||
const list = Array.isArray(stories) ? stories : []
|
||||
|
||||
if (!list.length) {
|
||||
return (
|
||||
<div className="rounded-xl border border-white/10 bg-white/[0.02] px-6 py-12 text-center text-slate-300">
|
||||
No stories published yet.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{list.map((story) => (
|
||||
<a
|
||||
key={story.id}
|
||||
href={`/stories/${story.slug}`}
|
||||
className="group overflow-hidden rounded-xl border border-gray-700 bg-gray-800/70 shadow-lg transition duration-200 hover:scale-[1.01] hover:border-sky-500/40"
|
||||
>
|
||||
{story.cover_url ? (
|
||||
<img src={story.cover_url} alt={story.title} className="h-44 w-full object-cover transition-transform duration-300 group-hover:scale-105" />
|
||||
) : (
|
||||
<div className="h-44 w-full bg-gradient-to-br from-gray-900 via-slate-900 to-sky-950" />
|
||||
)}
|
||||
<div className="space-y-2 p-4">
|
||||
<h3 className="line-clamp-2 text-base font-semibold text-white">{story.title}</h3>
|
||||
<p className="line-clamp-2 text-xs text-gray-300">{story.excerpt || ''}</p>
|
||||
<div className="flex items-center gap-3 text-xs text-gray-400">
|
||||
<span>{story.reading_time || 1} min read</span>
|
||||
<span>{story.views || 0} views</span>
|
||||
<span>{story.likes_count || 0} likes</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<a
|
||||
href={`/stories/creator/${username}`}
|
||||
className="inline-flex rounded-lg border border-sky-400/30 bg-sky-500/10 px-3 py-2 text-sm text-sky-300 transition hover:scale-[1.01] hover:text-sky-200"
|
||||
>
|
||||
View all stories
|
||||
</a>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user