update
This commit is contained in:
@@ -192,15 +192,25 @@ function ReportModal({ open, onClose, onSubmit, submitting }) {
|
||||
|
||||
export default function ArtworkActionBar({ artwork, stats, canonicalUrl, onStatsChange }) {
|
||||
const [favorited, setFavorited] = useState(Boolean(artwork?.viewer?.is_favorited))
|
||||
const [bookmarked, setBookmarked] = useState(Boolean(artwork?.viewer?.is_bookmarked))
|
||||
const [bookmarkCount, setBookmarkCount] = useState(Number(stats?.bookmarks ?? artwork?.stats?.bookmarks ?? 0))
|
||||
const [downloading, setDownloading] = useState(false)
|
||||
const [reporting, setReporting] = useState(false)
|
||||
const [reported, setReported] = useState(false)
|
||||
const [reportOpen, setReportOpen] = useState(false)
|
||||
const isLoggedIn = artwork?.viewer != null
|
||||
const isLoggedIn = Boolean(artwork?.viewer?.is_authenticated)
|
||||
useEffect(() => {
|
||||
setFavorited(Boolean(artwork?.viewer?.is_favorited))
|
||||
}, [artwork?.id, artwork?.viewer?.is_favorited])
|
||||
|
||||
useEffect(() => {
|
||||
setBookmarked(Boolean(artwork?.viewer?.is_bookmarked))
|
||||
}, [artwork?.id, artwork?.viewer?.is_bookmarked])
|
||||
|
||||
useEffect(() => {
|
||||
setBookmarkCount(Number(stats?.bookmarks ?? artwork?.stats?.bookmarks ?? 0))
|
||||
}, [artwork?.id, artwork?.stats?.bookmarks, stats?.bookmarks])
|
||||
|
||||
const shareUrl = canonicalUrl || artwork?.canonical_url || (typeof window !== 'undefined' ? window.location.href : '#')
|
||||
const csrfToken = typeof document !== 'undefined'
|
||||
? document.querySelector('meta[name="csrf-token"]')?.getAttribute('content')
|
||||
@@ -249,6 +259,11 @@ export default function ArtworkActionBar({ artwork, stats, canonicalUrl, onStats
|
||||
}
|
||||
|
||||
const onToggleFavorite = async () => {
|
||||
if (!isLoggedIn) {
|
||||
window.location.href = '/login'
|
||||
return
|
||||
}
|
||||
|
||||
const nextState = !favorited
|
||||
setFavorited(nextState)
|
||||
try {
|
||||
@@ -257,6 +272,26 @@ export default function ArtworkActionBar({ artwork, stats, canonicalUrl, onStats
|
||||
} catch { setFavorited(!nextState) }
|
||||
}
|
||||
|
||||
const onToggleBookmark = async () => {
|
||||
if (!isLoggedIn) {
|
||||
window.location.href = '/login'
|
||||
return
|
||||
}
|
||||
|
||||
const nextState = !bookmarked
|
||||
setBookmarked(nextState)
|
||||
setBookmarkCount((current) => Math.max(0, current + (nextState ? 1 : -1)))
|
||||
|
||||
try {
|
||||
const payload = await postInteraction(`/api/artworks/${artwork.id}/bookmark`, { state: nextState })
|
||||
setBookmarked(Boolean(payload?.is_bookmarked))
|
||||
setBookmarkCount(Number(payload?.stats?.bookmarks ?? 0))
|
||||
} catch {
|
||||
setBookmarked(!nextState)
|
||||
setBookmarkCount((current) => Math.max(0, current + (nextState ? -1 : 1)))
|
||||
}
|
||||
}
|
||||
|
||||
const openReport = () => {
|
||||
if (reported) return
|
||||
setReportOpen(true)
|
||||
@@ -274,6 +309,7 @@ export default function ArtworkActionBar({ artwork, stats, canonicalUrl, onStats
|
||||
}
|
||||
|
||||
const favCount = formatCount(stats?.favorites ?? artwork?.stats?.favorites ?? 0)
|
||||
const savedCount = formatCount(bookmarkCount)
|
||||
const viewCount = formatCount(stats?.views ?? artwork?.stats?.views ?? 0)
|
||||
|
||||
return (
|
||||
@@ -296,6 +332,21 @@ export default function ArtworkActionBar({ artwork, stats, canonicalUrl, onStats
|
||||
<span className="tabular-nums">{favCount}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
aria-label={bookmarked ? 'Remove bookmark' : 'Save artwork'}
|
||||
onClick={onToggleBookmark}
|
||||
className={[
|
||||
'inline-flex items-center gap-2 rounded-full border px-5 py-2.5 text-sm font-medium transition-all duration-200',
|
||||
bookmarked
|
||||
? 'border-amber-400/35 bg-amber-400/14 text-amber-200 shadow-lg shadow-amber-500/10 hover:bg-amber-400/18'
|
||||
: 'border-white/[0.08] bg-white/[0.04] text-white/70 hover:border-white/[0.15] hover:bg-white/[0.07] hover:text-white',
|
||||
].join(' ')}
|
||||
>
|
||||
<BookmarkIcon filled={bookmarked} />
|
||||
<span className="tabular-nums">{savedCount}</span>
|
||||
</button>
|
||||
|
||||
{/* Views stat pill */}
|
||||
<div className="inline-flex items-center gap-2 rounded-full border border-white/[0.08] bg-white/[0.04] px-5 py-2.5 text-sm font-medium text-white/70">
|
||||
<CloudDownIcon />
|
||||
@@ -353,6 +404,21 @@ export default function ArtworkActionBar({ artwork, stats, canonicalUrl, onStats
|
||||
<span className="tabular-nums">{favCount}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
aria-label={bookmarked ? 'Remove bookmark' : 'Save artwork'}
|
||||
onClick={onToggleBookmark}
|
||||
className={[
|
||||
'inline-flex items-center gap-1.5 rounded-full border px-3.5 py-2 text-xs font-medium transition-all',
|
||||
bookmarked
|
||||
? 'border-amber-400/35 bg-amber-400/14 text-amber-200'
|
||||
: 'border-white/[0.08] bg-white/[0.04] text-white/70',
|
||||
].join(' ')}
|
||||
>
|
||||
<BookmarkIcon filled={bookmarked} />
|
||||
<span className="tabular-nums">{savedCount}</span>
|
||||
</button>
|
||||
|
||||
{/* Share */}
|
||||
<ArtworkShareButton artwork={artwork} shareUrl={shareUrl} size="small" isLoggedIn={isLoggedIn} />
|
||||
|
||||
|
||||
@@ -1,67 +1,16 @@
|
||||
import React, { useState } from 'react'
|
||||
import NovaConfirmDialog from '../ui/NovaConfirmDialog'
|
||||
import FollowButton from '../social/FollowButton'
|
||||
|
||||
const AVATAR_FALLBACK = 'https://files.skinbase.org/default/missing_sq.webp'
|
||||
|
||||
export default function ArtworkAuthor({ artwork, presentSq }) {
|
||||
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 isOwnArtwork = Number(artwork?.viewer?.id || 0) > 0 && Number(artwork?.viewer?.id) === Number(user.id || 0)
|
||||
const authorName = user.name || user.username || 'Artist'
|
||||
const profileUrl = user.profile_url || (user.username ? `/@${user.username}` : '#')
|
||||
const avatar = user.avatar_url || presentSq?.url || AVATAR_FALLBACK
|
||||
const csrfToken = typeof document !== 'undefined'
|
||||
? document.querySelector('meta[name="csrf-token"]')?.getAttribute('content')
|
||||
: null
|
||||
|
||||
const persistFollowState = async (nextState) => {
|
||||
setFollowing(nextState)
|
||||
try {
|
||||
const response = await fetch(`/api/users/${user.id}/follow`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': csrfToken || '',
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({ state: nextState }),
|
||||
})
|
||||
|
||||
if (!response.ok) throw new Error('Follow failed')
|
||||
const payload = await response.json()
|
||||
if (typeof payload?.followers_count === 'number') {
|
||||
setFollowersCount(payload.followers_count)
|
||||
}
|
||||
setFollowing(Boolean(payload?.is_following))
|
||||
} catch {
|
||||
setFollowing(!nextState)
|
||||
}
|
||||
}
|
||||
|
||||
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 (
|
||||
<>
|
||||
@@ -89,7 +38,7 @@ export default function ArtworkAuthor({ artwork, presentSq }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid grid-cols-2 gap-3">
|
||||
<div className={`mt-4 grid gap-3 ${isOwnArtwork ? 'grid-cols-1' : 'grid-cols-2'}`}>
|
||||
<a
|
||||
href={profileUrl}
|
||||
className="inline-flex min-h-11 items-center justify-center gap-2 rounded-lg border border-nova-600 px-3 py-2 text-sm text-white hover:bg-nova-800 transition"
|
||||
@@ -99,40 +48,22 @@ export default function ArtworkAuthor({ artwork, presentSq }) {
|
||||
</svg>
|
||||
Profile
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
className={`inline-flex min-h-11 items-center justify-center gap-2 rounded-lg px-3 py-2 text-sm font-semibold transition ${following ? 'border border-nova-600 text-white hover:bg-nova-800' : 'bg-accent text-deep hover:brightness-110'}`}
|
||||
onClick={onToggleFollow}
|
||||
>
|
||||
{following ? (
|
||||
<>
|
||||
<svg className="h-4 w-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M18 7.5v3m0 0v3m0-3h3m-3 0h-3m-2.25-4.125a3.375 3.375 0 1 1-6.75 0 3.375 3.375 0 0 1 6.75 0ZM3 19.235v-.11a6.375 6.375 0 0 1 12.75 0v.109A12.318 12.318 0 0 1 9.374 21c-2.331 0-4.512-.645-6.374-1.766Z" />
|
||||
</svg>
|
||||
Following
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="h-4 w-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
|
||||
</svg>
|
||||
Follow
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
{!isOwnArtwork ? (
|
||||
<FollowButton
|
||||
username={user.username}
|
||||
initialFollowing={following}
|
||||
initialCount={followersCount}
|
||||
showCount={false}
|
||||
className="min-h-11"
|
||||
sizeClassName="px-3 py-2 text-sm"
|
||||
onChange={({ following: nextFollowing, followersCount: nextFollowersCount }) => {
|
||||
setFollowing(nextFollowing)
|
||||
setFollowersCount(nextFollowersCount)
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<NovaConfirmDialog
|
||||
open={confirmOpen}
|
||||
title="Unfollow creator?"
|
||||
message={`You will stop seeing updates from @${user.username || user.name || 'this creator'} in your following feed.`}
|
||||
confirmLabel="Unfollow"
|
||||
cancelLabel="Keep following"
|
||||
confirmTone="danger"
|
||||
onConfirm={onConfirmUnfollow}
|
||||
onClose={onCloseConfirm}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
import LevelBadge from '../xp/LevelBadge'
|
||||
|
||||
const IMAGE_FALLBACK = 'https://files.skinbase.org/default/missing_md.webp'
|
||||
const AVATAR_FALLBACK = 'https://files.skinbase.org/default/avatar_default.webp'
|
||||
@@ -17,6 +18,39 @@ function formatCount(value) {
|
||||
return numberFormatter.format(numeric)
|
||||
}
|
||||
|
||||
function formatRelativeTime(value) {
|
||||
if (!value) return ''
|
||||
|
||||
const date = value instanceof Date ? value : new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return ''
|
||||
|
||||
const now = new Date()
|
||||
const diffMs = date.getTime() - now.getTime()
|
||||
const diffSeconds = Math.round(diffMs / 1000)
|
||||
const absSeconds = Math.abs(diffSeconds)
|
||||
const rtf = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' })
|
||||
|
||||
if (absSeconds < 60) return rtf.format(diffSeconds, 'second')
|
||||
|
||||
const diffMinutes = Math.round(diffSeconds / 60)
|
||||
if (Math.abs(diffMinutes) < 60) return rtf.format(diffMinutes, 'minute')
|
||||
|
||||
const diffHours = Math.round(diffSeconds / 3600)
|
||||
if (Math.abs(diffHours) < 24) return rtf.format(diffHours, 'hour')
|
||||
|
||||
const diffDays = Math.round(diffSeconds / 86400)
|
||||
if (Math.abs(diffDays) < 7) return rtf.format(diffDays, 'day')
|
||||
|
||||
const diffWeeks = Math.round(diffSeconds / 604800)
|
||||
if (Math.abs(diffWeeks) < 5) return rtf.format(diffWeeks, 'week')
|
||||
|
||||
const diffMonths = Math.round(diffSeconds / 2629800)
|
||||
if (Math.abs(diffMonths) < 12) return rtf.format(diffMonths, 'month')
|
||||
|
||||
const diffYears = Math.round(diffSeconds / 31557600)
|
||||
return rtf.format(diffYears, 'year')
|
||||
}
|
||||
|
||||
function slugify(value) {
|
||||
return String(value ?? '')
|
||||
.toLowerCase()
|
||||
@@ -137,6 +171,20 @@ function ActionButton({ label, children, onClick }) {
|
||||
)
|
||||
}
|
||||
|
||||
function BadgePill({ className = '', iconClass = '', children }) {
|
||||
return (
|
||||
<span
|
||||
className={cx(
|
||||
'inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.14em] backdrop-blur-sm ring-1 shadow-[0_8px_24px_rgba(2,6,23,0.28)]',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{iconClass ? <i className={iconClass} aria-hidden="true" /> : null}
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ArtworkCard({
|
||||
artwork,
|
||||
variant = 'default',
|
||||
@@ -159,9 +207,10 @@ export default function ArtworkCard({
|
||||
fetchPriority,
|
||||
onLike,
|
||||
showActions = true,
|
||||
metricBadge = null,
|
||||
}) {
|
||||
const item = artwork || {}
|
||||
const rawAuthor = item.author
|
||||
const rawAuthor = item.author || item.creator
|
||||
const title = decodeHtml(item.title || item.name || 'Untitled artwork')
|
||||
const author = decodeHtml(
|
||||
(typeof rawAuthor === 'string' ? rawAuthor : rawAuthor?.name)
|
||||
@@ -170,6 +219,8 @@ export default function ArtworkCard({
|
||||
|| 'Skinbase Artist'
|
||||
)
|
||||
const username = rawAuthor?.username || item.author_username || item.username || null
|
||||
const authorLevel = Number(rawAuthor?.level ?? item.author_level ?? item.creator?.level ?? 0)
|
||||
const authorRank = rawAuthor?.rank || item.author_rank || item.creator?.rank || ''
|
||||
const image = item.image || item.thumb || item.thumb_url || item.thumbnail_url || IMAGE_FALLBACK
|
||||
const avatar = rawAuthor?.avatar_url || rawAuthor?.avatar || item.avatar || item.author_avatar || item.avatar_url || AVATAR_FALLBACK
|
||||
const likes = item.likes ?? item.favourites ?? 0
|
||||
@@ -194,6 +245,11 @@ export default function ArtworkCard({
|
||||
const titleClass = compact ? 'text-[0.96rem]' : 'text-[1rem] sm:text-[1.08rem]'
|
||||
const metadataLine = [contentType, category, resolution].filter(Boolean).join(' • ')
|
||||
const authorHref = username ? `/@${username}` : null
|
||||
const resolvedMetricBadge = metricBadge || item.metric_badge || null
|
||||
const relativePublishedAt = useMemo(
|
||||
() => formatRelativeTime(item.published_at || item.publishedAt || null),
|
||||
[item.published_at, item.publishedAt]
|
||||
)
|
||||
const initialLiked = Boolean(item.viewer?.is_liked)
|
||||
const [liked, setLiked] = useState(initialLiked)
|
||||
const [likeCount, setLikeCount] = useState(Number(likes ?? 0) || 0)
|
||||
@@ -294,7 +350,7 @@ export default function ArtworkCard({
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium text-white/90">{title}</p>
|
||||
{showAuthor && (
|
||||
<p className="mt-0.5 truncate text-xs text-slate-400">
|
||||
<div className="mt-0.5 flex items-center gap-2 text-xs text-slate-400">
|
||||
{authorHref ? (
|
||||
<span>
|
||||
by {author} <span className="text-slate-500">@{username}</span>
|
||||
@@ -302,7 +358,8 @@ export default function ArtworkCard({
|
||||
) : (
|
||||
<span>by {author}</span>
|
||||
)}
|
||||
</p>
|
||||
{authorLevel > 0 ? <LevelBadge level={authorLevel} rank={authorRank} compact /> : null}
|
||||
</div>
|
||||
)}
|
||||
<p className="mt-1 truncate text-[10px] uppercase tracking-wider text-slate-600">
|
||||
{contentType || 'Artwork'}
|
||||
@@ -349,8 +406,29 @@ export default function ArtworkCard({
|
||||
|
||||
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/85 via-black/38 to-transparent opacity-90 transition duration-300 md:opacity-45 md:group-hover:opacity-100 md:group-focus-within:opacity-100" />
|
||||
|
||||
{(resolvedMetricBadge?.label || relativePublishedAt) ? (
|
||||
<div className="pointer-events-none absolute inset-x-0 top-0 z-20 flex items-start justify-between gap-3 p-3">
|
||||
<div>
|
||||
{resolvedMetricBadge?.label ? (
|
||||
<BadgePill className={resolvedMetricBadge.className || 'bg-emerald-500/14 text-emerald-200 ring-emerald-400/30'} iconClass={resolvedMetricBadge.iconClass}>
|
||||
{resolvedMetricBadge.label}
|
||||
</BadgePill>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{relativePublishedAt ? (
|
||||
<BadgePill className="bg-black/45 text-white/75 ring-white/12" iconClass="fa-regular fa-clock text-[10px]">
|
||||
{relativePublishedAt}
|
||||
</BadgePill>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{showActions && (
|
||||
<div className="absolute right-3 top-3 z-20 flex translate-y-2 gap-2 opacity-100 transition duration-200 md:opacity-0 md:group-hover:translate-y-0 md:group-hover:opacity-100 md:group-focus-within:translate-y-0 md:group-focus-within:opacity-100">
|
||||
<div className={cx(
|
||||
'absolute right-3 z-20 flex translate-y-2 gap-2 opacity-100 transition duration-200 md:opacity-0 md:group-hover:translate-y-0 md:group-hover:opacity-100 md:group-focus-within:translate-y-0 md:group-focus-within:opacity-100',
|
||||
relativePublishedAt ? 'top-12' : 'top-3'
|
||||
)}>
|
||||
<ActionButton label={liked ? 'Unlike artwork' : 'Like artwork'} onClick={handleLike}>
|
||||
<HeartIcon className={cx('h-4 w-4 transition-transform duration-200', liked ? 'fill-current text-rose-300' : '', likeBusy ? 'scale-90' : '')} />
|
||||
</ActionButton>
|
||||
@@ -384,9 +462,12 @@ export default function ArtworkCard({
|
||||
}}
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-medium text-white/90">
|
||||
{author}
|
||||
{username && <span className="text-[11px] text-white/60"> @{username}</span>}
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="block min-w-0 truncate text-sm font-medium text-white/90">
|
||||
{author}
|
||||
{username && <span className="text-[11px] text-white/60"> @{username}</span>}
|
||||
</span>
|
||||
{authorLevel > 0 ? <LevelBadge level={authorLevel} rank={authorRank} compact className="shrink-0" /> : null}
|
||||
</span>
|
||||
{showStats && metadataLine && (
|
||||
<span className="mt-0.5 block truncate text-[11px] text-white/70">
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import axios from 'axios'
|
||||
import CommentForm from '../comments/CommentForm'
|
||||
import ReactionBar from '../comments/ReactionBar'
|
||||
import LevelBadge from '../xp/LevelBadge'
|
||||
import { isFlood } from '../../utils/emojiFlood'
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
@@ -130,6 +131,7 @@ function ReplyItem({ reply, parentId, artworkId, isLoggedIn, onReplyPosted, dept
|
||||
) : (
|
||||
<span className="text-[12px] font-semibold text-white/90">{profileLabel}</span>
|
||||
)}
|
||||
<LevelBadge level={user?.level} rank={user?.rank} compact className="shrink-0" />
|
||||
<span className="text-white/15" aria-hidden="true">·</span>
|
||||
<time
|
||||
dateTime={reply.created_at}
|
||||
@@ -286,6 +288,7 @@ function CommentItem({ comment, isLoggedIn, artworkId, onReplyPosted }) {
|
||||
) : (
|
||||
<span className="text-[13px] font-semibold text-white/95">{profileLabel}</span>
|
||||
)}
|
||||
<LevelBadge level={user?.level} rank={user?.rank} compact className="shrink-0" />
|
||||
<span className="text-white/15" aria-hidden="true">·</span>
|
||||
<time
|
||||
dateTime={comment.created_at}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useMemo, useState } from 'react'
|
||||
import NovaConfirmDialog from '../ui/NovaConfirmDialog'
|
||||
import FollowButton from '../social/FollowButton'
|
||||
|
||||
const AVATAR_FALLBACK = 'https://files.skinbase.org/default/missing_sq.webp'
|
||||
|
||||
@@ -24,16 +24,12 @@ 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 isOwnArtwork = Number(artwork?.viewer?.id || 0) > 0 && Number(artwork?.viewer?.id) === Number(user.id || 0)
|
||||
const authorName = user.name || user.username || 'Artist'
|
||||
const profileUrl = user.profile_url || (user.username ? `/@${user.username}` : '#')
|
||||
const avatar = user.avatar_url || presentSq?.url || AVATAR_FALLBACK
|
||||
const csrfToken = typeof document !== 'undefined'
|
||||
? document.querySelector('meta[name="csrf-token"]')?.getAttribute('content')
|
||||
: null
|
||||
|
||||
const creatorItems = useMemo(() => {
|
||||
const filtered = (Array.isArray(related) ? related : []).filter((item) => {
|
||||
@@ -46,53 +42,6 @@ export default function CreatorSpotlight({ artwork, presentSq, related = [] }) {
|
||||
return source.slice(0, 12).map(toCard)
|
||||
}, [related, authorName, artwork?.canonical_url])
|
||||
|
||||
const persistFollowState = async (nextState) => {
|
||||
setFollowing(nextState)
|
||||
try {
|
||||
const response = await fetch(`/api/users/${user.id}/follow`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': csrfToken || '',
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({ state: nextState }),
|
||||
})
|
||||
|
||||
if (!response.ok) throw new Error('Follow failed')
|
||||
const payload = await response.json()
|
||||
if (typeof payload?.followers_count === 'number') {
|
||||
setFollowersCount(payload.followers_count)
|
||||
}
|
||||
setFollowing(Boolean(payload?.is_following))
|
||||
} catch {
|
||||
setFollowing(!nextState)
|
||||
}
|
||||
}
|
||||
|
||||
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">
|
||||
@@ -131,22 +80,19 @@ export default function CreatorSpotlight({ artwork, presentSq, related = [] }) {
|
||||
</svg>
|
||||
Profile
|
||||
</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>
|
||||
{!isOwnArtwork ? (
|
||||
<FollowButton
|
||||
username={user.username}
|
||||
initialFollowing={following}
|
||||
initialCount={followersCount}
|
||||
showCount={false}
|
||||
className="flex-1"
|
||||
onChange={({ following: nextFollowing, followersCount: nextFollowersCount }) => {
|
||||
setFollowing(nextFollowing)
|
||||
setFollowersCount(nextFollowersCount)
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -189,16 +135,6 @@ export default function CreatorSpotlight({ artwork, presentSq, related = [] }) {
|
||||
)}
|
||||
</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