Save workspace changes

This commit is contained in:
2026-04-18 17:02:56 +02:00
parent f02ea9a711
commit 87d60af5a9
4220 changed files with 1388603 additions and 1554 deletions

View File

@@ -0,0 +1,158 @@
import React, { useEffect, useState } from 'react'
function actorLabel(item) {
if (!item?.actor) {
return item?.type === 'notification' ? 'System' : 'Someone'
}
return item.actor.username ? `@${item.actor.username}` : item.actor.name || 'User'
}
function describeActivity(item) {
switch (item?.type) {
case 'comment':
return item?.context?.artwork_title ? `commented on ${item.context.artwork_title}` : 'commented on your artwork'
case 'new_follower':
return 'started following you'
case 'notification':
return item?.message || 'sent a notification'
default:
return item?.message || 'shared new activity'
}
}
function activityIcon(type) {
switch (type) {
case 'comment':
return 'fa-solid fa-comment-dots'
case 'new_follower':
return 'fa-solid fa-user-plus'
case 'notification':
return 'fa-solid fa-bell'
default:
return 'fa-solid fa-bolt'
}
}
function timeLabel(dateString) {
const date = new Date(dateString)
if (Number.isNaN(date.getTime())) {
return 'just now'
}
return date.toLocaleString()
}
export default function ActivityFeed() {
const [items, setItems] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
useEffect(() => {
let cancelled = false
async function load() {
try {
setLoading(true)
const response = await window.axios.get('/api/activity', {
params: {
filter: 'my',
per_page: 8,
},
})
if (!cancelled) {
setItems(Array.isArray(response.data?.data) ? response.data.data : [])
setError('')
}
} catch (err) {
if (!cancelled) {
setError('Could not load activity right now.')
}
} finally {
if (!cancelled) {
setLoading(false)
}
}
}
load()
return () => {
cancelled = true
}
}, [])
return (
<section className="rounded-[28px] border border-white/10 bg-[linear-gradient(180deg,rgba(15,23,42,0.92),rgba(15,23,42,0.82))] p-5 shadow-[0_24px_90px_rgba(2,8,23,0.32)]">
<div className="mb-5 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<p className="text-[11px] uppercase tracking-[0.24em] text-sky-200/70">Live activity</p>
<h2 className="mt-2 text-xl font-semibold text-white">Activity Feed</h2>
<p className="mt-2 max-w-xl text-sm leading-6 text-slate-300">
Recent followers, artwork comments, and notifications that deserve your attention.
</p>
</div>
<span className="inline-flex items-center justify-center rounded-full border border-white/10 bg-white/5 px-3 py-1.5 text-[11px] font-semibold uppercase tracking-wide text-slate-300 sm:justify-start">Recent actions</span>
</div>
{loading ? <p className="text-sm text-slate-400">Loading activity...</p> : null}
{error ? <p className="text-sm text-rose-300">{error}</p> : null}
{!loading && !error && items.length === 0 ? (
<div className="rounded-2xl border border-white/8 bg-white/5 px-5 py-6 text-sm text-slate-300">
<p className="font-medium text-white">No recent activity yet.</p>
<p className="mt-2 text-slate-400">New followers, comments, and notifications will appear here as they happen.</p>
</div>
) : null}
{!loading && !error && items.length > 0 ? (
<div className="max-h-[520px] space-y-3 overflow-y-auto pr-1">
{items.map((item) => (
<article
key={item.id}
className={`rounded-xl border p-3 transition ${
item.is_unread
? 'border-sky-400/30 bg-sky-400/10'
: 'border-white/8 bg-white/[0.04]'
}`}
>
<div className="flex items-start gap-3">
<div className="flex h-11 w-11 shrink-0 items-center justify-center overflow-hidden rounded-2xl border border-white/10 bg-slate-950/60">
{item.actor?.avatar ? (
<img src={item.actor.avatar} alt={actorLabel(item)} className="h-full w-full object-cover" />
) : (
<i className={`${activityIcon(item.type)} text-sm text-sky-100/80`} />
)}
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
<div>
<p className="text-sm text-slate-100">
<span className="font-semibold text-white">{actorLabel(item)}</span>{' '}
<span>{describeActivity(item)}</span>
</p>
{item.message && item.type !== 'notification' ? (
<p className="mt-1 text-xs text-slate-400">{item.message}</p>
) : null}
</div>
<span className="text-[11px] uppercase tracking-wide text-slate-400 sm:shrink-0">{timeLabel(item.created_at)}</span>
</div>
{item.context?.artwork_url ? (
<a
href={item.context.artwork_url}
className="mt-3 inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/5 px-3 py-1.5 text-[11px] font-semibold uppercase tracking-wide text-slate-200 transition hover:border-white/20 hover:bg-white/10"
>
View artwork
<i className="fa-solid fa-arrow-right text-[10px]" />
</a>
) : null}
</div>
</div>
</article>
))}
</div>
) : null}
</section>
)
}

