more fixes

This commit is contained in:
2026-03-12 07:22:38 +01:00
parent 547215cbe8
commit 4f576ceb04
226 changed files with 14380 additions and 4453 deletions

View 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>
)
}

View File

@@ -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)
}}
/>
</>
)
}

View File

@@ -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>

View File

@@ -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 && (

View File

@@ -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>
)
}

View File

@@ -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>

View File

@@ -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>
)

View 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>
)
}