This commit is contained in:
2026-03-20 21:17:26 +01:00
parent 1a62fcb81d
commit 29c3ff8572
229 changed files with 13147 additions and 2577 deletions

View File

@@ -0,0 +1,71 @@
import React from 'react'
/**
* Compact artwork card for embedding inside a PostCard.
* Shows thumbnail, title and original author with attribution.
*/
export default function EmbeddedArtworkCard({ artwork }) {
if (!artwork) return null
const artUrl = `/art/${artwork.id}/${slugify(artwork.title)}`
const authorUrl = `/@${artwork.author.username}`
const handleCardClick = (e) => {
// Don't navigate when clicking the author link
if (e.defaultPrevented) return
window.location.href = artUrl
}
const handleKeyDown = (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
window.location.href = artUrl
}
}
return (
// Outer element is a div to avoid <a> inside <a> — navigation handled via onClick
<div
role="link"
tabIndex={0}
aria-label={artwork.title}
onClick={handleCardClick}
onKeyDown={handleKeyDown}
className="group flex gap-3 rounded-xl border border-white/[0.08] bg-black/30 p-3 hover:border-sky-500/30 transition-colors cursor-pointer"
>
{/* Thumbnail */}
<div className="w-20 h-16 rounded-lg overflow-hidden shrink-0 bg-white/5">
{artwork.thumb_url ? (
<img
src={artwork.thumb_url}
alt={artwork.title}
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
loading="lazy"
/>
) : (
<div className="w-full h-full flex items-center justify-center text-slate-600">
<i className="fa-solid fa-image" />
</div>
)}
</div>
{/* Meta */}
<div className="flex flex-col justify-center min-w-0">
<p className="text-sm font-medium text-white/90 truncate">{artwork.title}</p>
<a
href={authorUrl}
onClick={(e) => e.stopPropagation()}
className="text-xs text-slate-400 hover:text-sky-400 transition-colors mt-0.5 truncate"
>
<i className="fa-solid fa-user-circle fa-fw mr-1 opacity-60" />
by {artwork.author.name || `@${artwork.author.username}`}
</a>
<span className="text-[10px] text-slate-600 mt-1 uppercase tracking-wider">Artwork</span>
</div>
</div>
)
}
function slugify(str) {
return (str || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
}

View File

@@ -1,5 +1,6 @@
import React, { useState, useEffect, useRef } from 'react'
import axios from 'axios'
import LevelBadge from '../xp/LevelBadge'
function formatRelative(isoString) {
const diff = Date.now() - new Date(isoString).getTime()
@@ -133,6 +134,7 @@ export default function PostComments({ postId, isLoggedIn, isOwn = false, initia
>
{c.author.name || `@${c.author.username}`}
</a>
<LevelBadge level={c.author.level} rank={c.author.rank} compact />
<span className="text-[10px] text-slate-600">{formatRelative(c.created_at)}</span>
{c.is_highlighted && (
<span className="text-[10px] text-sky-400 font-medium flex items-center gap-1">

View File

@@ -0,0 +1,32 @@
import React from 'react'
const TYPE_TONES = {
Uploads: 'border-amber-400/40 bg-amber-500/10 text-amber-100',
Engagement: 'border-rose-400/40 bg-rose-500/10 text-rose-100',
Social: 'border-sky-400/40 bg-sky-500/10 text-sky-100',
Stories: 'border-emerald-400/40 bg-emerald-500/10 text-emerald-100',
Milestones: 'border-violet-400/40 bg-violet-500/10 text-violet-100',
}
function cx(...parts) {
return parts.filter(Boolean).join(' ')
}
export default function AchievementBadge({ achievement, compact = false, className = '' }) {
const tone = TYPE_TONES[achievement?.type] || TYPE_TONES.Milestones
const unlocked = Boolean(achievement?.unlocked)
return (
<span
className={cx(
'inline-flex items-center gap-2 rounded-full border px-2.5 py-1 font-semibold tracking-[0.08em]',
compact ? 'text-[10px] uppercase' : 'text-[11px] uppercase',
unlocked ? tone : 'border-white/10 bg-white/5 text-slate-300',
className,
)}
>
<i className={`fa-solid ${achievement?.icon || 'fa-trophy'} text-[0.9em]`} />
<span>{achievement?.type || 'Achievement'}</span>
</span>
)
}

View File

@@ -0,0 +1,54 @@
import React from 'react'
import AchievementBadge from './AchievementBadge'
function cx(...parts) {
return parts.filter(Boolean).join(' ')
}
export default function AchievementCard({ achievement, className = '' }) {
const unlocked = Boolean(achievement?.unlocked)
const progress = Number(achievement?.progress || 0)
const target = Number(achievement?.condition_value || 0)
const percent = Math.max(0, Math.min(100, Number(achievement?.progress_percent || 0)))
return (
<article
className={cx(
'rounded-2xl border p-4 shadow-lg transition',
unlocked
? 'border-emerald-400/25 bg-[linear-gradient(180deg,rgba(16,185,129,0.08),rgba(15,23,42,0.72))]'
: 'border-white/10 bg-white/[0.04]',
className,
)}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<AchievementBadge achievement={achievement} compact />
<h3 className="mt-3 text-base font-semibold text-white">{achievement?.name}</h3>
<p className="mt-1 text-sm text-slate-300">{achievement?.description}</p>
</div>
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-2xl border border-white/10 bg-white/5 text-lg text-white/90">
<i className={`fa-solid ${achievement?.icon || 'fa-trophy'}`} />
</div>
</div>
<div className="mt-4 flex items-center justify-between gap-3 text-xs text-slate-400">
<span>{achievement?.xp_reward || 0} XP reward</span>
{unlocked ? <span>Unlocked</span> : <span>{progress} / {target}</span>}
</div>
<div className="mt-2 h-2 overflow-hidden rounded-full bg-white/10 ring-1 ring-white/10">
<div
className={cx('h-full rounded-full transition-[width] duration-500', unlocked ? 'bg-emerald-400' : 'bg-sky-400')}
style={{ width: `${unlocked ? 100 : percent}%` }}
/>
</div>
{achievement?.unlocked_at ? (
<p className="mt-3 text-xs text-slate-500">
Unlocked {new Date(achievement.unlocked_at).toLocaleDateString()}
</p>
) : null}
</article>
)
}

View File

@@ -0,0 +1,44 @@
import React from 'react'
import AchievementCard from './AchievementCard'
export default function AchievementsList({ unlocked = [], locked = [], limitLocked }) {
const visibleLocked = typeof limitLocked === 'number' ? locked.slice(0, limitLocked) : locked
return (
<div className="space-y-8">
<section>
<div className="mb-4 flex items-center justify-between gap-3">
<h2 className="text-sm font-semibold uppercase tracking-[0.2em] text-slate-400">Unlocked</h2>
<span className="text-xs text-slate-500">{unlocked.length} earned</span>
</div>
{unlocked.length === 0 ? (
<div className="rounded-2xl border border-white/10 bg-white/[0.03] px-5 py-8 text-sm text-slate-400">
No achievements unlocked yet.
</div>
) : (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
{unlocked.map((achievement) => (
<AchievementCard key={achievement.id} achievement={achievement} />
))}
</div>
)}
</section>
<section>
<div className="mb-4 flex items-center justify-between gap-3">
<h2 className="text-sm font-semibold uppercase tracking-[0.2em] text-slate-400">In Progress</h2>
<span className="text-xs text-slate-500">{locked.length} still locked</span>
</div>
{visibleLocked.length === 0 ? null : (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
{visibleLocked.map((achievement) => (
<AchievementCard key={achievement.id} achievement={achievement} />
))}
</div>
)}
</section>
</div>
)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,24 +1,46 @@
import React from 'react'
export default function ActivityArtworkPreview({ artwork }) {
if (!artwork?.url || !artwork?.thumb) return null
export default function ActivityArtworkPreview({ artwork, story }) {
if (artwork?.url && artwork?.thumb) {
return (
<a
href={artwork.url}
className="group block w-full shrink-0 overflow-hidden rounded-2xl border border-white/[0.08] bg-white/[0.03] sm:w-[120px]"
>
<div className="aspect-[6/5] overflow-hidden bg-black/20">
<img
src={artwork.thumb}
alt={artwork.title || 'Artwork'}
className="h-full w-full object-cover transition duration-300 group-hover:scale-[1.04]"
loading="lazy"
decoding="async"
/>
</div>
<div className="border-t border-white/[0.06] px-3 py-2">
<p className="truncate text-[11px] font-medium text-white/65">{artwork.title || 'Artwork'}</p>
</div>
</a>
)
}
if (!story?.url || !story?.cover_url) return null
return (
<a
href={artwork.url}
href={story.url}
className="group block w-full shrink-0 overflow-hidden rounded-2xl border border-white/[0.08] bg-white/[0.03] sm:w-[120px]"
>
<div className="aspect-[6/5] overflow-hidden bg-black/20">
<img
src={artwork.thumb}
alt={artwork.title || 'Artwork'}
src={story.cover_url}
alt={story.title || 'Story'}
className="h-full w-full object-cover transition duration-300 group-hover:scale-[1.04]"
loading="lazy"
decoding="async"
/>
</div>
<div className="border-t border-white/[0.06] px-3 py-2">
<p className="truncate text-[11px] font-medium text-white/65">{artwork.title || 'Artwork'}</p>
<p className="truncate text-[11px] font-medium text-white/65">{story.title || 'Story'}</p>
</div>
</a>
)

View File

@@ -6,11 +6,65 @@ import ActivityReactions from './ActivityReactions'
function ActivityHeadline({ activity }) {
const artworkLink = activity?.artwork?.url
const artworkTitle = activity?.artwork?.title || 'an artwork'
const storyLink = activity?.story?.url
const storyTitle = activity?.story?.title || 'a story'
const mentionedUser = activity?.mentioned_user
const reaction = activity?.reaction
const commentAuthor = activity?.comment?.author
const targetUser = activity?.target_user
switch (activity?.type) {
case 'upload':
if (storyLink) {
return (
<p className="text-sm leading-6 text-white/70">
<span className="font-medium text-white">published </span>
{storyLink ? <a href={storyLink} className="text-sky-300 hover:text-sky-200">{storyTitle}</a> : <span className="text-white">{storyTitle}</span>}
</p>
)
}
return (
<p className="text-sm leading-6 text-white/70">
<span className="font-medium text-white">published </span>
{artworkLink ? <a href={artworkLink} className="text-sky-300 hover:text-sky-200">{artworkTitle}</a> : <span className="text-white">{artworkTitle}</span>}
</p>
)
case 'favorite':
return (
<p className="text-sm leading-6 text-white/70">
<span className="font-medium text-white">favorited </span>
{artworkLink ? <a href={artworkLink} className="text-sky-300 hover:text-sky-200">{artworkTitle}</a> : <span className="text-white">{artworkTitle}</span>}
</p>
)
case 'follow':
return (
<p className="text-sm leading-6 text-white/70">
<span className="font-medium text-white">followed </span>
{targetUser?.profile_url ? <a href={targetUser.profile_url} className="text-sky-300 hover:text-sky-200">@{targetUser.username || targetUser.name}</a> : <span className="text-white">another creator</span>}
</p>
)
case 'award':
return (
<p className="text-sm leading-6 text-white/70">
<span className="font-medium text-white">awarded </span>
{artworkLink ? <a href={artworkLink} className="text-sky-300 hover:text-sky-200">{artworkTitle}</a> : <span className="text-white">{artworkTitle}</span>}
</p>
)
case 'story_like':
return (
<p className="text-sm leading-6 text-white/70">
<span className="font-medium text-white">liked </span>
{storyLink ? <a href={storyLink} className="text-sky-300 hover:text-sky-200">{storyTitle}</a> : <span className="text-white">{storyTitle}</span>}
</p>
)
case 'story_comment':
return (
<p className="text-sm leading-6 text-white/70">
<span className="font-medium text-white">commented on </span>
{storyLink ? <a href={storyLink} className="text-sky-300 hover:text-sky-200">{storyTitle}</a> : <span className="text-white">{storyTitle}</span>}
</p>
)
case 'comment':
return (
<p className="text-sm leading-6 text-white/70">
@@ -80,7 +134,7 @@ export default function ActivityCard({ activity, isLoggedIn = false }) {
</div>
<div className="sm:ml-auto">
<ActivityArtworkPreview artwork={activity.artwork} />
<ActivityArtworkPreview artwork={activity.artwork} story={activity.story} />
</div>
</div>
</article>

View File

@@ -3,8 +3,13 @@ import ReactionBar from '../comments/ReactionBar'
export default function ActivityReactions({ activity, isLoggedIn = false }) {
const commentId = activity?.comment?.id || null
const commentUrl = activity?.comment?.url || activity?.artwork?.url || '#'
const commentUrl = activity?.comment?.url || null
const artworkUrl = activity?.artwork?.url || null
const storyUrl = activity?.story?.url || null
if (!commentId && !artworkUrl && !storyUrl) {
return null
}
return (
<div className="flex flex-wrap items-center gap-3 pt-2">
@@ -17,13 +22,15 @@ export default function ActivityReactions({ activity, isLoggedIn = false }) {
/>
) : null}
<a
href={commentUrl}
className="inline-flex items-center gap-1.5 rounded-full border border-white/[0.08] bg-white/[0.03] px-3 py-1.5 text-xs font-medium text-white/55 transition hover:border-sky-400/30 hover:bg-sky-500/10 hover:text-sky-200"
>
<i className="fa-regular fa-comment-dots" />
Reply
</a>
{commentUrl ? (
<a
href={commentUrl}
className="inline-flex items-center gap-1.5 rounded-full border border-white/[0.08] bg-white/[0.03] px-3 py-1.5 text-xs font-medium text-white/55 transition hover:border-sky-400/30 hover:bg-sky-500/10 hover:text-sky-200"
>
<i className="fa-regular fa-comment-dots" />
Reply
</a>
) : null}
{artworkUrl ? (
<a
@@ -34,6 +41,16 @@ export default function ActivityReactions({ activity, isLoggedIn = false }) {
View artwork
</a>
) : null}
{storyUrl ? (
<a
href={storyUrl}
className="inline-flex items-center gap-1.5 rounded-full border border-white/[0.08] bg-white/[0.03] px-3 py-1.5 text-xs font-medium text-white/55 transition hover:border-white/15 hover:bg-white/[0.07] hover:text-white"
>
<i className="fa-regular fa-newspaper" />
Read story
</a>
) : null}
</div>
)
}

View File

@@ -2,12 +2,18 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { EditorContent, Extension, useEditor } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight';
import Image from '@tiptap/extension-image';
import Link from '@tiptap/extension-link';
import Placeholder from '@tiptap/extension-placeholder';
import Underline from '@tiptap/extension-underline';
import Suggestion from '@tiptap/suggestion';
import { Node, mergeAttributes } from '@tiptap/core';
import { common, createLowlight } from 'lowlight';
import tippy from 'tippy.js';
import { buildBotFingerprint } from '../../lib/security/botFingerprint';
import TurnstileField from '../security/TurnstileField';
import Select from '../ui/Select';
type StoryType = {
slug: string;
@@ -62,6 +68,28 @@ type Props = {
csrfToken: string;
};
const EMPTY_DOC = {
type: 'doc',
content: [{ type: 'paragraph' }],
};
const lowlight = createLowlight(common);
const CODE_BLOCK_LANGUAGES = [
{ value: 'bash', label: 'Bash / Shell' },
{ value: 'plaintext', label: 'Plain text' },
{ value: 'php', label: 'PHP' },
{ value: 'javascript', label: 'JavaScript' },
{ value: 'typescript', label: 'TypeScript' },
{ value: 'json', label: 'JSON' },
{ value: 'html', label: 'HTML' },
{ value: 'css', label: 'CSS' },
{ value: 'sql', label: 'SQL' },
{ value: 'xml', label: 'XML / SVG' },
{ value: 'yaml', label: 'YAML' },
{ value: 'markdown', label: 'Markdown' },
];
const ArtworkBlock = Node.create({
name: 'artworkEmbed',
group: 'block',
@@ -230,11 +258,14 @@ const DownloadAssetBlock = Node.create({
function createSlashCommandExtension(insert: {
image: () => void;
uploadImage: () => void;
artwork: () => void;
code: () => void;
quote: () => void;
divider: () => void;
gallery: () => void;
video: () => void;
download: () => void;
}) {
return Extension.create({
name: 'slashCommands',
@@ -246,22 +277,28 @@ function createSlashCommandExtension(insert: {
startOfLine: true,
items: ({ query }: { query: string }) => {
const all = [
{ title: 'Upload Image', key: 'uploadImage' },
{ title: 'Image', key: 'image' },
{ title: 'Artwork', key: 'artwork' },
{ title: 'Code', key: 'code' },
{ title: 'Quote', key: 'quote' },
{ title: 'Divider', key: 'divider' },
{ title: 'Gallery', key: 'gallery' },
{ title: 'Video', key: 'video' },
{ title: 'Download', key: 'download' },
];
return all.filter((item) => item.key.startsWith(query.toLowerCase()));
},
command: ({ props }: { editor: any; props: { key: string } }) => {
if (props.key === 'uploadImage') insert.uploadImage();
if (props.key === 'image') insert.image();
if (props.key === 'artwork') insert.artwork();
if (props.key === 'code') insert.code();
if (props.key === 'quote') insert.quote();
if (props.key === 'divider') insert.divider();
if (props.key === 'gallery') insert.gallery();
if (props.key === 'video') insert.video();
if (props.key === 'download') insert.download();
},
render: () => {
let popup: any;
@@ -359,22 +396,37 @@ function createSlashCommandExtension(insert: {
});
}
async function requestJson<T>(url: string, method: string, body: unknown, csrfToken: string): Promise<T> {
async function botHeaders(extra: Record<string, string> = {}, captcha: { token?: string } = {}) {
const fingerprint = await buildBotFingerprint();
return {
...extra,
'X-Bot-Fingerprint': fingerprint,
...(captcha?.token ? { 'X-Captcha-Token': captcha.token } : {}),
};
}
async function requestJson<T>(url: string, method: string, body: unknown, csrfToken: string, captcha: { token?: string } = {}): Promise<T> {
const response = await fetch(url, {
method,
headers: {
headers: await botHeaders({
'Content-Type': 'application/json',
'X-CSRF-TOKEN': csrfToken,
'X-Requested-With': 'XMLHttpRequest',
},
}, captcha),
body: JSON.stringify(body),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(`Request failed: ${response.status}`);
const error = new Error((payload as any)?.message || `Request failed: ${response.status}`) as Error & { status?: number; payload?: unknown };
error.status = response.status;
error.payload = payload;
throw error;
}
return response.json() as Promise<T>;
return payload as T;
}
export default function StoryEditor({ mode, initialStory, storyTypes, endpoints, csrfToken }: Props) {
@@ -398,7 +450,26 @@ export default function StoryEditor({ mode, initialStory, storyTypes, endpoints,
const [showLivePreview, setShowLivePreview] = useState(false);
const [livePreviewHtml, setLivePreviewHtml] = useState('');
const [inlineToolbar, setInlineToolbar] = useState({ visible: false, top: 0, left: 0 });
const [fieldErrors, setFieldErrors] = useState<Record<string, string[]>>({});
const [generalError, setGeneralError] = useState('');
const [wordCount, setWordCount] = useState(0);
const [readMinutes, setReadMinutes] = useState(1);
const [codeBlockLanguage, setCodeBlockLanguage] = useState('bash');
const [isSubmitting, setIsSubmitting] = useState(false);
const [captchaState, setCaptchaState] = useState({
required: false,
token: '',
message: '',
nonce: 0,
provider: 'turnstile',
siteKey: '',
inputName: 'cf-turnstile-response',
scriptUrl: '',
});
const lastSavedRef = useRef('');
const editorRef = useRef<any>(null);
const bodyImageInputRef = useRef<HTMLInputElement | null>(null);
const coverImageInputRef = useRef<HTMLInputElement | null>(null);
const emitSaveEvent = useCallback((kind: 'autosave' | 'manual', id?: number) => {
window.dispatchEvent(new CustomEvent('story-editor:saved', {
@@ -410,6 +481,58 @@ export default function StoryEditor({ mode, initialStory, storyTypes, endpoints,
}));
}, []);
const resetCaptchaState = useCallback(() => {
setCaptchaState((prev) => ({
...prev,
required: false,
token: '',
message: '',
nonce: prev.nonce + 1,
}));
}, []);
const captureCaptchaRequirement = useCallback((payload: any = {}) => {
const requiresCaptcha = !!(payload?.requires_captcha || payload?.requiresCaptcha);
if (!requiresCaptcha) {
return false;
}
const nextCaptcha = payload?.captcha || {};
const message = payload?.errors?.captcha?.[0] || payload?.message || 'Complete the captcha challenge to continue.';
setCaptchaState((prev) => ({
required: true,
token: '',
message,
nonce: prev.nonce + 1,
provider: nextCaptcha.provider || payload?.captcha_provider || prev.provider || 'turnstile',
siteKey: nextCaptcha.siteKey || payload?.captcha_site_key || prev.siteKey || '',
inputName: nextCaptcha.inputName || payload?.captcha_input || prev.inputName || 'cf-turnstile-response',
scriptUrl: nextCaptcha.scriptUrl || payload?.captcha_script_url || prev.scriptUrl || '',
}));
return true;
}, []);
const applyFailure = useCallback((error: any, fallback: string) => {
const payload = error?.payload || {};
const nextErrors = payload?.errors && typeof payload.errors === 'object' ? payload.errors : {};
setFieldErrors(nextErrors);
const requiresCaptcha = captureCaptchaRequirement(payload);
const message = nextErrors?.captcha?.[0]
|| nextErrors?.title?.[0]
|| nextErrors?.content?.[0]
|| payload?.message
|| fallback;
setGeneralError(message);
setSaveStatus(requiresCaptcha ? 'Captcha required' : message);
}, [captureCaptchaRequirement]);
const clearFeedback = useCallback(() => {
setGeneralError('');
setFieldErrors({});
}, []);
const openLinkPrompt = useCallback((editor: any) => {
const prev = editor.getAttributes('link').href;
const url = window.prompt('Link URL', prev || 'https://');
@@ -439,66 +562,111 @@ export default function StoryEditor({ mode, initialStory, storyTypes, endpoints,
const response = await fetch(endpoints.uploadImage, {
method: 'POST',
headers: {
headers: await botHeaders({
'X-CSRF-TOKEN': csrfToken,
'X-Requested-With': 'XMLHttpRequest',
},
}, captchaState),
body: formData,
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
setFieldErrors(payload?.errors && typeof payload.errors === 'object' ? payload.errors : {});
captureCaptchaRequirement(payload);
setGeneralError(payload?.errors?.captcha?.[0] || payload?.message || 'Image upload failed');
return null;
}
const data = await response.json();
clearFeedback();
if (captchaState.required && captchaState.token) {
resetCaptchaState();
}
const data = payload;
return data.medium_url || data.original_url || data.thumbnail_url || null;
}, [endpoints.uploadImage, csrfToken]);
}, [captchaState, captureCaptchaRequirement, clearFeedback, endpoints.uploadImage, csrfToken, resetCaptchaState]);
const applyCodeBlockLanguage = useCallback((language: string) => {
const nextLanguage = (language || 'plaintext').trim() || 'plaintext';
setCodeBlockLanguage(nextLanguage);
const currentEditor = editorRef.current;
if (!currentEditor || !currentEditor.isActive('codeBlock')) {
return;
}
currentEditor.chain().focus().updateAttributes('codeBlock', { language: nextLanguage }).run();
}, []);
const toggleCodeBlockWithLanguage = useCallback(() => {
const currentEditor = editorRef.current;
if (!currentEditor) return;
if (currentEditor.isActive('codeBlock')) {
currentEditor.chain().focus().toggleCodeBlock().run();
return;
}
currentEditor.chain().focus().setCodeBlock({ language: codeBlockLanguage }).run();
}, [codeBlockLanguage]);
const insertActions = useMemo(() => ({
image: () => {
const currentEditor = editorRef.current;
const url = window.prompt('Image URL', 'https://');
if (!url || !editor) return;
editor.chain().focus().setImage({ src: url }).run();
if (!url || !currentEditor) return;
currentEditor.chain().focus().setImage({ src: url }).run();
},
uploadImage: () => bodyImageInputRef.current?.click(),
artwork: () => setArtworkModalOpen(true),
code: () => {
if (!editor) return;
editor.chain().focus().toggleCodeBlock().run();
toggleCodeBlockWithLanguage();
},
quote: () => {
if (!editor) return;
editor.chain().focus().toggleBlockquote().run();
const currentEditor = editorRef.current;
if (!currentEditor) return;
currentEditor.chain().focus().toggleBlockquote().run();
},
divider: () => {
if (!editor) return;
editor.chain().focus().setHorizontalRule().run();
const currentEditor = editorRef.current;
if (!currentEditor) return;
currentEditor.chain().focus().setHorizontalRule().run();
},
gallery: () => {
if (!editor) return;
const currentEditor = editorRef.current;
if (!currentEditor) return;
const raw = window.prompt('Gallery image URLs (comma separated)', '');
const images = (raw || '').split(',').map((value) => value.trim()).filter(Boolean);
editor.chain().focus().insertContent({ type: 'galleryBlock', attrs: { images } }).run();
currentEditor.chain().focus().insertContent({ type: 'galleryBlock', attrs: { images } }).run();
},
video: () => {
if (!editor) return;
const currentEditor = editorRef.current;
if (!currentEditor) return;
const src = window.prompt('Video embed URL (YouTube/Vimeo)', 'https://www.youtube.com/embed/');
if (!src) return;
editor.chain().focus().insertContent({ type: 'videoEmbed', attrs: { src, title: 'Embedded video' } }).run();
currentEditor.chain().focus().insertContent({ type: 'videoEmbed', attrs: { src, title: 'Embedded video' } }).run();
},
download: () => {
if (!editor) return;
const currentEditor = editorRef.current;
if (!currentEditor) return;
const url = window.prompt('Download URL', 'https://');
if (!url) return;
const label = window.prompt('Button label', 'Download asset') || 'Download asset';
editor.chain().focus().insertContent({ type: 'downloadAsset', attrs: { url, label } }).run();
currentEditor.chain().focus().insertContent({ type: 'downloadAsset', attrs: { url, label } }).run();
},
}), []);
}), [toggleCodeBlockWithLanguage]);
const editor = useEditor({
extensions: [
StarterKit.configure({
codeBlock: false,
heading: { levels: [1, 2, 3] },
}),
CodeBlockLowlight.configure({
lowlight,
}),
Underline,
Image,
Link.configure({
openOnClick: false,
@@ -517,10 +685,10 @@ export default function StoryEditor({ mode, initialStory, storyTypes, endpoints,
DownloadAssetBlock,
createSlashCommandExtension(insertActions),
],
content: initialStory.content || { type: 'doc', content: [{ type: 'paragraph' }] },
content: initialStory.content || EMPTY_DOC,
editorProps: {
attributes: {
class: 'tiptap prose prose-invert max-w-none min-h-[26rem] rounded-xl border border-gray-700 bg-gray-900/80 px-6 py-5 text-gray-200 focus:outline-none',
class: 'tiptap prose prose-invert prose-headings:tracking-tight prose-p:text-[1.04rem] prose-p:leading-8 prose-p:text-stone-200 prose-strong:text-white prose-a:text-sky-300 prose-blockquote:border-l-sky-400 prose-blockquote:text-stone-300 prose-code:text-sky-200 max-w-none min-h-[32rem] bg-transparent px-0 py-0 text-stone-200 focus:outline-none',
},
handleDrop: (_view, event) => {
const file = event.dataTransfer?.files?.[0];
@@ -559,11 +727,17 @@ export default function StoryEditor({ mode, initialStory, storyTypes, endpoints,
},
});
editorRef.current = editor;
useEffect(() => {
if (!editor) return;
const updatePreview = () => {
setLivePreviewHtml(editor.getHTML());
const text = editor.getText().replace(/\s+/g, ' ').trim();
const words = text === '' ? 0 : text.split(' ').length;
setWordCount(words);
setReadMinutes(Math.max(1, Math.ceil(words / 200)));
};
updatePreview();
@@ -574,6 +748,30 @@ export default function StoryEditor({ mode, initialStory, storyTypes, endpoints,
};
}, [editor]);
useEffect(() => {
if (!editor) return;
const syncCodeBlockLanguage = () => {
if (!editor.isActive('codeBlock')) {
return;
}
const nextLanguage = String(editor.getAttributes('codeBlock').language || '').trim();
if (nextLanguage !== '') {
setCodeBlockLanguage(nextLanguage);
}
};
syncCodeBlockLanguage();
editor.on('selectionUpdate', syncCodeBlockLanguage);
editor.on('update', syncCodeBlockLanguage);
return () => {
editor.off('selectionUpdate', syncCodeBlockLanguage);
editor.off('update', syncCodeBlockLanguage);
};
}, [editor]);
useEffect(() => {
if (!artworkModalOpen) return;
void fetchArtworks(artworkQuery);
@@ -620,13 +818,17 @@ export default function StoryEditor({ mode, initialStory, storyTypes, endpoints,
og_image: ogImage || coverImage,
status,
scheduled_for: scheduledFor || null,
content: editor?.getJSON() || { type: 'doc', content: [{ type: 'paragraph' }] },
content: editor?.getJSON() || EMPTY_DOC,
}), [storyId, title, excerpt, coverImage, storyType, tagsCsv, metaTitle, metaDescription, canonicalUrl, ogImage, status, scheduledFor, editor]);
useEffect(() => {
if (!editor) return;
const timer = window.setInterval(async () => {
if (isSubmitting) {
return;
}
const body = payload();
const snapshot = JSON.stringify(body);
if (snapshot === lastSavedRef.current) {
@@ -634,21 +836,37 @@ export default function StoryEditor({ mode, initialStory, storyTypes, endpoints,
}
try {
clearFeedback();
setSaveStatus('Saving...');
const data = await requestJson<{ story_id?: number; message?: string }>(endpoints.autosave, 'POST', body, csrfToken);
const data = await requestJson<{ story_id?: number; message?: string; edit_url?: string }>(
endpoints.autosave,
'POST',
captchaState.required && captchaState.inputName ? {
...body,
[captchaState.inputName]: captchaState.token || '',
} : body,
csrfToken,
captchaState,
);
if (data.story_id && !storyId) {
setStoryId(data.story_id);
}
if (data.edit_url && window.location.pathname.endsWith('/create')) {
window.history.replaceState({}, '', data.edit_url);
}
lastSavedRef.current = snapshot;
setSaveStatus(data.message || 'Saved just now');
if (captchaState.required && captchaState.token) {
resetCaptchaState();
}
emitSaveEvent('autosave', data.story_id || storyId);
} catch {
setSaveStatus('Autosave failed');
} catch (error) {
applyFailure(error, 'Autosave failed');
}
}, 10000);
return () => window.clearInterval(timer);
}, [editor, payload, endpoints.autosave, csrfToken, storyId, emitSaveEvent]);
}, [applyFailure, captchaState, clearFeedback, csrfToken, editor, emitSaveEvent, endpoints.autosave, isSubmitting, payload, resetCaptchaState, storyId]);
const persistStory = async (submitAction: 'save_draft' | 'submit_review' | 'publish_now' | 'schedule_publish') => {
const body = {
@@ -659,20 +877,81 @@ export default function StoryEditor({ mode, initialStory, storyTypes, endpoints,
};
try {
clearFeedback();
setIsSubmitting(true);
setSaveStatus('Saving...');
const endpoint = storyId ? endpoints.update : endpoints.create;
const method = storyId ? 'PUT' : 'POST';
const data = await requestJson<{ story_id: number; message?: string }>(endpoint, method, body, csrfToken);
const data = await requestJson<{ story_id: number; message?: string; status?: string; edit_url?: string; public_url?: string }>(endpoint, method, captchaState.required && captchaState.inputName ? {
...body,
[captchaState.inputName]: captchaState.token || '',
} : body, csrfToken, captchaState);
if (data.story_id) {
setStoryId(data.story_id);
}
if (data.edit_url && window.location.pathname.endsWith('/create')) {
window.history.replaceState({}, '', data.edit_url);
}
lastSavedRef.current = JSON.stringify(payload());
setSaveStatus(data.message || 'Saved just now');
if (captchaState.required && captchaState.token) {
resetCaptchaState();
}
emitSaveEvent('manual', data.story_id || storyId);
} catch {
setSaveStatus('Save failed');
if (submitAction === 'publish_now' && data.public_url) {
window.location.assign(data.public_url);
return;
}
} catch (error) {
applyFailure(error, submitAction === 'publish_now' ? 'Publish failed' : 'Save failed');
} finally {
setIsSubmitting(false);
}
};
const handleBodyImagePicked = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
event.target.value = '';
if (!file) return;
setSaveStatus('Uploading image...');
const uploaded = await uploadImageFile(file);
if (!uploaded || !editor) {
setSaveStatus('Image upload failed');
return;
}
editor.chain().focus().setImage({ src: uploaded }).run();
setSaveStatus('Image uploaded');
};
const handleCoverImagePicked = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
event.target.value = '';
if (!file) return;
setSaveStatus('Uploading cover...');
const uploaded = await uploadImageFile(file);
if (!uploaded) {
setSaveStatus('Cover upload failed');
return;
}
setCoverImage(uploaded);
setSaveStatus('Cover uploaded');
};
const readinessChecks = useMemo(() => ([
{ label: 'Title', ok: title.trim().length > 0, hint: 'Give the story a clear headline.' },
{ label: 'Body', ok: wordCount >= 50, hint: 'Aim for at least 50 words before publishing.' },
{ label: 'Story type', ok: storyType.trim().length > 0, hint: 'Choose the format that fits the post.' },
]), [storyType, title, wordCount]);
const titleError = fieldErrors?.title?.[0] || '';
const contentError = fieldErrors?.content?.[0] || '';
const excerptError = fieldErrors?.excerpt?.[0] || '';
const tagsError = fieldErrors?.tags_csv?.[0] || '';
const insertArtwork = (item: Artwork) => {
if (!editor) return;
editor.chain().focus().insertContent({
@@ -689,86 +968,260 @@ export default function StoryEditor({ mode, initialStory, storyTypes, endpoints,
return (
<div className="space-y-6">
<div className="rounded-xl border border-gray-700 bg-gray-800/60 p-4 shadow-lg">
<div className="space-y-4">
<input
value={title}
onChange={(event) => setTitle(event.target.value)}
placeholder="Title"
className="w-full rounded-xl border border-gray-700 bg-gray-900 px-4 py-3 text-2xl font-semibold text-gray-100"
/>
<div className="sticky top-16 z-30 overflow-hidden rounded-[1.5rem] border border-white/10 bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.18),_transparent_28%),linear-gradient(135deg,rgba(12,18,28,0.96),rgba(10,14,22,0.92))] p-4 shadow-[0_20px_70px_rgba(3,7,18,0.26)] backdrop-blur-xl">
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-2 text-xs uppercase tracking-[0.24em] text-white/45">
<span className="rounded-full border border-white/10 bg-white/[0.06] px-2.5 py-1 text-[11px] font-semibold text-white/70">{mode === 'create' ? 'New story' : 'Editing draft'}</span>
<span>{wordCount.toLocaleString()} words</span>
<span>{readMinutes} min read</span>
<span>{saveStatus}</span>
</div>
<p className="max-w-2xl text-sm text-white/62">Write in the main column, keep the sidebar for story settings, and only surface captcha when protection actually asks for it.</p>
</div>
<div className="grid gap-3 md:grid-cols-2">
<input value={excerpt} onChange={(event) => setExcerpt(event.target.value)} placeholder="Excerpt" className="rounded-xl border border-gray-700 bg-gray-900 px-3 py-2 text-sm text-gray-200" />
<select value={storyType} onChange={(event) => setStoryType(event.target.value)} className="rounded-xl border border-gray-700 bg-gray-900 px-3 py-2 text-sm text-gray-200">
{storyTypes.map((type) => (
<option key={type.slug} value={type.slug}>{type.name}</option>
))}
</select>
<input value={tagsCsv} onChange={(event) => setTagsCsv(event.target.value)} placeholder="Tags (comma separated)" className="rounded-xl border border-gray-700 bg-gray-900 px-3 py-2 text-sm text-gray-200" />
<select value={status} onChange={(event) => setStatus(event.target.value)} className="rounded-xl border border-gray-700 bg-gray-900 px-3 py-2 text-sm text-gray-200">
<option value="draft">Draft</option>
<option value="pending_review">Pending Review</option>
<option value="published">Published</option>
<option value="scheduled">Scheduled</option>
<option value="archived">Archived</option>
</select>
<input value={coverImage} onChange={(event) => setCoverImage(event.target.value)} placeholder="Cover image URL" className="rounded-xl border border-gray-700 bg-gray-900 px-3 py-2 text-sm text-gray-200" />
<input type="datetime-local" value={scheduledFor} onChange={(event) => setScheduledFor(event.target.value)} className="rounded-xl border border-gray-700 bg-gray-900 px-3 py-2 text-sm text-gray-200" />
<input value={metaTitle} onChange={(event) => setMetaTitle(event.target.value)} placeholder="Meta title" className="rounded-xl border border-gray-700 bg-gray-900 px-3 py-2 text-sm text-gray-200" />
<input value={metaDescription} onChange={(event) => setMetaDescription(event.target.value)} placeholder="Meta description" className="rounded-xl border border-gray-700 bg-gray-900 px-3 py-2 text-sm text-gray-200" />
<input value={canonicalUrl} onChange={(event) => setCanonicalUrl(event.target.value)} placeholder="Canonical URL" className="rounded-xl border border-gray-700 bg-gray-900 px-3 py-2 text-sm text-gray-200" />
<input value={ogImage} onChange={(event) => setOgImage(event.target.value)} placeholder="OG image URL" className="rounded-xl border border-gray-700 bg-gray-900 px-3 py-2 text-sm text-gray-200" />
<div className="flex flex-wrap items-center gap-2">
<button type="button" onClick={() => setShowInsertMenu((current) => !current)} className="rounded-xl border border-white/10 bg-white/[0.05] px-3 py-2 text-sm text-white/78 transition hover:bg-white/[0.09]">Insert block</button>
<button type="button" onClick={() => setShowLivePreview((current) => !current)} className="rounded-xl border border-white/10 bg-white/[0.05] px-3 py-2 text-sm text-white/78 transition hover:bg-white/[0.09]">{showLivePreview ? 'Hide preview' : 'Preview'}</button>
<button type="button" onClick={() => persistStory('save_draft')} disabled={isSubmitting} className="rounded-xl border border-white/10 bg-white/[0.05] px-3 py-2 text-sm text-white transition hover:bg-white/[0.09] disabled:opacity-60">Save draft</button>
<button type="button" onClick={() => persistStory('submit_review')} disabled={isSubmitting} className="rounded-xl border border-amber-400/30 bg-amber-400/12 px-3 py-2 text-sm text-amber-100 transition hover:bg-amber-400/20 disabled:opacity-60">Submit review</button>
<button type="button" onClick={() => persistStory('publish_now')} disabled={isSubmitting} className="rounded-xl border border-emerald-400/30 bg-emerald-400/14 px-3 py-2 text-sm font-medium text-emerald-100 transition hover:bg-emerald-400/22 disabled:opacity-60">Publish now</button>
</div>
</div>
</div>
<div className="relative rounded-xl border border-gray-700 bg-gray-800/60 p-4 shadow-lg">
<div className="mb-3 flex flex-wrap items-center gap-2">
<button type="button" onClick={() => setShowInsertMenu((current) => !current)} className="rounded-lg border border-gray-600 px-3 py-1 text-xs text-gray-200">+ Insert</button>
<button type="button" onClick={() => setShowLivePreview((current) => !current)} className="rounded-lg border border-gray-600 px-3 py-1 text-xs text-gray-200">{showLivePreview ? 'Hide Preview' : 'Live Preview'}</button>
<button type="button" onClick={() => persistStory('save_draft')} className="rounded-lg border border-gray-600 bg-gray-700/40 px-3 py-1 text-xs text-gray-200">Save Draft</button>
<button type="button" onClick={() => persistStory('submit_review')} className="rounded-lg border border-amber-500/40 bg-amber-500/10 px-3 py-1 text-xs text-amber-200">Submit for Review</button>
<button type="button" onClick={() => persistStory('publish_now')} className="rounded-lg border border-emerald-500/40 bg-emerald-500/10 px-3 py-1 text-xs text-emerald-200">Publish Now</button>
<button type="button" onClick={() => persistStory('schedule_publish')} className="rounded-lg border border-sky-500/40 bg-sky-500/10 px-3 py-1 text-xs text-sky-200">Schedule Publish</button>
<span className="ml-auto text-xs text-emerald-300">{saveStatus}</span>
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_22rem]">
<div className="space-y-6">
<section className="overflow-hidden rounded-[2rem] border border-white/10 bg-[radial-gradient(circle_at_top_left,_rgba(251,191,36,0.12),_transparent_26%),linear-gradient(180deg,rgba(16,22,33,0.96),rgba(9,12,19,0.92))] shadow-[0_22px_80px_rgba(4,8,20,0.24)]">
{coverImage ? (
<div className="relative h-56 overflow-hidden border-b border-white/10">
<img src={coverImage} alt="Story cover" className="h-full w-full object-cover" />
<div className="absolute inset-0 bg-gradient-to-t from-slate-950 via-slate-950/25 to-transparent" />
<div className="absolute bottom-4 left-4 rounded-full border border-white/15 bg-black/35 px-3 py-1 text-xs uppercase tracking-[0.24em] text-white/75">Cover preview</div>
</div>
) : null}
<div className="space-y-5 p-6 md:p-8">
<div className="flex flex-wrap items-center gap-3 text-xs uppercase tracking-[0.22em] text-white/42">
<span>{storyTypes.find((type) => type.slug === storyType)?.name || 'Story'}</span>
<span>{status.replace(/_/g, ' ')}</span>
{scheduledFor ? <span>Scheduled {scheduledFor}</span> : null}
</div>
<div>
<input
value={title}
onChange={(event) => setTitle(event.target.value)}
placeholder="Give the story a title worth opening"
className="w-full border-0 bg-transparent px-0 text-4xl font-semibold tracking-tight text-white placeholder:text-white/25 focus:outline-none md:text-5xl"
/>
{titleError ? <p className="mt-2 text-sm text-rose-300">{titleError}</p> : null}
</div>
<div>
<textarea
value={excerpt}
onChange={(event) => setExcerpt(event.target.value)}
placeholder="Write a short dek that explains why this story matters."
rows={3}
className="w-full resize-none border-0 bg-transparent px-0 text-base leading-7 text-white/70 placeholder:text-white/25 focus:outline-none"
/>
{excerptError ? <p className="mt-2 text-sm text-rose-300">{excerptError}</p> : null}
</div>
<div className="grid gap-3 sm:grid-cols-3">
<div className="rounded-2xl border border-white/10 bg-white/[0.04] p-4">
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-white/38">Words</p>
<p className="mt-2 text-2xl font-semibold text-white">{wordCount.toLocaleString()}</p>
</div>
<div className="rounded-2xl border border-white/10 bg-white/[0.04] p-4">
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-white/38">Reading time</p>
<p className="mt-2 text-2xl font-semibold text-white">{readMinutes} min</p>
</div>
<div className="rounded-2xl border border-white/10 bg-white/[0.04] p-4">
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-white/38">Status</p>
<p className="mt-2 text-2xl font-semibold capitalize text-white">{status.replace(/_/g, ' ')}</p>
</div>
</div>
</div>
</section>
<section className="relative overflow-hidden rounded-[2rem] border border-white/10 bg-[linear-gradient(180deg,rgba(16,19,28,0.98),rgba(8,10,17,0.96))] shadow-[0_24px_90px_rgba(4,8,20,0.28)]">
<div className="border-b border-white/10 px-5 py-4">
<div className="flex flex-wrap items-center gap-2">
{editor ? (
<>
<button type="button" className={`rounded-xl px-3 py-2 text-sm ${editor.isActive('bold') ? 'bg-sky-400/20 text-sky-100' : 'bg-white/[0.05] text-white/75'}`} onClick={() => editor.chain().focus().toggleBold().run()}>Bold</button>
<button type="button" className={`rounded-xl px-3 py-2 text-sm ${editor.isActive('italic') ? 'bg-sky-400/20 text-sky-100' : 'bg-white/[0.05] text-white/75'}`} onClick={() => editor.chain().focus().toggleItalic().run()}>Italic</button>
<button type="button" className={`rounded-xl px-3 py-2 text-sm ${editor.isActive('underline') ? 'bg-sky-400/20 text-sky-100' : 'bg-white/[0.05] text-white/75'}`} onClick={() => editor.chain().focus().toggleUnderline().run()}>Underline</button>
<button type="button" className={`rounded-xl px-3 py-2 text-sm ${editor.isActive('heading', { level: 2 }) ? 'bg-sky-400/20 text-sky-100' : 'bg-white/[0.05] text-white/75'}`} onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}>H2</button>
<button type="button" className={`rounded-xl px-3 py-2 text-sm ${editor.isActive('heading', { level: 3 }) ? 'bg-sky-400/20 text-sky-100' : 'bg-white/[0.05] text-white/75'}`} onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}>H3</button>
<button type="button" className={`rounded-xl px-3 py-2 text-sm ${editor.isActive('bulletList') ? 'bg-sky-400/20 text-sky-100' : 'bg-white/[0.05] text-white/75'}`} onClick={() => editor.chain().focus().toggleBulletList().run()}>Bullets</button>
<button type="button" className={`rounded-xl px-3 py-2 text-sm ${editor.isActive('orderedList') ? 'bg-sky-400/20 text-sky-100' : 'bg-white/[0.05] text-white/75'}`} onClick={() => editor.chain().focus().toggleOrderedList().run()}>Numbers</button>
<button type="button" className={`rounded-xl px-3 py-2 text-sm ${editor.isActive('blockquote') ? 'bg-sky-400/20 text-sky-100' : 'bg-white/[0.05] text-white/75'}`} onClick={() => editor.chain().focus().toggleBlockquote().run()}>Quote</button>
<button type="button" className={`rounded-xl px-3 py-2 text-sm ${editor.isActive('codeBlock') ? 'bg-sky-400/20 text-sky-100' : 'bg-white/[0.05] text-white/75'}`} onClick={toggleCodeBlockWithLanguage}>Code block</button>
<div className="inline-flex items-center gap-2 rounded-xl bg-white/[0.05] px-3 py-2 text-sm text-white/75">
<span className="text-white/50">Lang</span>
<div className="min-w-[10rem]">
<Select
value={codeBlockLanguage}
onChange={(event) => applyCodeBlockLanguage(event.target.value)}
options={CODE_BLOCK_LANGUAGES}
size="sm"
className="border-white/10 bg-slate-950/90 py-1 text-sm text-white hover:border-white/20"
/>
</div>
</div>
<button type="button" className="rounded-xl bg-white/[0.05] px-3 py-2 text-sm text-white/75" onClick={() => openLinkPrompt(editor)}>Link</button>
</>
) : null}
</div>
</div>
{showInsertMenu && (
<div className="border-b border-white/10 bg-white/[0.03] px-5 py-4">
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 xl:grid-cols-4">
<button type="button" className="rounded-xl border border-white/10 bg-white/[0.05] px-3 py-2 text-sm text-white/80" onClick={insertActions.uploadImage}>Upload image</button>
<button type="button" className="rounded-xl border border-white/10 bg-white/[0.05] px-3 py-2 text-sm text-white/80" onClick={insertActions.image}>Image URL</button>
<button type="button" className="rounded-xl border border-white/10 bg-white/[0.05] px-3 py-2 text-sm text-white/80" onClick={insertActions.artwork}>Embed artwork</button>
<button type="button" className="rounded-xl border border-white/10 bg-white/[0.05] px-3 py-2 text-sm text-white/80" onClick={insertActions.gallery}>Gallery</button>
<button type="button" className="rounded-xl border border-white/10 bg-white/[0.05] px-3 py-2 text-sm text-white/80" onClick={insertActions.video}>Video</button>
<button type="button" className="rounded-xl border border-white/10 bg-white/[0.05] px-3 py-2 text-sm text-white/80" onClick={insertActions.download}>Download</button>
<button type="button" className="rounded-xl border border-white/10 bg-white/[0.05] px-3 py-2 text-sm text-white/80" onClick={insertActions.quote}>Quote</button>
<button type="button" className="rounded-xl border border-white/10 bg-white/[0.05] px-3 py-2 text-sm text-white/80" onClick={insertActions.code}>Code block</button>
<div className="col-span-2 rounded-xl border border-white/10 bg-white/[0.05] px-3 py-2 sm:col-span-3 xl:col-span-2">
<div className="mb-2 text-sm text-white/45">Language</div>
<Select
value={codeBlockLanguage}
onChange={(event) => applyCodeBlockLanguage(event.target.value)}
options={CODE_BLOCK_LANGUAGES}
size="sm"
className="border-white/10 bg-slate-950/90 text-white hover:border-white/20"
/>
</div>
</div>
</div>
)}
{editor && inlineToolbar.visible && (
<div
className="fixed z-40 flex items-center gap-1 rounded-2xl border border-white/10 bg-slate-950/95 px-2 py-1 shadow-lg backdrop-blur"
style={{ top: `${inlineToolbar.top}px`, left: `${inlineToolbar.left}px` }}
>
<button type="button" className={`rounded px-2 py-1 text-xs ${editor.isActive('bold') ? 'bg-sky-500/30 text-sky-100' : 'text-gray-200'}`} onMouseDown={(event) => event.preventDefault()} onClick={() => editor.chain().focus().toggleBold().run()}>B</button>
<button type="button" className={`rounded px-2 py-1 text-xs ${editor.isActive('italic') ? 'bg-sky-500/30 text-sky-100' : 'text-gray-200'}`} onMouseDown={(event) => event.preventDefault()} onClick={() => editor.chain().focus().toggleItalic().run()}>I</button>
<button type="button" className={`rounded px-2 py-1 text-xs ${editor.isActive('underline') ? 'bg-sky-500/30 text-sky-100' : 'text-gray-200'}`} onMouseDown={(event) => event.preventDefault()} onClick={() => editor.chain().focus().toggleUnderline().run()}>U</button>
<button type="button" className={`rounded px-2 py-1 text-xs ${editor.isActive('code') ? 'bg-sky-500/30 text-sky-100' : 'text-gray-200'}`} onMouseDown={(event) => event.preventDefault()} onClick={() => editor.chain().focus().toggleCode().run()}>{'</>'}</button>
<button type="button" className="rounded px-2 py-1 text-xs text-gray-200" onMouseDown={(event) => event.preventDefault()} onClick={() => openLinkPrompt(editor)}>Link</button>
</div>
)}
<div className="px-6 py-8 md:px-10 md:py-10">
<EditorContent editor={editor} />
{contentError ? <p className="mt-4 text-sm text-rose-300">{contentError}</p> : null}
</div>
{showLivePreview && (
<div className="border-t border-white/10 bg-white/[0.02] px-6 py-6 md:px-10">
<div className="mb-3 text-xs font-semibold uppercase tracking-[0.22em] text-white/40">Live preview</div>
<div className="prose prose-invert max-w-none prose-pre:bg-slate-950 prose-p:text-stone-200" dangerouslySetInnerHTML={{ __html: livePreviewHtml }} />
</div>
)}
</section>
</div>
{showInsertMenu && (
<div className="mb-3 grid grid-cols-2 gap-2 rounded-xl border border-gray-700 bg-gray-900/90 p-2 sm:grid-cols-3">
<button type="button" className="rounded-md border border-gray-700 px-2 py-1 text-xs text-gray-200" onClick={insertActions.image}>Image</button>
<button type="button" className="rounded-md border border-gray-700 px-2 py-1 text-xs text-gray-200" onClick={insertActions.artwork}>Embed Artwork</button>
<button type="button" className="rounded-md border border-gray-700 px-2 py-1 text-xs text-gray-200" onClick={insertActions.code}>Code Block</button>
<button type="button" className="rounded-md border border-gray-700 px-2 py-1 text-xs text-gray-200" onClick={insertActions.quote}>Quote</button>
<button type="button" className="rounded-md border border-gray-700 px-2 py-1 text-xs text-gray-200" onClick={insertActions.divider}>Divider</button>
<button type="button" className="rounded-md border border-gray-700 px-2 py-1 text-xs text-gray-200" onClick={insertActions.gallery}>Gallery</button>
<button type="button" className="rounded-md border border-gray-700 px-2 py-1 text-xs text-gray-200" onClick={insertActions.video}>Video Embed</button>
<button type="button" className="rounded-md border border-gray-700 px-2 py-1 text-xs text-gray-200" onClick={insertActions.download}>Download Asset</button>
</div>
)}
<aside className="space-y-4 xl:sticky xl:top-24 self-start">
{(generalError || captchaState.required) && (
<section className="rounded-[1.5rem] border border-amber-400/20 bg-amber-500/10 p-5">
<p className="text-xs font-semibold uppercase tracking-[0.22em] text-amber-100/70">Action needed</p>
<p className="mt-3 text-sm text-amber-50">{generalError || captchaState.message || 'Complete the captcha challenge to continue.'}</p>
{captchaState.required && captchaState.siteKey ? (
<div className="mt-4 rounded-2xl border border-white/10 bg-black/20 p-3">
<TurnstileField
key={`story-editor-captcha-${captchaState.nonce}`}
provider={captchaState.provider}
siteKey={captchaState.siteKey}
scriptUrl={captchaState.scriptUrl}
onToken={(token) => setCaptchaState((prev) => ({ ...prev, token }))}
className="min-h-16"
/>
</div>
) : null}
</section>
)}
{editor && inlineToolbar.visible && (
<div
className="fixed z-40 flex items-center gap-1 rounded-lg border border-gray-700 bg-gray-900 px-2 py-1 shadow-lg"
style={{ top: `${inlineToolbar.top}px`, left: `${inlineToolbar.left}px` }}
>
<button type="button" className={`rounded px-2 py-1 text-xs ${editor.isActive('bold') ? 'bg-sky-500/30 text-sky-100' : 'text-gray-200'}`} onMouseDown={(event) => event.preventDefault()} onClick={() => editor.chain().focus().toggleBold().run()}>B</button>
<button type="button" className={`rounded px-2 py-1 text-xs ${editor.isActive('italic') ? 'bg-sky-500/30 text-sky-100' : 'text-gray-200'}`} onMouseDown={(event) => event.preventDefault()} onClick={() => editor.chain().focus().toggleItalic().run()}>I</button>
<button type="button" className={`rounded px-2 py-1 text-xs ${editor.isActive('code') ? 'bg-sky-500/30 text-sky-100' : 'text-gray-200'}`} onMouseDown={(event) => event.preventDefault()} onClick={() => editor.chain().focus().toggleCode().run()}>{'</>'}</button>
<button type="button" className="rounded px-2 py-1 text-xs text-gray-200" onMouseDown={(event) => event.preventDefault()} onClick={() => openLinkPrompt(editor)}>Link</button>
<button type="button" className="rounded px-2 py-1 text-xs text-gray-200" onMouseDown={(event) => event.preventDefault()} onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}>H2</button>
<button type="button" className="rounded px-2 py-1 text-xs text-gray-200" onMouseDown={(event) => event.preventDefault()} onClick={() => editor.chain().focus().toggleBlockquote().run()}>Quote</button>
</div>
)}
<section className="rounded-[1.5rem] border border-white/10 bg-white/[0.03] p-5">
<p className="text-xs font-semibold uppercase tracking-[0.22em] text-white/38">Publish checklist</p>
<div className="mt-4 space-y-3">
{readinessChecks.map((item) => (
<div key={item.label} className="rounded-2xl border border-white/8 bg-black/10 px-4 py-3">
<div className="flex items-center justify-between gap-3">
<span className="text-sm font-medium text-white">{item.label}</span>
<span className={`text-xs font-semibold uppercase tracking-[0.18em] ${item.ok ? 'text-emerald-300' : 'text-amber-200'}`}>{item.ok ? 'Ready' : 'Needs work'}</span>
</div>
<p className="mt-2 text-sm text-white/48">{item.hint}</p>
</div>
))}
</div>
</section>
<EditorContent editor={editor} />
<section className="rounded-[1.5rem] border border-white/10 bg-white/[0.03] p-5">
<p className="text-xs font-semibold uppercase tracking-[0.22em] text-white/38">Story settings</p>
<div className="mt-4 space-y-4">
<div>
<label className="mb-2 block text-sm font-medium text-white/80">Story type</label>
<select value={storyType} onChange={(event) => setStoryType(event.target.value)} className="w-full rounded-xl border border-white/10 bg-slate-950/80 px-3 py-2.5 text-sm text-white">
{storyTypes.map((type) => (
<option key={type.slug} value={type.slug}>{type.name}</option>
))}
</select>
</div>
{showLivePreview && (
<div className="mt-4 rounded-xl border border-gray-700 bg-gray-900/60 p-4">
<div className="mb-2 text-xs font-semibold uppercase tracking-wide text-gray-400">Live Preview</div>
<div className="prose prose-invert max-w-none prose-pre:bg-gray-900" dangerouslySetInnerHTML={{ __html: livePreviewHtml }} />
</div>
)}
<div>
<label className="mb-2 block text-sm font-medium text-white/80">Tags</label>
<input value={tagsCsv} onChange={(event) => setTagsCsv(event.target.value)} placeholder="art direction, process, workflow" className="w-full rounded-xl border border-white/10 bg-slate-950/80 px-3 py-2.5 text-sm text-white placeholder:text-white/25" />
{tagsError ? <p className="mt-2 text-sm text-rose-300">{tagsError}</p> : <p className="mt-2 text-xs text-white/40">Comma-separated. New tags are created automatically.</p>}
</div>
<div>
<label className="mb-2 block text-sm font-medium text-white/80">Workflow status</label>
<select value={status} onChange={(event) => setStatus(event.target.value)} className="w-full rounded-xl border border-white/10 bg-slate-950/80 px-3 py-2.5 text-sm text-white">
<option value="draft">Draft</option>
<option value="pending_review">Pending Review</option>
<option value="published">Published</option>
<option value="scheduled">Scheduled</option>
<option value="archived">Archived</option>
</select>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-white/80">Schedule publish</label>
<input type="datetime-local" value={scheduledFor} onChange={(event) => setScheduledFor(event.target.value)} className="w-full rounded-xl border border-white/10 bg-slate-950/80 px-3 py-2.5 text-sm text-white" />
</div>
</div>
</section>
<section className="rounded-[1.5rem] border border-white/10 bg-white/[0.03] p-5">
<div className="flex items-center justify-between gap-3">
<p className="text-xs font-semibold uppercase tracking-[0.22em] text-white/38">Cover</p>
<button type="button" onClick={() => coverImageInputRef.current?.click()} className="rounded-xl border border-white/10 bg-white/[0.05] px-3 py-2 text-xs text-white/78">Upload</button>
</div>
<div className="mt-4 space-y-3">
<input value={coverImage} onChange={(event) => setCoverImage(event.target.value)} placeholder="https://..." className="w-full rounded-xl border border-white/10 bg-slate-950/80 px-3 py-2.5 text-sm text-white placeholder:text-white/25" />
{coverImage ? <img src={coverImage} alt="Cover preview" className="h-40 w-full rounded-2xl object-cover" /> : <div className="rounded-2xl border border-dashed border-white/10 px-4 py-8 text-center text-sm text-white/38">Add a cover image to give the story more presence in feeds.</div>}
</div>
</section>
<section className="rounded-[1.5rem] border border-white/10 bg-white/[0.03] p-5">
<p className="text-xs font-semibold uppercase tracking-[0.22em] text-white/38">SEO & social</p>
<div className="mt-4 space-y-3">
<input value={metaTitle} onChange={(event) => setMetaTitle(event.target.value)} placeholder="Meta title" className="w-full rounded-xl border border-white/10 bg-slate-950/80 px-3 py-2.5 text-sm text-white placeholder:text-white/25" />
<textarea value={metaDescription} onChange={(event) => setMetaDescription(event.target.value)} rows={3} placeholder="Meta description" className="w-full resize-none rounded-xl border border-white/10 bg-slate-950/80 px-3 py-2.5 text-sm text-white placeholder:text-white/25" />
<input value={canonicalUrl} onChange={(event) => setCanonicalUrl(event.target.value)} placeholder="Canonical URL" className="w-full rounded-xl border border-white/10 bg-slate-950/80 px-3 py-2.5 text-sm text-white placeholder:text-white/25" />
<input value={ogImage} onChange={(event) => setOgImage(event.target.value)} placeholder="OG image URL" className="w-full rounded-xl border border-white/10 bg-slate-950/80 px-3 py-2.5 text-sm text-white placeholder:text-white/25" />
</div>
</section>
</aside>
</div>
<div className="flex flex-wrap gap-3">
@@ -791,6 +1244,9 @@ export default function StoryEditor({ mode, initialStory, storyTypes, endpoints,
)}
</div>
<input ref={bodyImageInputRef} type="file" accept="image/*" className="hidden" onChange={handleBodyImagePicked} />
<input ref={coverImageInputRef} type="file" accept="image/*" className="hidden" onChange={handleCoverImagePicked} />
{artworkModalOpen && (
<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-xl border border-gray-700 bg-gray-900 p-4 shadow-lg">

View File

@@ -0,0 +1,69 @@
import React from 'react'
import LevelBadge from '../xp/LevelBadge'
const PODIUM_STYLES = {
1: 'border-yellow-300/40 bg-[linear-gradient(180deg,rgba(250,204,21,0.18),rgba(15,23,42,0.84))]',
2: 'border-slate-300/30 bg-[linear-gradient(180deg,rgba(226,232,240,0.16),rgba(15,23,42,0.84))]',
3: 'border-amber-700/40 bg-[linear-gradient(180deg,rgba(180,83,9,0.22),rgba(15,23,42,0.84))]',
}
function cx(...parts) {
return parts.filter(Boolean).join(' ')
}
function formatScore(score) {
return new Intl.NumberFormat().format(Math.round(Number(score || 0)))
}
export default function LeaderboardItem({ item, type, highlight = false }) {
const entity = item?.entity || {}
const rank = Number(item?.rank || 0)
const tone = highlight ? PODIUM_STYLES[rank] || PODIUM_STYLES[3] : 'border-white/10 bg-white/[0.03]'
const image = entity.avatar || entity.image || null
return (
<article className={cx('rounded-3xl border p-4 shadow-lg transition', tone)}>
<div className="flex items-start gap-4">
<div className={cx('flex shrink-0 items-center justify-center rounded-2xl border font-black', highlight ? 'h-14 w-14 text-xl' : 'h-11 w-11 text-base', 'border-white/10 bg-slate-950/70 text-white')}>
#{rank}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<a href={entity.url || '#'} className="block text-lg font-semibold text-white hover:text-sky-300 transition">
{entity.name || 'Unknown'}
</a>
{entity.creator_name ? (
<a href={entity.creator_url || '#'} className="mt-1 block text-sm text-slate-400 hover:text-sky-300 transition">
by {entity.creator_name}
</a>
) : null}
{entity.username ? <p className="mt-1 text-sm text-slate-500">@{entity.username}</p> : null}
</div>
<div className="text-right">
<p className="text-[11px] uppercase tracking-[0.24em] text-slate-500">Score</p>
<p className="mt-1 text-2xl font-black text-white">{formatScore(item?.score)}</p>
</div>
</div>
<div className="mt-4 flex flex-wrap items-center gap-3">
{type === 'creator' ? <LevelBadge level={entity.level} rank={entity.rank} compact /> : null}
{type !== 'creator' && entity.creator_name ? (
<span className="rounded-full border border-white/10 bg-white/5 px-3 py-1 text-[11px] uppercase tracking-[0.14em] text-slate-300">
{type}
</span>
) : null}
</div>
</div>
{image ? (
<a href={entity.url || '#'} className={cx('block shrink-0 overflow-hidden rounded-2xl border border-white/10 bg-slate-900', type === 'creator' ? 'h-16 w-16' : 'h-20 w-24')}>
<img src={image} alt={entity.name || 'Leaderboard item'} className="h-full w-full object-cover" loading="lazy" />
</a>
) : null}
</div>
</article>
)
}

View File

@@ -0,0 +1,44 @@
import React from 'react'
import LeaderboardItem from './LeaderboardItem'
export default function LeaderboardList({ items = [], type }) {
const podium = items.slice(0, 3)
const rest = items.slice(3)
return (
<div className="space-y-8">
{podium.length > 0 ? (
<section>
<div className="mb-4 flex items-center justify-between gap-3">
<h2 className="text-sm font-semibold uppercase tracking-[0.24em] text-slate-400">Top 3</h2>
<span className="text-xs text-slate-500">Podium leaders</span>
</div>
<div className="grid grid-cols-1 gap-4 xl:grid-cols-3">
{podium.map((item) => (
<LeaderboardItem key={`${type}-${item.rank}`} item={item} type={type} highlight />
))}
</div>
</section>
) : null}
<section>
<div className="mb-4 flex items-center justify-between gap-3">
<h2 className="text-sm font-semibold uppercase tracking-[0.24em] text-slate-400">Leaderboard</h2>
<span className="text-xs text-slate-500">{items.length} ranked entries</span>
</div>
{items.length === 0 ? (
<div className="rounded-3xl border border-white/10 bg-white/[0.03] px-6 py-10 text-sm text-slate-400">
No leaderboard entries available yet for this period.
</div>
) : (
<div className="space-y-4">
{rest.map((item) => (
<LeaderboardItem key={`${type}-${item.rank}`} item={item} type={type} />
))}
</div>
)}
</section>
</div>
)
}

View File

@@ -0,0 +1,35 @@
import React from 'react'
function cx(...parts) {
return parts.filter(Boolean).join(' ')
}
export default function LeaderboardTabs({ items, active, onChange, sticky = false, label }) {
return (
<div className={cx(sticky ? 'sticky top-16 z-20' : '', 'rounded-2xl border border-white/10 bg-slate-950/85 p-2 backdrop-blur') }>
<div className="flex flex-wrap items-center gap-2" role="tablist" aria-label={label || 'Leaderboard tabs'}>
{items.map((item) => {
const isActive = item.value === active
return (
<button
key={item.value}
type="button"
role="tab"
aria-selected={isActive}
onClick={() => onChange(item.value)}
className={cx(
'rounded-full px-4 py-2 text-sm font-semibold transition',
isActive
? 'bg-sky-400 text-slate-950 shadow-[0_12px_30px_rgba(56,189,248,0.28)]'
: 'bg-white/5 text-slate-300 hover:bg-white/10 hover:text-white',
)}
>
{item.label}
</button>
)
})}
</div>
</div>
)
}

View File

@@ -0,0 +1,109 @@
import React, { useState } from 'react'
import MasonryGallery from '../gallery/MasonryGallery'
const SORT_OPTIONS = [
{ value: 'latest', label: 'Latest' },
{ value: 'trending', label: 'Trending' },
{ value: 'rising', label: 'Rising' },
{ value: 'views', label: 'Most Viewed' },
{ value: 'favs', label: 'Most Favourited' },
]
function slugify(str) {
return (str || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
}
function FeaturedStrip({ featuredArtworks }) {
if (!featuredArtworks?.length) return null
return (
<div className="mb-7 rounded-2xl border border-white/10 bg-white/[0.02] p-4 md:p-5">
<h2 className="mb-3 flex items-center gap-2 text-xs font-semibold uppercase tracking-widest text-slate-400">
<i className="fa-solid fa-star fa-fw text-amber-400" />
Featured
</h2>
<div className="scrollbar-hide flex snap-x snap-mandatory gap-4 overflow-x-auto pb-2">
{featuredArtworks.slice(0, 5).map((art) => (
<a
key={art.id}
href={`/art/${art.id}/${slugify(art.name)}`}
className="group w-56 shrink-0 snap-start md:w-64"
>
<div className="aspect-[5/3] overflow-hidden rounded-xl bg-black/30 ring-1 ring-white/10 transition-all hover:ring-sky-400/40">
<img
src={art.thumb}
alt={art.name}
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-[1.03]"
loading="lazy"
/>
</div>
<p className="mt-2 truncate text-sm text-slate-300 transition-colors group-hover:text-white">
{art.name}
</p>
{art.label ? <p className="truncate text-[11px] text-slate-600">{art.label}</p> : null}
</a>
))}
</div>
</div>
)
}
export default function ProfileGalleryPanel({ artworks, featuredArtworks, username }) {
const [sort, setSort] = useState('latest')
const [items, setItems] = useState(artworks?.data ?? artworks ?? [])
const [nextCursor, setNextCursor] = useState(artworks?.next_cursor ?? null)
const handleSort = async (newSort) => {
setSort(newSort)
setItems([])
try {
const response = await fetch(`/api/profile/${encodeURIComponent(username)}/artworks?sort=${newSort}`, {
headers: { Accept: 'application/json' },
})
if (!response.ok) {
return
}
const data = await response.json()
setItems(data.data ?? data)
setNextCursor(data.next_cursor ?? null)
} catch (_) {}
}
return (
<>
<FeaturedStrip featuredArtworks={featuredArtworks} />
<div className="mb-5 flex flex-wrap items-center gap-3">
<span className="text-xs font-semibold uppercase tracking-wider text-slate-500">Sort</span>
<div className="flex flex-wrap gap-1">
{SORT_OPTIONS.map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => handleSort(opt.value)}
className={`rounded-lg px-3 py-1.5 text-xs font-medium transition-all ${
sort === opt.value
? 'bg-sky-500/20 text-sky-300 ring-1 ring-sky-400/40'
: 'text-slate-400 hover:bg-white/5 hover:text-white'
}`}
>
{opt.label}
</button>
))}
</div>
</div>
<MasonryGallery
key={`profile-${username}-${sort}`}
artworks={items}
galleryType="profile"
cursorEndpoint={`/api/profile/${encodeURIComponent(username)}/artworks?sort=${encodeURIComponent(sort)}`}
initialNextCursor={nextCursor}
limit={24}
/>
</>
)
}

View File

@@ -1,74 +1,23 @@
import React, { useState } from 'react'
import NovaConfirmDialog from '../ui/NovaConfirmDialog'
import ProfileCoverEditor from './ProfileCoverEditor'
import LevelBadge from '../xp/LevelBadge'
import XPProgressBar from '../xp/XPProgressBar'
import FollowButton from '../social/FollowButton'
/**
* ProfileHero
* Cover banner + avatar + identity block + action buttons
*/
export default function ProfileHero({ user, profile, isOwner, viewerIsFollowing, followerCount, heroBgUrl, countryName }) {
export default function ProfileHero({ user, profile, isOwner, viewerIsFollowing, followerCount, heroBgUrl, countryName, leaderboardRank, extraActions = null }) {
const [following, setFollowing] = useState(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 [confirmOpen, setConfirmOpen] = useState(false)
const [pendingFollowState, setPendingFollowState] = useState(null)
const uname = user.username || user.name || 'Unknown'
const displayName = user.name || uname
const joinDate = user.created_at
? new Date(user.created_at).toLocaleDateString('en-US', { month: 'long', year: 'numeric' })
: null
const bio = profile?.bio || profile?.about || ''
const persistFollowState = async (nextState) => {
if (loading) return
setLoading(true)
try {
const res = await fetch(`/@${uname.toLowerCase()}/follow`, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content ?? '',
'Accept': 'application/json',
},
})
if (res.ok) {
const data = await res.json()
setFollowing(data.following)
setCount(data.follower_count)
}
} catch (_) {}
setLoading(false)
}
const toggleFollow = 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 (
<>
<div className="max-w-6xl mx-auto px-4 pt-4">
@@ -82,7 +31,7 @@ export default function ProfileHero({ user, profile, isOwner, viewerIsFollowing,
position: 'relative',
}}
>
{isOwner && (
{isOwner ? (
<div className="absolute right-3 top-3 z-20">
<button
type="button"
@@ -94,7 +43,7 @@ export default function ProfileHero({ user, profile, isOwner, viewerIsFollowing,
Edit Cover
</button>
</div>
)}
) : null}
<div
className="absolute inset-0"
@@ -109,49 +58,58 @@ export default function ProfileHero({ user, profile, isOwner, viewerIsFollowing,
</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">
<div className="flex flex-col gap-4 md:flex-row md:items-end md:gap-5">
<div className="mx-auto z-10 shrink-0 md:mx-0">
<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)]"
className="h-[104px] w-[104px] rounded-full border-2 border-white/15 object-cover shadow-[0_0_0_6px_rgba(15,23,36,0.95),0_10px_32px_rgba(0,0,0,0.6)] md:h-[116px] md:w-[116px]"
/>
</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">
<div className="min-w-0 flex-1 text-center md:text-left">
<h1 className="text-[28px] font-bold leading-tight tracking-tight text-white md:text-[34px]">
{displayName}
</h1>
<p className="text-slate-400 text-sm mt-0.5 font-mono">@{uname}</p>
<p className="mt-0.5 font-mono text-sm text-slate-400">@{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 && (
<div className="mt-3 flex flex-wrap items-center justify-center gap-2 md:justify-start">
<LevelBadge level={user?.level} rank={user?.rank} />
{leaderboardRank?.rank ? (
<span className="inline-flex items-center gap-2 rounded-full border border-sky-400/25 bg-sky-400/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.12em] text-sky-100">
Rank #{leaderboardRank.rank} this week
</span>
) : null}
</div>
<div className="mt-2 flex flex-wrap items-center justify-center gap-2.5 text-xs text-slate-400 md:justify-start">
{countryName ? (
<span className="inline-flex items-center gap-1.5 rounded-full border border-white/10 bg-white/5 px-2.5 py-1">
{profile?.country_code ? (
<img
src={`/gfx/flags/shiny/24/${encodeURIComponent(profile.country_code)}.png`}
src={`/gfx/flags/shiny/24/${encodeURIComponent(String(profile.country_code).toUpperCase())}.png`}
alt={countryName}
className="w-4 h-auto rounded-sm"
onError={(e) => { e.target.style.display = 'none' }}
onError={(event) => { event.target.style.display = 'none' }}
/>
)}
) : null}
{countryName}
</span>
)}
) : null}
{joinDate && (
<span className="inline-flex items-center gap-1.5 rounded-full bg-white/5 border border-white/10 px-2.5 py-1">
{joinDate ? (
<span className="inline-flex items-center gap-1.5 rounded-full border border-white/10 bg-white/5 px-2.5 py-1">
<i className="fa-solid fa-calendar-days fa-fw opacity-70" />
Joined {joinDate}
</span>
)}
) : null}
{profile?.website && (
{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"
className="inline-flex items-center gap-1.5 rounded-full border border-white/10 bg-white/5 px-2.5 py-1 text-sky-300 transition-colors hover:bg-white/10 hover:text-sky-200"
>
<i className="fa-solid fa-link fa-fw" />
{(() => {
@@ -163,22 +121,32 @@ export default function ProfileHero({ user, profile, isOwner, viewerIsFollowing,
}
})()}
</a>
)}
) : null}
</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 className="mx-auto mt-3 max-w-2xl line-clamp-2 text-sm leading-relaxed text-slate-300/90 md:mx-0 md:line-clamp-3">
{bio}
</p>
)}
) : null}
<XPProgressBar
xp={user?.xp}
currentLevelXp={user?.current_level_xp}
nextLevelXp={user?.next_level_xp}
progressPercent={user?.progress_percent}
maxLevel={user?.max_level}
className="mt-4 max-w-xl"
/>
</div>
<div className="shrink-0 flex items-center justify-center md:justify-end gap-2 pb-0.5">
<div className="shrink-0 flex items-center justify-center gap-2 pb-0.5 md:justify-end">
{extraActions}
{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"
className="inline-flex items-center gap-2 rounded-xl border border-white/15 px-4 py-2.5 text-sm font-medium text-slate-300 transition-all hover:bg-white/5 hover:text-white"
aria-label="Edit profile"
>
<i className="fa-solid fa-pen fa-fw" />
@@ -186,7 +154,7 @@ export default function ProfileHero({ user, profile, isOwner, viewerIsFollowing,
</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"
className="inline-flex items-center gap-2 rounded-xl bg-sky-600 px-4 py-2.5 text-sm font-medium text-white shadow-lg shadow-sky-900/30 transition-all hover:bg-sky-500"
aria-label="Open Studio"
>
<i className="fa-solid fa-wand-magic-sparkles fa-fw" />
@@ -195,32 +163,20 @@ export default function ProfileHero({ user, profile, isOwner, viewerIsFollowing,
</>
) : (
<>
<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>
<FollowButton
username={uname}
initialFollowing={following}
initialCount={count}
followingClassName="bg-green-500/10 border border-green-400/40 text-green-400 hover:bg-green-500/15"
idleClassName="bg-sky-500/10 border border-sky-400/40 text-sky-400 hover:bg-sky-500/20"
onChange={({ following: nextFollowing, followersCount }) => {
setFollowing(nextFollowing)
setCount(followersCount)
}}
/>
<button
type="button"
onClick={() => {
if (navigator.share) {
navigator.share({ title: `${displayName} on Skinbase`, url: window.location.href })
@@ -229,7 +185,7 @@ export default function ProfileHero({ user, profile, isOwner, viewerIsFollowing,
}
}}
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"
className="rounded-xl border border-white/10 p-2.5 text-slate-400 transition-all hover:bg-white/5 hover:text-white"
>
<i className="fa-solid fa-share-nodes fa-fw" />
</button>
@@ -241,8 +197,10 @@ export default function ProfileHero({ user, profile, isOwner, viewerIsFollowing,
</div>
<ProfileCoverEditor
isOpen={editorOpen}
open={editorOpen}
onClose={() => setEditorOpen(false)}
currentCoverUrl={coverUrl}
currentPosition={coverPosition}
coverUrl={coverUrl}
coverPosition={coverPosition}
onCoverUpdated={(nextUrl, nextPosition) => {
@@ -254,18 +212,6 @@ export default function ProfileHero({ user, profile, isOwner, viewerIsFollowing,
setCoverPosition(50)
}}
/>
<NovaConfirmDialog
open={confirmOpen}
title="Unfollow creator?"
message={`You will stop seeing updates from @${uname} in your following feed.`}
confirmLabel="Unfollow"
cancelLabel="Keep following"
confirmTone="danger"
onConfirm={onConfirmUnfollow}
onClose={onCloseConfirm}
busy={loading}
/>
</>
)
}

View File

@@ -3,6 +3,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: 'achievements', label: 'Achievements', icon: 'fa-trophy' },
{ id: 'posts', label: 'Posts', icon: 'fa-newspaper' },
{ id: 'collections', label: 'Collections', icon: 'fa-layer-group' },
{ id: 'about', label: 'About', icon: 'fa-id-card' },

View File

@@ -95,7 +95,7 @@ export default function TabAbout({ user, profile, socialLinks, countryName, foll
<span className="flex items-center gap-2">
{profile?.country_code && (
<img
src={`/gfx/flags/shiny/24/${encodeURIComponent(profile.country_code)}.png`}
src={`/gfx/flags/shiny/24/${encodeURIComponent(String(profile.country_code).toUpperCase())}.png`}
alt={countryName}
className="w-4 h-auto rounded-sm"
onError={(e) => { e.target.style.display = 'none' }}

View File

@@ -0,0 +1,35 @@
import React from 'react'
import AchievementsList from '../../achievements/AchievementsList'
export default function TabAchievements({ achievements }) {
const unlocked = Array.isArray(achievements?.unlocked) ? achievements.unlocked : []
const locked = Array.isArray(achievements?.locked) ? achievements.locked : []
return (
<div
id="tabpanel-achievements"
role="tabpanel"
aria-labelledby="tab-achievements"
className="pt-6"
>
<div className="mb-6 flex flex-wrap items-end justify-between gap-4">
<div>
<h2 className="text-xs font-semibold uppercase tracking-[0.2em] text-slate-500">Achievements</h2>
<p className="mt-2 text-sm text-slate-300">
Milestones, creator wins, and level-based unlocks collected on Skinbase.
</p>
</div>
<div className="flex gap-3 text-xs text-slate-400">
<span className="rounded-full border border-white/10 bg-white/5 px-3 py-1.5">
{achievements?.counts?.unlocked || 0} unlocked
</span>
<span className="rounded-full border border-white/10 bg-white/5 px-3 py-1.5">
{achievements?.counts?.total || 0} total
</span>
</div>
</div>
<AchievementsList unlocked={unlocked} locked={locked} />
</div>
)
}

View File

@@ -1,4 +1,5 @@
import React, { useRef, useState } from 'react'
import LevelBadge from '../../xp/LevelBadge'
const DEFAULT_AVATAR = 'https://files.skinbase.org/default/avatar_default.webp'
@@ -22,6 +23,7 @@ function CommentItem({ comment }) {
>
{comment.author_name}
</a>
<LevelBadge level={comment.author_level} rank={comment.author_rank} compact />
<span className="text-slate-600 text-xs ml-auto whitespace-nowrap">
{(() => {
try {

View File

@@ -1,82 +1,7 @@
import React, { useState } from 'react'
import MasonryGallery from '../../gallery/MasonryGallery'
import React from 'react'
import ProfileGalleryPanel from '../ProfileGalleryPanel'
const SORT_OPTIONS = [
{ value: 'latest', label: 'Latest' },
{ value: 'trending', label: 'Trending' },
{ value: 'rising', label: 'Rising' },
{ value: 'views', label: 'Most Viewed' },
{ value: 'favs', label: 'Most Favourited' },
]
/**
* Featured artworks horizontal scroll strip.
*/
function FeaturedStrip({ featuredArtworks }) {
if (!featuredArtworks?.length) return null
return (
<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-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-56 md:w-64"
>
<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-[1.03]"
loading="lazy"
/>
</div>
<p className="text-sm text-slate-300 mt-2 truncate group-hover:text-white transition-colors">
{art.name}
</p>
{art.label && (
<p className="text-[11px] text-slate-600 truncate">{art.label}</p>
)}
</a>
))}
</div>
</div>
)
}
function slugify(str) {
return (str || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
}
/**
* TabArtworks
* Features: sort selector, featured strip, masonry-style artwork grid,
* skeleton loading, empty state, load-more pagination.
*/
export default function TabArtworks({ artworks, featuredArtworks, username, isActive }) {
const [sort, setSort] = useState('latest')
const [items, setItems] = useState(artworks?.data ?? artworks ?? [])
const [nextCursor, setNextCursor] = useState(artworks?.next_cursor ?? null)
const handleSort = async (newSort) => {
setSort(newSort)
setItems([])
try {
const res = await fetch(`/api/profile/${encodeURIComponent(username)}/artworks?sort=${newSort}`, {
headers: { Accept: 'application/json' },
})
if (res.ok) {
const data = await res.json()
setItems(data.data ?? data)
setNextCursor(data.next_cursor ?? null)
}
} catch (_) {}
}
return (
<div
@@ -85,37 +10,10 @@ export default function TabArtworks({ artworks, featuredArtworks, username, isAc
aria-labelledby="tab-artworks"
className="pt-6"
>
{/* Featured strip */}
<FeaturedStrip featuredArtworks={featuredArtworks} />
{/* Sort bar */}
<div className="flex items-center gap-3 mb-5 flex-wrap">
<span className="text-xs text-slate-500 uppercase tracking-wider font-semibold">Sort</span>
<div className="flex gap-1 flex-wrap">
{SORT_OPTIONS.map((opt) => (
<button
key={opt.value}
onClick={() => handleSort(opt.value)}
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-all ${
sort === opt.value
? 'bg-sky-500/20 text-sky-300 ring-1 ring-sky-400/40'
: 'text-slate-400 hover:text-white hover:bg-white/5'
}`}
>
{opt.label}
</button>
))}
</div>
</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}
<ProfileGalleryPanel
artworks={artworks}
featuredArtworks={featuredArtworks}
username={username}
/>
</div>
)

View File

@@ -1,4 +1,4 @@
import React, { useState } from 'react'
import React, { useCallback, useEffect, useRef, useState } from 'react'
import ArtworkGallery from '../../artwork/ArtworkGallery'
function FavSkeleton() {
@@ -14,12 +14,22 @@ function FavSkeleton() {
* Shows artworks the user has favourited.
*/
export default function TabFavourites({ favourites, isOwner, username }) {
const [items, setItems] = useState(favourites ?? [])
const [nextCursor, setNextCursor] = useState(null)
const initialItems = Array.isArray(favourites)
? favourites
: (favourites?.data ?? [])
const [items, setItems] = useState(initialItems)
const [nextCursor, setNextCursor] = useState(favourites?.next_cursor ?? null)
const [loadingMore, setLoadingMore] = useState(false)
const loadMoreRef = useRef(null)
const loadMore = async () => {
useEffect(() => {
setItems(initialItems)
setNextCursor(favourites?.next_cursor ?? null)
}, [favourites, initialItems])
const loadMore = useCallback(async () => {
if (!nextCursor || loadingMore) return
setLoadingMore(true)
try {
const res = await fetch(
@@ -33,7 +43,30 @@ export default function TabFavourites({ favourites, isOwner, username }) {
}
} catch (_) {}
setLoadingMore(false)
}
}, [loadingMore, nextCursor, username])
useEffect(() => {
const node = loadMoreRef.current
if (!node || !nextCursor) {
return undefined
}
const observer = new IntersectionObserver(
(entries) => {
if (entries.some((entry) => entry.isIntersecting)) {
loadMore()
}
},
{
rootMargin: '320px 0px',
}
)
observer.observe(node)
return () => observer.disconnect()
}, [loadMore, nextCursor])
return (
<div
@@ -68,6 +101,10 @@ export default function TabFavourites({ favourites, isOwner, username }) {
{loadingMore && Array.from({ length: 4 }).map((_, i) => <FavSkeleton key={`sk-${i}`} />)}
</ArtworkGallery>
{nextCursor && (
<div ref={loadMoreRef} className="h-6 w-full" aria-hidden="true" />
)}
{nextCursor && (
<div className="mt-8 text-center">
<button

View File

@@ -1,4 +1,5 @@
import React from 'react'
import LevelBadge from '../../xp/LevelBadge'
export default function TabStories({ stories, username }) {
const list = Array.isArray(stories) ? stories : []
@@ -26,6 +27,10 @@ export default function TabStories({ stories, username }) {
<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">
<div className="flex items-center justify-between gap-2">
<LevelBadge level={story.creator_level} rank={story.creator_rank} compact />
<span className="text-[11px] uppercase tracking-[0.16em] text-gray-500">Story</span>
</div>
<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">

View File

@@ -0,0 +1,33 @@
import React, { useState } from 'react'
export default function BookmarkButton({ active = false, count = 0, onToggle, label = 'Save', activeLabel = 'Saved' }) {
const [busy, setBusy] = useState(false)
const handleClick = async () => {
if (!onToggle || busy) return
setBusy(true)
try {
await onToggle()
} finally {
setBusy(false)
}
}
return (
<button
type="button"
onClick={handleClick}
disabled={busy}
className={[
'inline-flex items-center gap-2 rounded-full border px-4 py-2 text-sm font-medium transition-all disabled:cursor-not-allowed disabled:opacity-60',
active
? 'border-amber-400/30 bg-amber-400/12 text-amber-200'
: 'border-white/[0.08] bg-white/[0.04] text-white/70 hover:bg-white/[0.08] hover:text-white',
].join(' ')}
>
<i className={`fa-solid fa-fw ${busy ? 'fa-circle-notch fa-spin' : 'fa-bookmark'}`} />
<span>{active ? activeLabel : label}</span>
<span className="text-xs opacity-80">{Number(count || 0).toLocaleString()}</span>
</button>
)
}

View File

@@ -0,0 +1,61 @@
import React, { useState } from 'react'
export default function CommentForm({ placeholder = 'Write a comment…', submitLabel = 'Post', onSubmit, onCancel, compact = false }) {
const [content, setContent] = useState('')
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
const handleSubmit = async (event) => {
event.preventDefault()
const trimmed = content.trim()
if (!trimmed || busy) return
setBusy(true)
setError('')
try {
await onSubmit?.(trimmed)
setContent('')
} catch (submitError) {
setError(submitError?.message || 'Unable to post comment.')
} finally {
setBusy(false)
}
}
return (
<form className="space-y-3" onSubmit={handleSubmit}>
<textarea
value={content}
onChange={(event) => setContent(event.target.value)}
rows={compact ? 3 : 4}
maxLength={10000}
placeholder={placeholder}
className="w-full rounded-2xl border border-white/[0.08] bg-white/[0.04] px-4 py-3 text-sm text-white placeholder-white/35 outline-none transition focus:border-sky-400/40 focus:bg-white/[0.06]"
/>
{error ? <p className="text-sm text-rose-300">{error}</p> : null}
<div className="flex items-center justify-between gap-3">
<span className="text-xs text-white/35">{content.trim().length}/10000</span>
<div className="flex items-center gap-2">
{onCancel ? (
<button
type="button"
onClick={onCancel}
className="rounded-full border border-white/[0.08] bg-white/[0.04] px-4 py-2 text-sm font-medium text-white/70 transition hover:bg-white/[0.08] hover:text-white"
>
Cancel
</button>
) : null}
<button
type="submit"
disabled={busy || content.trim().length === 0}
className="rounded-full bg-sky-500 px-4 py-2 text-sm font-semibold text-white transition hover:bg-sky-400 disabled:cursor-not-allowed disabled:opacity-60"
>
{busy ? 'Posting…' : submitLabel}
</button>
</div>
</div>
</form>
)
}

View File

@@ -0,0 +1,87 @@
import React, { useState } from 'react'
import LevelBadge from '../xp/LevelBadge'
import CommentForm from './CommentForm'
function CommentItem({ comment, canReply, onReply, onDelete }) {
const [replying, setReplying] = useState(false)
return (
<article id={`story-comment-${comment.id}`} className="rounded-2xl border border-white/[0.08] bg-white/[0.04] p-4">
<div className="flex gap-3">
<img
src={comment.user?.avatar_url || 'https://files.skinbase.org/default/avatar_default.webp'}
alt={comment.user?.display || 'User'}
className="h-10 w-10 rounded-full object-cover ring-1 ring-white/10"
/>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
{comment.user?.profile_url ? (
<a href={comment.user.profile_url} className="text-sm font-semibold text-white hover:text-sky-300">
{comment.user.display}
</a>
) : (
<span className="text-sm font-semibold text-white">{comment.user?.display || 'User'}</span>
)}
<LevelBadge level={comment.user?.level} rank={comment.user?.rank} compact />
<span className="text-xs uppercase tracking-[0.16em] text-white/30">{comment.time_ago}</span>
</div>
<div
className="prose prose-invert prose-sm mt-2 max-w-none text-white/80 prose-p:my-1.5 prose-a:text-sky-300"
dangerouslySetInnerHTML={{ __html: comment.rendered_content || '' }}
/>
<div className="mt-3 flex items-center gap-3 text-xs text-white/45">
{canReply ? (
<button type="button" onClick={() => setReplying((value) => !value)} className="transition hover:text-white">
Reply
</button>
) : null}
{comment.can_delete ? (
<button type="button" onClick={() => onDelete?.(comment.id)} className="transition hover:text-rose-300">
Delete
</button>
) : null}
</div>
{replying ? (
<div className="mt-3">
<CommentForm
compact
placeholder={`Reply to ${comment.user?.display || 'user'}`}
submitLabel="Reply"
onCancel={() => setReplying(false)}
onSubmit={async (content) => {
await onReply?.(comment.id, content)
setReplying(false)
}}
/>
</div>
) : null}
{Array.isArray(comment.replies) && comment.replies.length > 0 ? (
<div className="mt-4 space-y-3 border-l border-white/[0.08] pl-4">
{comment.replies.map((reply) => (
<CommentItem key={reply.id} comment={reply} canReply={canReply} onReply={onReply} onDelete={onDelete} />
))}
</div>
) : null}
</div>
</div>
</article>
)
}
export default function CommentList({ comments = [], canReply = false, onReply, onDelete, emptyMessage = 'No comments yet.' }) {
if (!comments.length) {
return <p className="text-sm text-white/45">{emptyMessage}</p>
}
return (
<div className="space-y-4">
{comments.map((comment) => (
<CommentItem key={comment.id} comment={comment} canReply={canReply} onReply={onReply} onDelete={onDelete} />
))}
</div>
)
}

View File

@@ -0,0 +1,97 @@
import React, { useState } from 'react'
import NovaConfirmDialog from '../ui/NovaConfirmDialog'
export default function FollowButton({
username,
initialFollowing = false,
initialCount = 0,
showCount = true,
className = '',
followingClassName = 'bg-white/[0.04] border border-white/[0.08] text-white/75 hover:bg-white/[0.08]',
idleClassName = 'bg-accent text-deep hover:brightness-110',
sizeClassName = 'px-4 py-2.5 text-sm',
confirmMessage,
onChange,
}) {
const [following, setFollowing] = useState(Boolean(initialFollowing))
const [count, setCount] = useState(Number(initialCount || 0))
const [loading, setLoading] = useState(false)
const [confirmOpen, setConfirmOpen] = useState(false)
const csrfToken = typeof document !== 'undefined'
? document.querySelector('meta[name="csrf-token"]')?.getAttribute('content')
: null
const persist = async (nextState) => {
if (!username || loading) return
setLoading(true)
try {
const response = await fetch(`/api/user/${encodeURIComponent(username)}/follow`, {
method: nextState ? 'POST' : 'DELETE',
headers: {
Accept: 'application/json',
'X-CSRF-TOKEN': csrfToken || '',
},
credentials: 'same-origin',
})
if (!response.ok) throw new Error('Follow request failed')
const payload = await response.json()
const nextFollowing = Boolean(payload?.following)
const nextCount = Number(payload?.followers_count ?? count)
setFollowing(nextFollowing)
setCount(nextCount)
onChange?.({ following: nextFollowing, followersCount: nextCount })
} catch {
// Keep previous state on failure.
} finally {
setLoading(false)
}
}
const onToggle = async () => {
if (!following) {
await persist(true)
return
}
setConfirmOpen(true)
}
const toneClassName = following ? followingClassName : idleClassName
return (
<>
<button
type="button"
onClick={onToggle}
disabled={loading || !username}
aria-label={following ? 'Unfollow creator' : 'Follow creator'}
className={[
'inline-flex items-center justify-center gap-2 rounded-xl font-semibold transition-all disabled:cursor-not-allowed disabled:opacity-60',
sizeClassName,
toneClassName,
className,
].join(' ')}
>
<i className={`fa-solid fa-fw ${loading ? 'fa-circle-notch fa-spin' : following ? 'fa-user-check' : 'fa-user-plus'}`} />
<span>{following ? 'Following' : 'Follow'}</span>
{showCount ? <span className="text-xs opacity-70">{count.toLocaleString()}</span> : null}
</button>
<NovaConfirmDialog
open={confirmOpen}
title="Unfollow creator?"
message={confirmMessage || `You will stop seeing updates from @${username} in your following feed.`}
confirmLabel="Unfollow"
cancelLabel="Keep following"
confirmTone="danger"
onConfirm={async () => {
setConfirmOpen(false)
await persist(false)
}}
onClose={() => setConfirmOpen(false)}
/>
</>
)
}

View File

@@ -0,0 +1,33 @@
import React, { useState } from 'react'
export default function LikeButton({ active = false, count = 0, onToggle, label = 'Like', activeLabel = 'Liked' }) {
const [busy, setBusy] = useState(false)
const handleClick = async () => {
if (!onToggle || busy) return
setBusy(true)
try {
await onToggle()
} finally {
setBusy(false)
}
}
return (
<button
type="button"
onClick={handleClick}
disabled={busy}
className={[
'inline-flex items-center gap-2 rounded-full border px-4 py-2 text-sm font-medium transition-all disabled:cursor-not-allowed disabled:opacity-60',
active
? 'border-rose-500/30 bg-rose-500/12 text-rose-300'
: 'border-white/[0.08] bg-white/[0.04] text-white/70 hover:bg-white/[0.08] hover:text-white',
].join(' ')}
>
<i className={`fa-solid fa-fw ${busy ? 'fa-circle-notch fa-spin' : active ? 'fa-heart' : 'fa-heart'}`} />
<span>{active ? activeLabel : label}</span>
<span className="text-xs opacity-80">{Number(count || 0).toLocaleString()}</span>
</button>
)
}

View File

@@ -0,0 +1,163 @@
import React, { useEffect, useRef, useState } from 'react'
export default function NotificationDropdown({ initialUnreadCount = 0, notificationsUrl = '/api/notifications' }) {
const [open, setOpen] = useState(false)
const [loading, setLoading] = useState(false)
const [items, setItems] = useState([])
const [unreadCount, setUnreadCount] = useState(Number(initialUnreadCount || 0))
const rootRef = useRef(null)
const csrfToken = typeof document !== 'undefined'
? document.querySelector('meta[name="csrf-token"]')?.getAttribute('content')
: null
useEffect(() => {
if (!open) return undefined
const onDocumentClick = (event) => {
if (rootRef.current && !rootRef.current.contains(event.target)) {
setOpen(false)
}
}
document.addEventListener('mousedown', onDocumentClick)
return () => document.removeEventListener('mousedown', onDocumentClick)
}, [open])
useEffect(() => {
if (!open || items.length > 0) return
setLoading(true)
fetch(notificationsUrl, {
headers: { Accept: 'application/json' },
credentials: 'same-origin',
})
.then(async (response) => {
if (!response.ok) throw new Error('Failed to load notifications')
return response.json()
})
.then((payload) => {
setItems(Array.isArray(payload?.data) ? payload.data : [])
setUnreadCount(Number(payload?.unread_count || 0))
})
.catch(() => {})
.finally(() => setLoading(false))
}, [items.length, notificationsUrl, open])
const markAllRead = async () => {
try {
await fetch('/api/notifications/read-all', {
method: 'POST',
headers: {
Accept: 'application/json',
'X-CSRF-TOKEN': csrfToken || '',
},
credentials: 'same-origin',
})
setItems((current) => current.map((item) => ({ ...item, read: true })))
setUnreadCount(0)
} catch {
// Keep current state on failure.
}
}
const markSingleRead = async (id) => {
await fetch(`/api/notifications/${id}/read`, {
method: 'POST',
headers: {
Accept: 'application/json',
'X-CSRF-TOKEN': csrfToken || '',
},
credentials: 'same-origin',
})
setItems((current) => current.map((item) => item.id === id ? { ...item, read: true } : item))
setUnreadCount((current) => Math.max(0, current - 1))
}
const handleNotificationClick = async (event, item) => {
if (!item?.id || item.read) return
const href = event.currentTarget.getAttribute('href')
if (!href) return
event.preventDefault()
try {
await markSingleRead(item.id)
} catch {
// Continue to the destination even if marking as read fails.
}
window.location.assign(href)
}
return (
<div ref={rootRef} className="relative">
<button
type="button"
onClick={() => setOpen((value) => !value)}
className="relative inline-flex h-10 w-10 items-center justify-center rounded-lg text-white/75 transition hover:bg-white/5 hover:text-white"
aria-label="Notifications"
>
<svg className="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M18 8a6 6 0 10-12 0c0 7-3 7-3 7h18s-3 0-3-7" />
<path d="M13.7 21a2 2 0 01-3.4 0" />
</svg>
{unreadCount > 0 ? (
<span className="absolute -bottom-1 right-0 rounded bg-red-700/80 px-1.5 py-0.5 text-[11px] font-semibold text-white border border-sb-line">
{unreadCount > 99 ? '99+' : unreadCount}
</span>
) : null}
</button>
{open ? (
<div className="absolute right-0 mt-2 w-[22rem] overflow-hidden rounded-2xl border border-white/[0.08] bg-panel shadow-2xl shadow-black/35">
<div className="flex items-center justify-between border-b border-white/[0.06] px-4 py-3">
<div>
<h3 className="text-sm font-semibold text-white">Notifications</h3>
<p className="text-xs text-white/35">Recent follows, likes, comments, and unlocks.</p>
</div>
{unreadCount > 0 ? (
<button type="button" onClick={markAllRead} className="text-xs font-medium text-sky-300 transition hover:text-sky-200">
Mark all read
</button>
) : null}
</div>
<div className="max-h-[28rem] overflow-y-auto">
{loading ? <div className="px-4 py-6 text-sm text-white/45">Loading notifications</div> : null}
{!loading && items.length === 0 ? <div className="px-4 py-6 text-sm text-white/45">No notifications yet.</div> : null}
{!loading && items.map((item) => (
<a
key={item.id}
href={item.url || '/dashboard/comments/received'}
onClick={(event) => handleNotificationClick(event, item)}
className={[
'flex gap-3 border-b border-white/[0.05] px-4 py-3 transition hover:bg-white/[0.04]',
item.read ? 'bg-transparent' : 'bg-sky-500/[0.06]',
].join(' ')}
>
<img
src={item.actor?.avatar_url || 'https://files.skinbase.org/default/avatar_default.webp'}
alt={item.actor?.name || 'Notification'}
className="h-10 w-10 rounded-full object-cover ring-1 ring-white/10"
/>
<div className="min-w-0 flex-1">
<p className="text-sm text-white/85">{item.message}</p>
<p className="mt-1 text-xs uppercase tracking-[0.16em] text-white/30">{item.time_ago || ''}</p>
</div>
</a>
))}
</div>
<div className="border-t border-white/[0.06] bg-white/[0.02] px-4 py-3">
<a href="/dashboard/notifications" className="inline-flex items-center gap-2 text-sm font-medium text-sky-300 transition hover:text-sky-200">
<i className="fa-solid fa-arrow-right" aria-hidden="true" />
View all notifications
</a>
</div>
</div>
) : null}
</div>
)
}

View File

@@ -0,0 +1,161 @@
import React, { useState } from 'react'
import FollowButton from './FollowButton'
import LikeButton from './LikeButton'
import BookmarkButton from './BookmarkButton'
import CommentForm from './CommentForm'
import CommentList from './CommentList'
export default function StorySocialPanel({ story, creator, initialState, initialComments, isAuthenticated = false }) {
const [state, setState] = useState({
liked: Boolean(initialState?.liked),
bookmarked: Boolean(initialState?.bookmarked),
likesCount: Number(initialState?.likes_count || 0),
commentsCount: Number(initialState?.comments_count || 0),
bookmarksCount: Number(initialState?.bookmarks_count || 0),
})
const [comments, setComments] = useState(Array.isArray(initialComments) ? initialComments : [])
const csrfToken = typeof document !== 'undefined'
? document.querySelector('meta[name="csrf-token"]')?.getAttribute('content')
: null
const postJson = async (url, method = 'POST', body = null) => {
const response = await fetch(url, {
method,
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'X-CSRF-TOKEN': csrfToken || '',
},
credentials: 'same-origin',
body: body ? JSON.stringify(body) : null,
})
if (!response.ok) {
const payload = await response.json().catch(() => ({}))
throw new Error(payload?.message || 'Request failed.')
}
return response.json()
}
const refreshCounts = (nextComments) => {
const countReplies = (items) => items.reduce((sum, item) => sum + 1 + countReplies(item.replies || []), 0)
return countReplies(nextComments)
}
const insertReply = (items, parentId, newComment) => items.map((item) => {
if (item.id === parentId) {
return { ...item, replies: [...(item.replies || []), newComment] }
}
if (Array.isArray(item.replies) && item.replies.length > 0) {
return { ...item, replies: insertReply(item.replies, parentId, newComment) }
}
return item
})
const deleteCommentRecursive = (items, commentId) => items
.filter((item) => item.id !== commentId)
.map((item) => ({
...item,
replies: Array.isArray(item.replies) ? deleteCommentRecursive(item.replies, commentId) : [],
}))
return (
<div className="space-y-6">
<div className="flex flex-wrap items-center gap-3">
<LikeButton
active={state.liked}
count={state.likesCount}
onToggle={async () => {
if (!isAuthenticated) {
window.location.href = '/login'
return
}
const payload = await postJson(`/api/stories/${story.id}/like`, 'POST', { state: !state.liked })
setState((current) => ({
...current,
liked: Boolean(payload?.liked),
likesCount: Number(payload?.likes_count || 0),
}))
}}
/>
<BookmarkButton
active={state.bookmarked}
count={state.bookmarksCount}
onToggle={async () => {
if (!isAuthenticated) {
window.location.href = '/login'
return
}
const payload = await postJson(`/api/stories/${story.id}/bookmark`, 'POST', { state: !state.bookmarked })
setState((current) => ({
...current,
bookmarked: Boolean(payload?.bookmarked),
bookmarksCount: Number(payload?.bookmarks_count || 0),
}))
}}
/>
{creator?.username ? (
<FollowButton
username={creator.username}
initialFollowing={Boolean(initialState?.is_following_creator)}
initialCount={Number(creator.followers_count || 0)}
className="min-w-[11rem]"
/>
) : null}
</div>
<section className="rounded-2xl border border-white/[0.08] bg-white/[0.04] p-5">
<div className="mb-4 flex items-center justify-between gap-3">
<div>
<h2 className="text-lg font-semibold text-white">Discussion</h2>
<p className="text-sm text-white/40">{state.commentsCount.toLocaleString()} comments on this story</p>
</div>
</div>
{isAuthenticated ? (
<div className="mb-5">
<CommentForm
placeholder="Add to the story discussion…"
submitLabel="Post Comment"
onSubmit={async (content) => {
const payload = await postJson(`/api/stories/${story.id}/comments`, 'POST', { content })
const nextComments = [payload.data, ...comments]
setComments(nextComments)
setState((current) => ({ ...current, commentsCount: refreshCounts(nextComments) }))
}}
/>
</div>
) : (
<p className="mb-5 text-sm text-white/45">
<a href="/login" className="text-sky-300 hover:text-sky-200">Sign in</a> to join the discussion.
</p>
)}
<CommentList
comments={comments}
canReply={isAuthenticated}
emptyMessage="No comments yet. Start the discussion."
onReply={async (parentId, content) => {
const payload = await postJson(`/api/stories/${story.id}/comments`, 'POST', { content, parent_id: parentId })
const nextComments = insertReply(comments, parentId, payload.data)
setComments(nextComments)
setState((current) => ({ ...current, commentsCount: refreshCounts(nextComments) }))
}}
onDelete={async (commentId) => {
await postJson(`/api/stories/${story.id}/comments/${commentId}`, 'DELETE')
const nextComments = deleteCommentRecursive(comments, commentId)
setComments(nextComments)
setState((current) => ({ ...current, commentsCount: refreshCounts(nextComments) }))
}}
/>
</section>
</div>
)
}

View File

@@ -0,0 +1,34 @@
import React from 'react'
const TONES = {
1: 'border-slate-500/30 bg-slate-500/10 text-slate-200',
2: 'border-sky-400/35 bg-sky-500/10 text-sky-200',
3: 'border-emerald-400/35 bg-emerald-500/10 text-emerald-200',
4: 'border-fuchsia-400/35 bg-fuchsia-500/10 text-fuchsia-200',
5: 'border-amber-400/35 bg-amber-500/10 text-amber-100',
6: 'border-rose-400/35 bg-rose-500/10 text-rose-100',
7: 'border-violet-400/35 bg-violet-500/10 text-violet-100',
}
function cx(...parts) {
return parts.filter(Boolean).join(' ')
}
export default function LevelBadge({ level = 1, rank = 'Newbie', compact = false, className = '' }) {
const numericLevel = Number(level || 1)
const tone = TONES[numericLevel] || TONES[1]
return (
<span
className={cx(
'inline-flex items-center gap-1 rounded-full border px-2.5 py-1 font-semibold tracking-[0.08em]',
compact ? 'text-[10px] uppercase' : 'text-[11px] uppercase',
tone,
className,
)}
>
<span>LVL {numericLevel}</span>
<span className="text-current/75">{rank}</span>
</span>
)
}

View File

@@ -0,0 +1,43 @@
import React from 'react'
function formatXp(value) {
return new Intl.NumberFormat().format(Number(value || 0))
}
function clampPercent(value) {
const numeric = Number(value || 0)
if (!Number.isFinite(numeric)) return 0
return Math.max(0, Math.min(100, numeric))
}
function cx(...parts) {
return parts.filter(Boolean).join(' ')
}
export default function XPProgressBar({
xp = 0,
currentLevelXp = 0,
nextLevelXp = 100,
progressPercent = 0,
maxLevel = false,
className = '',
}) {
const percent = maxLevel ? 100 : clampPercent(progressPercent)
return (
<div className={cx('space-y-2', className)}>
<div className="flex items-center justify-between gap-3 text-xs text-slate-300">
<span>{formatXp(xp)} XP</span>
<span>
{maxLevel ? 'Max level reached' : `${formatXp(currentLevelXp)} / ${formatXp(nextLevelXp)} XP`}
</span>
</div>
<div className="h-2.5 overflow-hidden rounded-full bg-white/10 ring-1 ring-white/10">
<div
className="h-full rounded-full bg-[linear-gradient(90deg,#38bdf8_0%,#a78bfa_55%,#f59e0b_100%)] transition-[width] duration-500"
style={{ width: `${percent}%` }}
/>
</div>
</div>
)
}