View File

@@ -0,0 +1,71 @@
import React, { useEffect, useState } from 'react'
function Widget({ label, value }) {
return (
<div className="rounded-2xl border border-white/8 bg-white/[0.04] p-4 shadow-lg transition hover:-translate-y-0.5 hover:border-white/15 hover:bg-white/[0.06]">
<p className="text-[11px] uppercase tracking-[0.16em] text-slate-400">{label}</p>
<p className="mt-2 text-2xl font-semibold text-white">{value}</p>
</div>
)
}
export default function CreatorAnalytics({ isCreator }) {
const [data, setData] = useState(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
let cancelled = false
async function load() {
try {
const response = await window.axios.get('/api/dashboard/analytics')
if (!cancelled) {
setData(response.data?.data || null)
}
} finally {
if (!cancelled) {
setLoading(false)
}
}
}
load()
return () => {
cancelled = true
}
}, [])
return (
<section className="rounded-[28px] border border-white/10 bg-[linear-gradient(180deg,rgba(15,23,42,0.92),rgba(15,23,42,0.82))] p-5 shadow-[0_24px_90px_rgba(2,8,23,0.32)]">
<div className="mb-5 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<p className="text-[11px] uppercase tracking-[0.24em] text-sky-200/70">Creator space</p>
<h2 className="mt-2 text-xl font-semibold text-white">Creator Analytics</h2>
<p className="mt-2 max-w-md text-sm leading-6 text-slate-300">Snapshot metrics for the work you publish and the audience building around it.</p>
</div>
<a href="/creator/analytics" className="inline-flex items-center justify-center gap-2 rounded-full border border-sky-400/25 bg-sky-400/10 px-3 py-1.5 text-xs font-semibold uppercase tracking-wide text-sky-100 transition hover:border-sky-300/35 hover:bg-sky-400/15 sm:justify-start">
Open analytics
<i className="fa-solid fa-arrow-right text-[10px]" />
</a>
</div>
{loading ? <p className="text-sm text-slate-400">Loading analytics...</p> : null}
{!loading && !isCreator && !data?.is_creator ? (
<div className="rounded-2xl border border-white/8 bg-white/[0.04] p-4 text-sm text-slate-300">
Upload your first artwork to unlock creator-only insights.
</div>
) : null}
{!loading && (isCreator || data?.is_creator) ? (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<Widget label="Total Artworks" value={data?.total_artworks ?? 0} />
<Widget label="Total Story Views" value={data?.total_story_views ?? 0} />
<Widget label="Total Followers" value={data?.total_followers ?? 0} />
<Widget label="Total Likes" value={data?.total_likes ?? 0} />
</div>
) : null}
</section>
)
}

View File

