refactor: unify artwork card rendering
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import React, { useMemo, useState } from 'react'
|
||||
import NovaConfirmDialog from '../ui/NovaConfirmDialog'
|
||||
|
||||
const AVATAR_FALLBACK = 'https://files.skinbase.org/default/missing_sq.webp'
|
||||
|
||||
@@ -23,6 +24,8 @@ function toCard(item) {
|
||||
export default function CreatorSpotlight({ artwork, presentSq, related = [] }) {
|
||||
const [following, setFollowing] = useState(Boolean(artwork?.viewer?.is_following_author))
|
||||
const [followersCount, setFollowersCount] = useState(Number(artwork?.user?.followers_count || 0))
|
||||
const [confirmOpen, setConfirmOpen] = useState(false)
|
||||
const [pendingFollowState, setPendingFollowState] = useState(null)
|
||||
|
||||
const user = artwork?.user || {}
|
||||
const authorName = user.name || user.username || 'Artist'
|
||||
@@ -43,13 +46,7 @@ export default function CreatorSpotlight({ artwork, presentSq, related = [] }) {
|
||||
return source.slice(0, 12).map(toCard)
|
||||
}, [related, authorName, artwork?.canonical_url])
|
||||
|
||||
const onToggleFollow = async () => {
|
||||
if (following) {
|
||||
const confirmed = window.confirm(`Unfollow @${user.username || authorName}?`)
|
||||
if (!confirmed) return
|
||||
}
|
||||
|
||||
const nextState = !following
|
||||
const persistFollowState = async (nextState) => {
|
||||
setFollowing(nextState)
|
||||
try {
|
||||
const response = await fetch(`/api/users/${user.id}/follow`, {
|
||||
@@ -73,99 +70,135 @@ export default function CreatorSpotlight({ artwork, presentSq, related = [] }) {
|
||||
}
|
||||
}
|
||||
|
||||
const onToggleFollow = async () => {
|
||||
const nextState = !following
|
||||
if (!nextState) {
|
||||
setPendingFollowState(nextState)
|
||||
setConfirmOpen(true)
|
||||
return
|
||||
}
|
||||
|
||||
await persistFollowState(nextState)
|
||||
}
|
||||
|
||||
const onConfirmUnfollow = async () => {
|
||||
if (pendingFollowState === null) return
|
||||
setConfirmOpen(false)
|
||||
await persistFollowState(pendingFollowState)
|
||||
setPendingFollowState(null)
|
||||
}
|
||||
|
||||
const onCloseConfirm = () => {
|
||||
setConfirmOpen(false)
|
||||
setPendingFollowState(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="rounded-2xl border border-white/[0.06] bg-white/[0.03] p-5">
|
||||
{/* Avatar + info — stacked for sidebar */}
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<a href={profileUrl} className="group">
|
||||
<img
|
||||
src={avatar}
|
||||
alt={authorName}
|
||||
className="h-16 w-16 rounded-full border-2 border-white/10 object-cover shadow-lg shadow-black/40 transition-transform duration-200 group-hover:scale-105"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
onError={(event) => {
|
||||
event.currentTarget.src = AVATAR_FALLBACK
|
||||
}}
|
||||
/>
|
||||
</a>
|
||||
|
||||
<a href={profileUrl} className="mt-3 block text-base font-bold text-white transition-colors hover:text-accent">
|
||||
{authorName}
|
||||
</a>
|
||||
{user.username && <p className="text-xs text-white/40">@{user.username}</p>}
|
||||
<p className="mt-1 text-xs font-medium text-white/30">
|
||||
{followersCount.toLocaleString()} Followers
|
||||
</p>
|
||||
|
||||
{/* Profile + Follow buttons */}
|
||||
<div className="mt-4 flex w-full gap-2">
|
||||
<a
|
||||
href={profileUrl}
|
||||
title="View profile"
|
||||
className="flex flex-1 items-center justify-center gap-1.5 rounded-xl border border-white/[0.08] bg-white/[0.04] px-3 py-2.5 text-sm font-medium text-white/70 transition-all hover:border-white/[0.15] hover:bg-white/[0.07] hover:text-white"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="h-4 w-4">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z" />
|
||||
</svg>
|
||||
Profile
|
||||
<>
|
||||
<section className="rounded-2xl border border-white/[0.06] bg-white/[0.03] p-5">
|
||||
{/* Avatar + info — stacked for sidebar */}
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<a href={profileUrl} className="group">
|
||||
<img
|
||||
src={avatar}
|
||||
alt={authorName}
|
||||
className="h-16 w-16 rounded-full border-2 border-white/10 object-cover shadow-lg shadow-black/40 transition-transform duration-200 group-hover:scale-105"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
onError={(event) => {
|
||||
event.currentTarget.src = AVATAR_FALLBACK
|
||||
}}
|
||||
/>
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={following ? 'Unfollow creator' : 'Follow creator'}
|
||||
onClick={onToggleFollow}
|
||||
className={[
|
||||
'flex flex-1 items-center justify-center gap-1.5 rounded-xl px-3 py-2.5 text-sm font-semibold transition-all duration-200',
|
||||
following
|
||||
? 'border border-white/[0.08] bg-white/[0.04] text-white/70 hover:bg-white/[0.07]'
|
||||
: 'bg-accent text-deep shadow-lg shadow-accent/20 hover:brightness-110 hover:shadow-accent/30',
|
||||
].join(' ')}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="h-4 w-4">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3" />
|
||||
</svg>
|
||||
{following ? 'Following' : 'Follow'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* More from creator rail */}
|
||||
{creatorItems.length > 0 && (
|
||||
<div className="mt-5 border-t border-white/[0.06] pt-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-white/80">More from {authorName}</h3>
|
||||
<a href={profileUrl} className="text-white/30 transition-colors hover:text-white/60">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="h-4 w-4">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" />
|
||||
<a href={profileUrl} className="mt-3 block text-base font-bold text-white transition-colors hover:text-accent">
|
||||
{authorName}
|
||||
</a>
|
||||
{user.username && <p className="text-xs text-white/40">@{user.username}</p>}
|
||||
<p className="mt-1 text-xs font-medium text-white/30">
|
||||
{followersCount.toLocaleString()} Followers
|
||||
</p>
|
||||
|
||||
{/* Profile + Follow buttons */}
|
||||
<div className="mt-4 flex w-full gap-2">
|
||||
<a
|
||||
href={profileUrl}
|
||||
title="View profile"
|
||||
className="flex flex-1 items-center justify-center gap-1.5 rounded-xl border border-white/[0.08] bg-white/[0.04] px-3 py-2.5 text-sm font-medium text-white/70 transition-all hover:border-white/[0.15] hover:bg-white/[0.07] hover:text-white"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="h-4 w-4">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z" />
|
||||
</svg>
|
||||
Profile
|
||||
</a>
|
||||
</div>
|
||||
<div className="mt-3 grid grid-cols-3 gap-2">
|
||||
{creatorItems.slice(0, 3).map((item, idx) => (
|
||||
<a key={`${item.id || item.url}-${idx}`} href={item.url} className="group/mini relative overflow-hidden rounded-xl">
|
||||
<div className="aspect-square overflow-hidden bg-deep">
|
||||
<img
|
||||
src={item.thumb || AVATAR_FALLBACK}
|
||||
alt={item.title || 'Artwork'}
|
||||
className="h-full w-full object-cover transition duration-300 group-hover/mini:scale-[1.06]"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
</div>
|
||||
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/50 via-transparent to-transparent" />
|
||||
<div className="absolute bottom-1.5 left-1.5 right-1.5 flex items-center gap-1">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className="h-3 w-3 text-rose-400">
|
||||
<path d="m9.653 16.915-.005-.003-.019-.01a20.759 20.759 0 0 1-1.162-.682 22.045 22.045 0 0 1-2.582-1.9C4.045 12.733 2 10.352 2 7.5a4.5 4.5 0 0 1 8-2.828A4.5 4.5 0 0 1 18 7.5c0 2.852-2.044 5.233-3.885 6.82a22.049 22.049 0 0 1-3.744 2.582l-.019.01-.005.003h-.002a.723.723 0 0 1-.69 0h-.002Z" />
|
||||
</svg>
|
||||
<span className="text-[10px] font-bold text-white drop-shadow">
|
||||
{item.likes ? formatCount(item.likes) : ''}
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={following ? 'Unfollow creator' : 'Follow creator'}
|
||||
onClick={onToggleFollow}
|
||||
className={[
|
||||
'flex flex-1 items-center justify-center gap-1.5 rounded-xl px-3 py-2.5 text-sm font-semibold transition-all duration-200',
|
||||
following
|
||||
? 'border border-white/[0.08] bg-white/[0.04] text-white/70 hover:bg-white/[0.07]'
|
||||
: 'bg-accent text-deep shadow-lg shadow-accent/20 hover:brightness-110 hover:shadow-accent/30',
|
||||
].join(' ')}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="h-4 w-4">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3" />
|
||||
</svg>
|
||||
{following ? 'Following' : 'Follow'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* More from creator rail */}
|
||||
{creatorItems.length > 0 && (
|
||||
<div className="mt-5 border-t border-white/[0.06] pt-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-white/80">More from {authorName}</h3>
|
||||
<a href={profileUrl} className="text-white/30 transition-colors hover:text-white/60">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="h-4 w-4">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
<div className="mt-3 grid grid-cols-3 gap-2">
|
||||
{creatorItems.slice(0, 3).map((item, idx) => (
|
||||
<a key={`${item.id || item.url}-${idx}`} href={item.url} className="group/mini relative overflow-hidden rounded-xl">
|
||||
<div className="aspect-square overflow-hidden bg-deep">
|
||||
<img
|
||||
src={item.thumb || AVATAR_FALLBACK}
|
||||
alt={item.title || 'Artwork'}
|
||||
className="h-full w-full object-cover transition duration-300 group-hover/mini:scale-[1.06]"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
</div>
|
||||
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/50 via-transparent to-transparent" />
|
||||
<div className="absolute bottom-1.5 left-1.5 right-1.5 flex items-center gap-1">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className="h-3 w-3 text-rose-400">
|
||||
<path d="m9.653 16.915-.005-.003-.019-.01a20.759 20.759 0 0 1-1.162-.682 22.045 22.045 0 0 1-2.582-1.9C4.045 12.733 2 10.352 2 7.5a4.5 4.5 0 0 1 8-2.828A4.5 4.5 0 0 1 18 7.5c0 2.852-2.044 5.233-3.885 6.82a22.049 22.049 0 0 1-3.744 2.582l-.019.01-.005.003h-.002a.723.723 0 0 1-.69 0h-.002Z" />
|
||||
</svg>
|
||||
<span className="text-[10px] font-bold text-white drop-shadow">
|
||||
{item.likes ? formatCount(item.likes) : ''}
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<NovaConfirmDialog
|
||||
open={confirmOpen}
|
||||
title="Unfollow creator?"
|
||||
message={`You will stop seeing updates from @${user.username || authorName} in your following feed.`}
|
||||
confirmLabel="Unfollow"
|
||||
cancelLabel="Keep following"
|
||||
confirmTone="danger"
|
||||
onConfirm={onConfirmUnfollow}
|
||||
onClose={onCloseConfirm}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user