@@ -0,0 +1,129 @@
import React from 'react'
const baseCard =
'group rounded-2xl border border-white/10 bg-[#0b1826]/85 p-4 shadow-lg shadow-black/20 transition hover:-translate-y-0.5 hover:border-sky-300/35 hover:bg-[#102033]'
export default function QuickActions({ isCreator, receivedCommentsCount = 0, onNavigate }) {
const actions = [
{
key: 'edit-profile',
label: 'Edit Profile',
href: '/dashboard/profile',
icon: 'fa-solid fa-user-gear',
description: 'Refresh your bio, socials, avatar, and country details.',
},
{
key: 'received-comments',
label: 'Review Feedback',
href: '/dashboard/comments/received',
icon: 'fa-solid fa-inbox',
description: 'Read the latest comments left on your work.',
badge: receivedCommentsCount > 0 ? `${receivedCommentsCount} new` : null,
},
{
key: 'notifications',
label: 'Open Notifications',
href: '/dashboard/notifications',
icon: 'fa-solid fa-bell',
description: 'Catch up on mentions, replies, and updates.',
},
...(isCreator
? [
{
key: 'manage-artworks',
label: 'Manage Artworks',
href: '/dashboard/artworks',
icon: 'fa-solid fa-layer-group',
description: 'Edit titles, details, and the presentation of your portfolio.',
},
{
key: 'write-story',
label: 'Write Story',
href: '/creator/stories/create',
icon: 'fa-solid fa-pen-nib',
description: 'Publish a tutorial, devlog, showcase, or announcement.',
},
{
key: 'open-studio',
label: 'Open Studio',
href: '/studio',
icon: 'fa-solid fa-compass-drafting',
description: 'Jump into the wider creator workspace and analytics tools.',
},
{
key: 'creator-stories',
label: 'Story Dashboard',
href: '/creator/stories',
icon: 'fa-solid fa-newspaper',
description: 'Review creator stories, drafts, and publishing flow in one place.',
},
]
: [
{
key: 'upload-artwork',
label: 'Upload Artwork',
href: '/upload',
icon: 'fa-solid fa-cloud-arrow-up',
description: 'Start publishing and unlock more creator-focused dashboard tools.',
},
{
key: 'explore-trending',
label: 'Explore Trending',
href: '/discover/trending',
icon: 'fa-solid fa-fire-flame-curved',
description: 'Browse what is hot right now to spot styles and creators worth following.',
},
{
key: 'find-creators',
label: 'Find Creators',
href: '/creators/top',
icon: 'fa-solid fa-user-group',
description: 'Discover artists to follow and build a stronger feed around your taste.',
},
{
key: 'saved-favorites',
label: 'Open Favorites',
href: '/dashboard/favorites',
icon: 'fa-solid fa-bookmark',
description: 'Revisit saved work and start shaping your own inspiration library.',
},
]),
]
return (
<section className="rounded-[28px] border border-white/10 bg-[#08111c]/90 p-5 shadow-2xl shadow-black/20 sm:p-6">
<div className="mb-5 flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
<div>
<p className="text-[11px] uppercase tracking-[0.24em] text-sky-200/80">Quick Actions</p>
<h2 className="mt-2 text-xl font-semibold text-white">Start something useful right now</h2>
</div>
<span className="rounded-full border border-white/10 bg-white/5 px-3 py-1 text-xs uppercase tracking-[0.16em] text-slate-300">
{isCreator ? 'Creator Mode' : 'Member Mode'}
</span>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
{actions.map((action) => (
<a key={action.key} href={action.href} onClick={() => onNavigate?.(action.href, action.label)} className={baseCard}>
<div className="flex items-start gap-3">
<span className="inline-flex h-11 w-11 items-center justify-center rounded-2xl border border-white/10 bg-white/5 text-sky-200">
<i className={action.icon} aria-hidden="true" />
</span>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2">
<p className="text-sm font-semibold text-white">{action.label}</p>
{action.badge ? (
<span className="rounded-full border border-sky-300/20 bg-sky-400/10 px-2 py-0.5 text-[11px] font-semibold uppercase tracking-[0.12em] text-sky-100">
{action.badge}
</span>
) : null}
</div>
<p className="mt-2 text-sm leading-6 text-slate-300">{action.description}</p>
</div>
</div>
</a>
))}
</div>
</section>
)
}

View File

@@ -0,0 +1,70 @@
import React, { useEffect, useState } from 'react'
import AchievementBadge from '../../components/achievements/AchievementBadge'
export default function RecentAchievements() {
const [data, setData] = useState({ recent: [], counts: { unlocked: 0, total: 0 } })
const [loading, setLoading] = useState(true)
useEffect(() => {
let cancelled = false
async function load() {
try {
const response = await window.axios.get('/api/user/achievements')
if (!cancelled && response.data) {
setData(response.data)
}
} finally {
if (!cancelled) {
setLoading(false)
}
}
}
load()
return () => {
cancelled = true
}
}, [])
return (
<section className="rounded-[28px] border border-white/10 bg-[linear-gradient(180deg,rgba(15,23,42,0.92),rgba(15,23,42,0.82))] p-5 shadow-[0_24px_90px_rgba(2,8,23,0.32)]">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<p className="text-[11px] uppercase tracking-[0.24em] text-sky-200/70">Achievements</p>
<h2 className="mt-2 text-xl font-semibold text-white">Recent Unlocks</h2>
<p className="mt-2 max-w-xs text-sm leading-6 text-slate-300">Recent badges and milestones that reflect how your account is developing.</p>
</div>
<span className="inline-flex items-center justify-center rounded-full border border-white/10 bg-white/5 px-3 py-1 text-xs text-slate-300 sm:justify-start">
{data?.counts?.unlocked || 0} / {data?.counts?.total || 0}
</span>
</div>
{loading ? <p className="mt-4 text-sm text-slate-400">Loading achievements...</p> : null}
{!loading && (!Array.isArray(data?.recent) || data.recent.length === 0) ? (
<div className="mt-4 rounded-2xl border border-white/8 bg-white/[0.04] px-4 py-5 text-sm text-slate-300">
<p className="font-medium text-white">No achievements unlocked yet.</p>
<p className="mt-2 text-slate-400">Keep posting, engaging, and growing your profile to start earning them.</p>
</div>
) : null}
{!loading && Array.isArray(data?.recent) && data.recent.length > 0 ? (
<div className="mt-4 space-y-3">
{data.recent.map((achievement) => (
<article key={achievement.id} className="rounded-2xl border border-white/8 bg-white/[0.04] p-4 transition hover:border-white/15 hover:bg-white/[0.06]">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-sm font-semibold text-white">{achievement.name}</p>
<p className="mt-1 text-xs text-slate-400">{achievement.description}</p>
</div>
<AchievementBadge achievement={achievement} compact />
</div>
</article>
))}
</div>
) : null}
</section>
)
}

View File

@@ -0,0 +1,140 @@
import React, { useEffect, useState } from 'react'
import LevelBadge from '../../components/xp/LevelBadge'
export default function RecommendedCreators() {
const [items, setItems] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [busyId, setBusyId] = useState(null)
useEffect(() => {
let cancelled = false
async function load() {
try {
const response = await window.axios.get('/api/dashboard/recommended-creators')
if (!cancelled) {
setItems(Array.isArray(response.data?.data) ? response.data.data : [])
setError('')
}
} catch {
if (!cancelled) {
setError('Could not load creator recommendations right now.')
}
} finally {
if (!cancelled) {
setLoading(false)
}
}
}
load()
return () => {
cancelled = true
}
}, [])
async function handleFollow(creator) {
if (!creator?.username || busyId === creator.id) {
return
}
setBusyId(creator.id)
try {
const response = await window.axios.post(`/@${creator.username}/follow`)
const isFollowing = Boolean(response.data?.following)
if (isFollowing) {
setItems((current) => current.filter((item) => item.id !== creator.id))
}
} catch {
setError('Could not update follow state right now.')
} finally {
setBusyId(null)
}
}
return (
<section className="rounded-[28px] border border-white/10 bg-[linear-gradient(180deg,rgba(15,23,42,0.92),rgba(15,23,42,0.82))] p-5 shadow-[0_24px_90px_rgba(2,8,23,0.32)]">
<div className="mb-5 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<p className="text-[11px] uppercase tracking-[0.24em] text-sky-200/70">Community</p>
<h2 className="mt-2 text-xl font-semibold text-white">Recommended Creators</h2>
<p className="mt-2 max-w-md text-sm leading-6 text-slate-300">
Strong accounts you are not following yet, selected to help you improve your feed and discover new audiences.
</p>
</div>
<a className="inline-flex items-center justify-center gap-2 rounded-full border border-sky-400/25 bg-sky-400/10 px-3 py-1.5 text-xs font-semibold uppercase tracking-wide text-sky-100 transition hover:border-sky-300/35 hover:bg-sky-400/15 sm:justify-start" href="/creators/top">
See all
<i className="fa-solid fa-arrow-right text-[10px]" />
</a>
</div>
{loading ? <p className="text-sm text-slate-400">Loading creators...</p> : null}
{error ? <p className="mb-4 text-sm text-rose-300">{error}</p> : null}
{!loading && items.length === 0 ? (
<div className="rounded-2xl border border-white/8 bg-white/5 px-5 py-6 text-sm text-slate-300">
<p className="font-medium text-white">No creator recommendations right now.</p>
<p className="mt-2 text-slate-400">Browse the full creator directory to keep expanding your network.</p>
</div>
) : null}
{!loading && items.length > 0 ? (
<div className="space-y-3">
{items.map((creator) => (
<article
key={creator.id}
className="flex flex-col gap-4 rounded-2xl border border-white/8 bg-white/[0.04] p-4 transition hover:-translate-y-0.5 hover:border-white/15 hover:bg-white/[0.06] sm:flex-row sm:items-center sm:justify-between"
>
<a href={creator.url || '#'} className="flex min-w-0 items-center gap-4">
<img
src={creator.avatar || '/images/default-avatar.png'}
alt={creator.username || creator.name || 'Creator'}
className="h-12 w-12 rounded-2xl border border-white/10 object-cover"
/>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<p className="truncate text-sm font-semibold text-white">
{creator.username ? `@${creator.username}` : creator.name}
</p>
<span className="inline-flex items-center rounded-full border border-sky-400/20 bg-sky-400/10 px-2.5 py-1 text-[10px] font-medium uppercase tracking-wide text-sky-100">
Suggested
</span>
</div>
<div className="mt-1">
<LevelBadge level={creator.level} rank={creator.rank} compact />
</div>
<div className="mt-2 flex flex-wrap items-center gap-3 text-xs text-slate-400">
<span>{Number(creator.followers_count || 0).toLocaleString()} followers</span>
<span>{Number(creator.uploads_count || 0).toLocaleString()} uploads</span>
</div>
</div>
</a>
<div className="flex w-full flex-col gap-3 sm:w-auto sm:flex-row sm:items-center sm:self-auto">
<a
href={creator.url || '#'}
className="inline-flex items-center justify-center rounded-full border border-white/10 px-3 py-2 text-xs font-semibold uppercase tracking-wide text-slate-200 transition hover:border-white/20 hover:bg-white/5"
>
View profile
</a>
<button
type="button"
onClick={() => handleFollow(creator)}
disabled={busyId === creator.id || !creator.username}
className="inline-flex items-center justify-center gap-2 rounded-full border border-emerald-400/25 bg-emerald-400/10 px-3.5 py-2 text-xs font-semibold uppercase tracking-wide text-emerald-100 transition hover:border-emerald-300/40 hover:bg-emerald-400/15 disabled:cursor-not-allowed disabled:opacity-60"
>
<i className={`fa-solid ${busyId === creator.id ? 'fa-circle-notch fa-spin' : 'fa-user-plus'} text-[10px]`} />
Follow
</button>
</div>
</article>
))}
</div>
) : null}
</section>
)
}

View File

@@ -0,0 +1,84 @@
import React, { useEffect, useState } from 'react'
import LevelBadge from '../../components/xp/LevelBadge'
export default function TopCreatorsWidget() {
const [data, setData] = useState({ items: [] })
const [loading, setLoading] = useState(true)
useEffect(() => {
let cancelled = false
async function load() {
try {
const response = await window.axios.get('/api/leaderboard/creators?period=weekly')
if (!cancelled && response.data) {
setData(response.data)
}
} finally {
if (!cancelled) {
setLoading(false)
}
}
}
load()
return () => {
cancelled = true
}
}, [])
const items = Array.isArray(data?.items) ? data.items.slice(0, 5) : []
return (
<section className="rounded-[28px] border border-white/10 bg-[linear-gradient(180deg,rgba(15,23,42,0.92),rgba(15,23,42,0.82))] p-5 shadow-[0_24px_90px_rgba(2,8,23,0.32)]">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<p className="text-[11px] uppercase tracking-[0.24em] text-sky-200/70">Leaderboard</p>
<h2 className="mt-2 text-xl font-semibold text-white">Top Creators</h2>
<p className="mt-2 max-w-xs text-sm leading-6 text-slate-300">A quick weekly pulse on who is gaining the most traction across the platform.</p>
</div>
<a href="/leaderboard?type=creators&period=weekly" className="inline-flex items-center justify-center gap-2 rounded-full border border-sky-400/25 bg-sky-400/10 px-3 py-1.5 text-xs font-semibold uppercase tracking-wide text-sky-100 transition hover:border-sky-300/35 hover:bg-sky-400/15 sm:justify-start">
View all
<i className="fa-solid fa-arrow-right text-[10px]" />
</a>
</div>
{loading ? <p className="mt-4 text-sm text-slate-400">Loading leaderboard...</p> : null}
{!loading && items.length === 0 ? (
<div className="mt-4 rounded-2xl border border-white/8 bg-white/[0.04] px-4 py-5 text-sm text-slate-300">
<p className="font-medium text-white">No creators ranked yet.</p>
<p className="mt-2 text-slate-400">Rankings will appear here once weekly creator scoring is available.</p>
</div>
) : null}
{!loading && items.length > 0 ? (
<div className="mt-4 space-y-3">
{items.map((item) => {
const entity = item.entity || {}
return (
<a key={item.rank} href={entity.url || '#'} className="flex items-center gap-3 rounded-2xl border border-white/8 bg-white/[0.04] p-4 transition hover:-translate-y-0.5 hover:border-white/15 hover:bg-white/[0.06]">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-sky-300/20 bg-sky-400/12 text-sm font-black text-white">
#{item.rank}
</div>
{entity.avatar ? <img src={entity.avatar} alt={entity.name || 'Creator'} className="h-11 w-11 rounded-xl border border-white/10 object-cover" loading="lazy" /> : null}
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-white">{entity.name}</p>
<div className="mt-1 flex items-center gap-2">
<LevelBadge level={entity.level} rank={entity.rank} compact />
</div>
</div>
<div className="shrink-0 text-right">
<span className="block text-sm font-semibold text-sky-300">{Math.round(item.score)}</span>
<span className="mt-1 block text-[11px] uppercase tracking-[0.16em] text-slate-400">Weekly score</span>
</div>
</a>
)
})}
</div>
) : null}
</section>
)
}

View File

@@ -0,0 +1,80 @@
import React, { useEffect, useState } from 'react'
import LevelBadge from '../../components/xp/LevelBadge'
export default function TrendingArtworks() {
const [items, setItems] = useState([])
const [loading, setLoading] = useState(true)
useEffect(() => {
let cancelled = false
async function load() {
try {
const response = await window.axios.get('/api/dashboard/trending-artworks')
if (!cancelled) {
setItems(Array.isArray(response.data?.data) ? response.data.data : [])
}
} finally {
if (!cancelled) {
setLoading(false)
}
}
}
load()
return () => {
cancelled = true
}
}, [])
return (
<section className="rounded-xl border border-gray-700 bg-gray-800 p-5 shadow-lg">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-xl font-semibold">Trending Artworks</h2>
<a className="text-xs text-cyan-300 hover:text-cyan-200" href="/discover/trending">
Explore more
</a>
</div>
{loading ? <p className="text-sm text-gray-400">Loading trending artworks...</p> : null}
{!loading && items.length === 0 ? (
<p className="text-sm text-gray-400">No trending artworks available.</p>
) : null}
{!loading && items.length > 0 ? (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
{items.map((item) => (
<a
key={item.id}
href={item.url}
className="group overflow-hidden rounded-xl border border-gray-700 bg-gray-900/70 transition hover:scale-[1.02] hover:border-cyan-500/40"
>
<img
src={item.thumbnail || '/images/placeholder.jpg'}
alt={item.title}
loading="lazy"
className="h-28 w-full object-cover sm:h-32"
/>
<div className="p-2">
<p className="line-clamp-1 text-sm font-semibold text-white">{item.title}</p>
{item.creator ? (
<div className="mt-1 flex items-center justify-between gap-2">
<span className="truncate text-xs text-gray-400">
{item.creator.username ? `@${item.creator.username}` : item.creator.name}
</span>
<LevelBadge level={item.creator.level} rank={item.creator.rank} compact />
</div>
) : null}
<p className="mt-1 text-xs text-gray-400">
{item.likes} likes {item.views} views
</p>
</div>
</a>
))}
</div>
) : null}
</section>
)
}

View File

@@ -0,0 +1,71 @@
import React, { useEffect, useState } from 'react'
import LevelBadge from '../../components/xp/LevelBadge'
import XPProgressBar from '../../components/xp/XPProgressBar'
export default function XPProgressWidget({ initialLevel = 1, initialRank = 'Newbie' }) {
const [data, setData] = useState({
xp: 0,
level: initialLevel,
rank: initialRank,
current_level_xp: 0,
next_level_xp: 100,
progress_percent: 0,
max_level: false,
})
const [loading, setLoading] = useState(true)
useEffect(() => {
let cancelled = false
async function load() {
try {
const response = await window.axios.get('/api/user/xp')
if (!cancelled && response.data) {
setData((current) => ({ ...current, ...response.data }))
}
} finally {
if (!cancelled) {
setLoading(false)
}
}
}
load()
return () => {
cancelled = true
}
}, [])
return (
<section className="rounded-[28px] border border-white/10 bg-[linear-gradient(180deg,rgba(15,23,42,0.92),rgba(15,23,42,0.82))] p-5 shadow-[0_24px_90px_rgba(2,8,23,0.32)]">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<p className="text-[11px] uppercase tracking-[0.24em] text-sky-200/70">Progression</p>
<h2 className="mt-2 text-xl font-semibold text-white">XP Progress</h2>
<p className="mt-2 max-w-xs text-sm leading-6 text-slate-300">Track how close you are to the next level and keep your creator momentum visible.</p>
</div>
<div className="sm:self-start">
<LevelBadge level={data.level} rank={data.rank} compact />
</div>
</div>
<div className="mt-5 rounded-2xl border border-white/8 bg-white/[0.04] p-4">
<XPProgressBar
xp={data.xp}
currentLevelXp={data.current_level_xp}
nextLevelXp={data.next_level_xp}
progressPercent={data.progress_percent}
maxLevel={data.max_level}
/>
<div className="mt-4 flex items-center justify-between gap-3 text-xs uppercase tracking-[0.16em] text-slate-400">
<span>Total XP</span>
<span className="font-semibold text-sky-100">{Number(data.xp || 0).toLocaleString()}</span>
</div>
</div>
{loading ? <p className="mt-4 text-xs text-slate-400">Syncing your latest XP...</p> : null}
</section>
)
}