Files
SkinbaseNova/resources/js/dashboard/DashboardPage.jsx
2026-03-20 21:17:26 +01:00

1169 lines
45 KiB
JavaScript

import React, { useEffect, useRef, useState } from 'react'
import QuickActions from './components/QuickActions'
import ActivityFeed from './components/ActivityFeed'
import CreatorAnalytics from './components/CreatorAnalytics'
import TrendingArtworks from './components/TrendingArtworks'
import RecommendedCreators from './components/RecommendedCreators'
import LevelBadge from '../components/xp/LevelBadge'
import RecentAchievements from './components/RecentAchievements'
import TopCreatorsWidget from './components/TopCreatorsWidget'
import XPProgressWidget from './components/XPProgressWidget'
const RECENT_DASHBOARD_VISITS_KEY = 'skinbase.dashboard.recent-visits'
const MAX_PINNED_DASHBOARD_SPACES = 8
const routeDirectory = {
'/dashboard': {
label: 'Dashboard Home',
icon: 'fa-solid fa-house',
description: 'Return to the main dashboard hub and overview panels.',
},
'/dashboard/profile': {
label: 'Profile Settings',
icon: 'fa-solid fa-user-pen',
description: 'Update your account presentation and profile settings.',
},
'/dashboard/notifications': {
label: 'Notifications',
icon: 'fa-solid fa-bell',
description: 'Review unread alerts and recent system updates.',
},
'/dashboard/comments/received': {
label: 'Received Comments',
icon: 'fa-solid fa-inbox',
description: 'Catch up on feedback left on your artworks.',
},
'/dashboard/followers': {
label: 'Followers',
icon: 'fa-solid fa-user-group',
description: 'See who is following your work.',
},
'/dashboard/following': {
label: 'Following',
icon: 'fa-solid fa-users-viewfinder',
description: 'Jump back into the creators you follow.',
},
'/dashboard/favorites': {
label: 'Favorites',
icon: 'fa-solid fa-bookmark',
description: 'Revisit the work you saved.',
},
'/dashboard/artworks': {
label: 'My Artworks',
icon: 'fa-solid fa-layer-group',
description: 'Manage your uploaded portfolio pieces.',
},
'/dashboard/gallery': {
label: 'Gallery',
icon: 'fa-solid fa-images',
description: 'Review the presentation of your gallery.',
},
'/dashboard/awards': {
label: 'Awards',
icon: 'fa-solid fa-trophy',
description: 'Track recognition and milestones.',
},
'/creator/stories': {
label: 'Story Dashboard',
icon: 'fa-solid fa-newspaper',
description: 'Review creator stories and drafts.',
},
'/studio': {
label: 'Studio',
icon: 'fa-solid fa-compass-drafting',
description: 'Open the broader creator workspace.',
},
}
function loadRecentDashboardVisits() {
if (typeof window === 'undefined') {
return []
}
try {
const raw = window.localStorage.getItem(RECENT_DASHBOARD_VISITS_KEY)
if (!raw) {
return []
}
const parsed = JSON.parse(raw)
return Array.isArray(parsed) ? parsed : []
} catch {
return []
}
}
function persistRecentDashboardVisits(items) {
if (typeof window === 'undefined') {
return []
}
window.localStorage.setItem(RECENT_DASHBOARD_VISITS_KEY, JSON.stringify(items))
return items
}
function normalizeRecentVisit(item) {
if (!item || typeof item !== 'object' || !item.href) {
return null
}
return {
href: item.href,
label: item.label || routeDirectory[item.href]?.label || 'Dashboard Space',
pinned: Boolean(item.pinned),
lastVisitedAt: item.lastVisitedAt || null,
}
}
function prepareRecentVisits(items) {
return items
.map(normalizeRecentVisit)
.filter(Boolean)
.sort((left, right) => {
if (left.pinned !== right.pinned) {
return left.pinned ? -1 : 1
}
const leftTime = left.lastVisitedAt ? new Date(left.lastVisitedAt).getTime() : 0
const rightTime = right.lastVisitedAt ? new Date(right.lastVisitedAt).getTime() : 0
return rightTime - leftTime
})
}
function saveRecentDashboardVisit(entry) {
if (typeof window === 'undefined') {
return []
}
const normalizedEntry = normalizeRecentVisit(entry)
const existingMatch = loadRecentDashboardVisits().map(normalizeRecentVisit).find((item) => item && item.href === normalizedEntry.href)
const existing = loadRecentDashboardVisits().map(normalizeRecentVisit).filter((item) => item && item.href !== normalizedEntry.href)
const next = [
{
...normalizedEntry,
pinned: existingMatch?.pinned ?? normalizedEntry.pinned ?? false,
lastVisitedAt: new Date().toISOString(),
},
...existing,
].slice(0, 8)
return persistRecentDashboardVisits(next)
}
function upsertPinnedDashboardVisit(href, label, pinned) {
if (typeof window === 'undefined') {
return []
}
const existing = loadRecentDashboardVisits().map(normalizeRecentVisit).filter(Boolean)
const match = existing.find((item) => item.href === href)
const next = match
? existing.map((item) =>
item.href === href
? {
...item,
label: item.label || label,
pinned,
lastVisitedAt: item.lastVisitedAt || new Date().toISOString(),
}
: item
)
: [
{
href,
label,
pinned,
lastVisitedAt: new Date().toISOString(),
},
...existing,
].slice(0, 8)
return persistRecentDashboardVisits(next)
}
function buildDashboardVisitEntries(items) {
return items
.map((item) => ({
...item,
...(routeDirectory[item.href] || {}),
}))
.filter((item) => item.href && routeDirectory[item.href])
}
function persistPinnedDashboardSpaces(hrefs) {
if (typeof window === 'undefined' || !window.axios) {
return Promise.resolve(null)
}
return window.axios
.put('/api/dashboard/preferences/shortcuts', {
pinned_spaces: hrefs.slice(0, MAX_PINNED_DASHBOARD_SPACES),
})
.catch(() => null)
}
function syncRecentVisitsWithPinnedOrder(items, pinnedSpaces = []) {
const pinnedSet = new Set(pinnedSpaces)
const merged = new Map()
items
.map(normalizeRecentVisit)
.filter(Boolean)
.forEach((item) => {
merged.set(item.href, {
...item,
pinned: pinnedSet.has(item.href),
})
})
pinnedSpaces.forEach((href) => {
const route = routeDirectory[href]
if (!route) {
return
}
const existing = merged.get(href)
merged.set(href, {
href,
label: existing?.label || route.label,
pinned: true,
lastVisitedAt: existing?.lastVisitedAt || null,
})
})
return Array.from(merged.values())
}
function orderDashboardVisits(items, pinnedSpaces = []) {
const synced = syncRecentVisitsWithPinnedOrder(items, pinnedSpaces)
const byHref = new Map(synced.map((item) => [item.href, item]))
const pinned = pinnedSpaces.map((href) => byHref.get(href)).filter(Boolean)
const pinnedSet = new Set(pinnedSpaces)
const unpinned = synced
.filter((item) => !pinnedSet.has(item.href))
.sort((left, right) => {
const leftTime = left.lastVisitedAt ? new Date(left.lastVisitedAt).getTime() : 0
const rightTime = right.lastVisitedAt ? new Date(right.lastVisitedAt).getTime() : 0
return rightTime - leftTime
})
return [...pinned, ...unpinned].slice(0, 8)
}
function sanitizePinnedDashboardSpaces(hrefs = []) {
return hrefs
.filter((href, index) => routeDirectory[href] && href !== '/dashboard' && hrefs.indexOf(href) === index)
.slice(0, MAX_PINNED_DASHBOARD_SPACES)
}
function movePinnedDashboardSpace(hrefs, href, direction) {
const currentIndex = hrefs.indexOf(href)
if (currentIndex === -1) {
return hrefs
}
const targetIndex = direction === 'left' ? currentIndex - 1 : currentIndex + 1
if (targetIndex < 0 || targetIndex >= hrefs.length) {
return hrefs
}
const next = [...hrefs]
const [item] = next.splice(currentIndex, 1)
next.splice(targetIndex, 0, item)
return next
}
function formatRelativeTime(value) {
if (!value) {
return 'just now'
}
const timestamp = new Date(value).getTime()
if (Number.isNaN(timestamp)) {
return 'just now'
}
const deltaSeconds = Math.max(0, Math.round((Date.now() - timestamp) / 1000))
if (deltaSeconds < 60) {
return 'just now'
}
const deltaMinutes = Math.round(deltaSeconds / 60)
if (deltaMinutes < 60) {
return `${deltaMinutes} min ago`
}
const deltaHours = Math.round(deltaMinutes / 60)
if (deltaHours < 24) {
return `${deltaHours} hr ago`
}
const deltaDays = Math.round(deltaHours / 24)
return `${deltaDays} day${deltaDays === 1 ? '' : 's'} ago`
}
function previewLabelForRoute(href, stats) {
switch (href) {
case '/dashboard/notifications':
return `${stats.notifications} unread alert${stats.notifications === 1 ? '' : 's'}`
case '/dashboard/comments/received':
return `${stats.receivedComments} comment${stats.receivedComments === 1 ? '' : 's'} waiting`
case '/dashboard/followers':
return `${stats.followers} follower${stats.followers === 1 ? '' : 's'}`
case '/dashboard/following':
return `${stats.following} account${stats.following === 1 ? '' : 's'} followed`
case '/dashboard/favorites':
return `${stats.favorites} saved favorite${stats.favorites === 1 ? '' : 's'}`
case '/dashboard/artworks':
return `${stats.artworks} artwork${stats.artworks === 1 ? '' : 's'} in portfolio`
case '/dashboard/gallery':
return `${stats.artworks} gallery item${stats.artworks === 1 ? '' : 's'}`
case '/creator/stories':
return `${stats.stories} stor${stats.stories === 1 ? 'y' : 'ies'} published`
default:
return null
}
}
function HeroStat({ label, value, tone = 'sky' }) {
const tones = {
sky: 'border-sky-400/20 bg-sky-400/10 text-sky-100',
amber: 'border-amber-400/20 bg-amber-400/10 text-amber-100',
emerald: 'border-emerald-400/20 bg-emerald-400/10 text-emerald-100',
slate: 'border-white/10 bg-white/5 text-white',
}
return (
<div className={`rounded-2xl border p-4 shadow-lg backdrop-blur ${tones[tone] || tones.slate}`}>
<p className="text-[11px] uppercase tracking-[0.22em] text-white/60">{label}</p>
<p className="mt-3 text-2xl font-semibold text-white">{value}</p>
</div>
)
}
function SuggestionChip({ href, label, icon, highlight = false, onNavigate }) {
return (
<a
href={href}
onClick={() => onNavigate?.(href, label)}
className={[
'inline-flex items-center gap-2 rounded-full border px-4 py-2 text-sm font-medium transition',
highlight
? 'border-sky-300/30 bg-sky-400/15 text-sky-100 hover:border-sky-200/50 hover:bg-sky-400/20'
: 'border-white/10 bg-white/5 text-slate-200 hover:border-white/20 hover:bg-white/10',
].join(' ')}
>
<i className={icon} aria-hidden="true" />
<span>{label}</span>
</a>
)
}
function OverviewMetric({ label, value, href, icon, accent = 'sky', onNavigate }) {
const accents = {
sky: 'text-sky-200 border-sky-300/20 bg-sky-400/10',
amber: 'text-amber-200 border-amber-300/20 bg-amber-400/10',
emerald: 'text-emerald-200 border-emerald-300/20 bg-emerald-400/10',
rose: 'text-rose-200 border-rose-300/20 bg-rose-400/10',
slate: 'text-slate-200 border-white/10 bg-white/5',
}
return (
<a
href={href}
onClick={() => onNavigate?.(href, label)}
className="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]"
>
<div className="flex items-center justify-between gap-3">
<span className={`inline-flex h-11 w-11 items-center justify-center rounded-2xl border ${accents[accent] || accents.slate}`}>
<i className={icon} aria-hidden="true" />
</span>
<span className="text-2xl font-semibold text-white">{value}</span>
</div>
<p className="mt-4 text-xs uppercase tracking-[0.18em] text-slate-400">{label}</p>
</a>
)
}
function SectionLinkCard({ item, onNavigate, onTogglePin, isPinned = false }) {
return (
<article className="group rounded-2xl border border-white/10 bg-[#0b1826]/80 p-4 shadow-lg shadow-black/20 transition hover:-translate-y-0.5 hover:border-sky-300/35 hover:bg-[#102033]">
<div className="flex items-start justify-between gap-3">
<span className="inline-flex h-11 w-11 items-center justify-center rounded-2xl border border-white/10 bg-white/5 text-lg text-sky-200">
<i className={item.icon} aria-hidden="true" />
</span>
<div className="flex items-center gap-2">
{item.badge ? (
<span className="rounded-full border border-sky-300/20 bg-sky-400/10 px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-sky-100">
{item.badge}
</span>
) : null}
<button
type="button"
onClick={() => onTogglePin?.(item.href, item.label)}
className={[
'inline-flex h-9 w-9 items-center justify-center rounded-full border transition',
isPinned
? 'border-amber-300/30 bg-amber-400/10 text-amber-200 hover:bg-amber-400/15'
: 'border-white/10 bg-white/5 text-slate-300 hover:border-white/20 hover:bg-white/10',
].join(' ')}
aria-label={isPinned ? `Unpin ${item.label}` : `Pin ${item.label}`}
title={isPinned ? 'Unpin this dashboard space' : 'Pin this dashboard space'}
>
<i className="fa-solid fa-thumbtack" aria-hidden="true" />
</button>
</div>
</div>
<div className="mt-4">
<h3 className="text-base font-semibold text-white transition group-hover:text-sky-100">{item.label}</h3>
<p className="mt-2 text-sm leading-6 text-slate-300">{item.description}</p>
{item.preview ? <p className="mt-3 text-xs font-semibold uppercase tracking-[0.14em] text-sky-200/80">{item.preview}</p> : null}
</div>
<div className="mt-4 flex items-center justify-between gap-3 text-xs uppercase tracking-[0.18em] text-slate-400">
<span>{item.meta}</span>
<a href={item.href} onClick={() => onNavigate?.(item.href, item.label)} className="text-sky-200 transition group-hover:translate-x-0.5">
Open
</a>
</div>
</article>
)
}
function DashboardSection({ eyebrow, title, description, items, onNavigate, onTogglePin, pinnedHrefs }) {
return (
<section className="rounded-[28px] border border-white/10 bg-[#08111c]/90 p-5 shadow-2xl shadow-black/20 sm:p-6">
<div className="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">{eyebrow}</p>
<h2 className="mt-2 text-xl font-semibold text-white">{title}</h2>
</div>
<p className="max-w-xl text-sm leading-6 text-slate-300">{description}</p>
</div>
<div className="mt-5 grid grid-cols-1 gap-4 lg:grid-cols-3">
{items.map((item) => (
<SectionLinkCard
key={item.href}
item={item}
onNavigate={onNavigate}
onTogglePin={onTogglePin}
isPinned={pinnedHrefs.has(item.href)}
/>
))}
</div>
</section>
)
}
function GuidedStep({ step, total, title, description, href, icon, emphasis = false, onNavigate }) {
return (
<a
href={href}
onClick={() => onNavigate?.(href, title)}
className={[
'group rounded-2xl border p-4 shadow-lg shadow-black/20 transition hover:-translate-y-0.5',
emphasis
? 'border-sky-300/30 bg-sky-400/10 hover:border-sky-200/50 hover:bg-sky-400/15'
: 'border-white/10 bg-[#0b1826]/85 hover:border-white/20 hover:bg-[#102033]',
].join(' ')}
>
<div className="flex items-start gap-4">
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-2xl border border-white/10 bg-white/5 text-white">
<span className="text-sm font-semibold">{step}/{total}</span>
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<i className={`${icon} text-sky-200`} aria-hidden="true" />
<h3 className="text-base font-semibold text-white">{title}</h3>
</div>
<p className="mt-2 text-sm leading-6 text-slate-300">{description}</p>
<span className="mt-4 inline-flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.16em] text-sky-100">
Open
<i className="fa-solid fa-arrow-right" aria-hidden="true" />
</span>
</div>
</div>
</a>
)
}
function DashboardGuidance({ title, description, steps, tone = 'sky', onNavigate }) {
const tones = {
sky: 'border-sky-300/20 bg-[linear-gradient(135deg,_rgba(56,189,248,0.12),_rgba(8,17,28,0.92)_45%,_rgba(8,17,28,0.96))]',
amber: 'border-amber-300/20 bg-[linear-gradient(135deg,_rgba(245,158,11,0.12),_rgba(8,17,28,0.92)_45%,_rgba(8,17,28,0.96))]',
}
return (
<section className={`rounded-[28px] border p-5 shadow-2xl shadow-black/20 sm:p-6 ${tones[tone] || tones.sky}`}>
<div className="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">Guided Path</p>
<h2 className="mt-2 text-xl font-semibold text-white">{title}</h2>
</div>
<p className="max-w-xl text-sm leading-6 text-slate-300">{description}</p>
</div>
<div className="mt-5 grid grid-cols-1 gap-4 lg:grid-cols-3">
{steps.map((item, index) => (
<GuidedStep key={item.href} step={index + 1} total={steps.length} {...item} onNavigate={onNavigate} />
))}
</div>
</section>
)
}
function RecentVisitCard({ item, onNavigate, onTogglePin }) {
return (
<article className="group rounded-2xl border border-white/10 bg-[#0b1826]/80 p-4 shadow-lg shadow-black/20 transition hover:-translate-y-0.5 hover:border-sky-300/35 hover:bg-[#102033]">
<div className="flex items-start gap-3">
<span className="inline-flex h-11 w-11 shrink-0 items-center justify-center rounded-2xl border border-white/10 bg-white/5 text-sky-200">
<i className={item.icon} aria-hidden="true" />
</span>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-3">
<h3 className="text-sm font-semibold text-white">{item.label}</h3>
<div className="flex items-center gap-2">
<span className="text-[11px] uppercase tracking-[0.16em] text-slate-500">{item.pinned ? 'Pinned' : 'Recent'}</span>
<button
type="button"
onClick={() => onTogglePin?.(item.href)}
className={[
'inline-flex h-8 w-8 items-center justify-center rounded-full border transition',
item.pinned
? 'border-amber-300/30 bg-amber-400/10 text-amber-200 hover:bg-amber-400/15'
: 'border-white/10 bg-white/5 text-slate-300 hover:border-white/20 hover:bg-white/10',
].join(' ')}
aria-label={item.pinned ? `Unpin ${item.label}` : `Pin ${item.label}`}
title={item.pinned ? 'Unpin this dashboard space' : 'Pin this dashboard space'}
>
<i className="fa-solid fa-thumbtack" aria-hidden="true" />
</button>
</div>
</div>
<p className="mt-2 text-sm leading-6 text-slate-300">{item.description}</p>
{item.preview ? <p className="mt-3 text-xs font-semibold uppercase tracking-[0.14em] text-sky-200/80">{item.preview}</p> : null}
<div className="mt-4 flex items-center justify-between gap-3">
<span className="text-xs text-slate-400">Last opened {formatRelativeTime(item.lastVisitedAt)}</span>
<a
href={item.href}
onClick={() => onNavigate?.(item.href, item.label)}
className="inline-flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.16em] text-sky-100"
>
Open
<i className="fa-solid fa-arrow-right" aria-hidden="true" />
</a>
</div>
</div>
</div>
</article>
)
}
function RecentVisitsSection({ items, onNavigate, onTogglePin }) {
if (items.length === 0) {
return null
}
return (
<section className="rounded-[28px] border border-white/10 bg-[#08111c]/90 p-5 shadow-2xl shadow-black/20 sm:p-6">
<div className="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">Continue</p>
<h2 className="mt-2 text-xl font-semibold text-white">Recently visited dashboard spaces</h2>
</div>
<p className="max-w-xl text-sm leading-6 text-slate-300">
Quick return links for the dashboard pages you used most recently. Pinned spaces stay at the top.
</p>
</div>
<div className="mt-5 grid grid-cols-1 gap-4 lg:grid-cols-2">
{items.map((item) => (
<RecentVisitCard key={item.href} item={item} onNavigate={onNavigate} onTogglePin={onTogglePin} />
))}
</div>
</section>
)
}
function PinnedSpacesStrip({ items, onNavigate, onTogglePin, onMove }) {
if (items.length === 0) {
return null
}
return (
<section className="mt-5 rounded-[26px] border border-white/10 bg-white/5 p-4 backdrop-blur sm:p-5">
<div className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
<div>
<p className="text-[11px] uppercase tracking-[0.22em] text-sky-200/80">Pinned Spaces</p>
<h2 className="mt-1 text-lg font-semibold text-white">Your fastest dashboard shortcuts</h2>
</div>
<p className="max-w-xl text-sm leading-6 text-slate-300">
These stay visible near the top so you can jump straight into your most important dashboard areas.
</p>
</div>
<div className="mt-4 grid grid-cols-1 gap-3 lg:grid-cols-3">
{items.map((item, index) => (
<article key={item.href} className="rounded-2xl border border-white/10 bg-[#0b1826]/75 p-4 shadow-lg shadow-black/20">
<div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 items-start gap-3">
<span className="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-2xl border border-white/10 bg-white/5 text-sky-200">
<i className={item.icon} aria-hidden="true" />
</span>
<div className="min-w-0">
<h3 className="text-sm font-semibold text-white">{item.label}</h3>
<p className="mt-1 text-sm leading-6 text-slate-300">{item.description}</p>
</div>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => onMove?.(item.href, 'left')}
disabled={index === 0}
className="inline-flex h-8 w-8 items-center justify-center rounded-full border border-white/10 bg-white/5 text-slate-200 transition hover:border-white/20 hover:bg-white/10 disabled:cursor-not-allowed disabled:opacity-40"
aria-label={`Move ${item.label} earlier`}
title="Move this shortcut earlier"
>
<i className="fa-solid fa-arrow-left" aria-hidden="true" />
</button>
<button
type="button"
onClick={() => onMove?.(item.href, 'right')}
disabled={index === items.length - 1}
className="inline-flex h-8 w-8 items-center justify-center rounded-full border border-white/10 bg-white/5 text-slate-200 transition hover:border-white/20 hover:bg-white/10 disabled:cursor-not-allowed disabled:opacity-40"
aria-label={`Move ${item.label} later`}
title="Move this shortcut later"
>
<i className="fa-solid fa-arrow-right" aria-hidden="true" />
</button>
<button
type="button"
onClick={() => onTogglePin?.(item.href)}
className="inline-flex h-8 w-8 items-center justify-center rounded-full border border-amber-300/30 bg-amber-400/10 text-amber-200 hover:bg-amber-400/15"
aria-label={`Unpin ${item.label}`}
title="Unpin this dashboard space"
>
<i className="fa-solid fa-thumbtack" aria-hidden="true" />
</button>
</div>
</div>
<div className="mt-4 flex items-center justify-between gap-3">
<span className="text-xs font-semibold uppercase tracking-[0.14em] text-sky-200/80">
{item.preview || 'Pinned shortcut'}
</span>
<a
href={item.href}
onClick={() => onNavigate?.(item.href, item.label)}
className="inline-flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.16em] text-sky-100"
>
Open
<i className="fa-solid fa-arrow-right" aria-hidden="true" />
</a>
</div>
</article>
))}
</div>
</section>
)
}
function ShortcutSaveToast({ notice }) {
if (!notice) {
return null
}
const tones = {
info: 'border-sky-300/30 bg-sky-400/12 text-sky-50',
success: 'border-emerald-300/30 bg-emerald-400/12 text-emerald-50',
error: 'border-rose-300/30 bg-rose-400/12 text-rose-50',
}
return (
<div className="pointer-events-none fixed bottom-5 right-5 z-50 max-w-sm" aria-live="polite" aria-atomic="true">
<div
role={notice.tone === 'error' ? 'alert' : 'status'}
className={[
'rounded-2xl border px-4 py-3 shadow-2xl shadow-black/30 backdrop-blur',
tones[notice.tone] || tones.success,
].join(' ')}
>
<p className="text-[11px] uppercase tracking-[0.2em] text-white/65">Dashboard Shortcuts</p>
<p className="mt-1 text-sm font-medium">{notice.message}</p>
</div>
</div>
)
}
export default function DashboardPage({ username, isCreator, level, rank, receivedCommentsCount, overview = {}, preferences = {} }) {
const persistedPinnedSpaces = sanitizePinnedDashboardSpaces(Array.isArray(preferences.pinned_spaces) ? preferences.pinned_spaces : [])
const persistedPinnedSpacesKey = persistedPinnedSpaces.join('|')
const overviewStats = {
artworks: Number(overview.artworks || 0),
stories: Number(overview.stories || 0),
followers: Number(overview.followers || 0),
following: Number(overview.following || 0),
favorites: Number(overview.favorites || 0),
notifications: Number(overview.notifications || 0),
receivedComments: Number(overview.received_comments || receivedCommentsCount || 0),
}
const [pinnedOrder, setPinnedOrder] = useState(persistedPinnedSpaces)
const [recentVisits, setRecentVisits] = useState([])
const [shortcutNotice, setShortcutNotice] = useState(null)
const latestShortcutSaveRequestRef = useRef(0)
useEffect(() => {
const nextPinnedOrder = sanitizePinnedDashboardSpaces(persistedPinnedSpaces)
const visits = orderDashboardVisits(loadRecentDashboardVisits(), nextPinnedOrder).filter(
(item) => item && item.href && item.href !== '/dashboard'
)
persistRecentDashboardVisits(visits)
setPinnedOrder(nextPinnedOrder)
setRecentVisits(buildDashboardVisitEntries(visits))
saveRecentDashboardVisit({ href: '/dashboard', label: 'Dashboard Home' })
}, [persistedPinnedSpacesKey])
useEffect(() => {
if (!shortcutNotice) {
return undefined
}
const timeoutId = window.setTimeout(() => {
setShortcutNotice(null)
}, 2400)
return () => {
window.clearTimeout(timeoutId)
}
}, [shortcutNotice])
function applyPinnedOrder(nextPinnedOrder, sourceVisits) {
const sanitizedPinnedOrder = sanitizePinnedDashboardSpaces(nextPinnedOrder)
const visits = orderDashboardVisits(sourceVisits, sanitizedPinnedOrder).filter((item) => item.href !== '/dashboard')
const requestId = latestShortcutSaveRequestRef.current + 1
persistRecentDashboardVisits(visits)
setPinnedOrder(sanitizedPinnedOrder)
setRecentVisits(buildDashboardVisitEntries(visits))
latestShortcutSaveRequestRef.current = requestId
setShortcutNotice({
tone: 'info',
message: 'Saving dashboard shortcuts...',
})
persistPinnedDashboardSpaces(sanitizedPinnedOrder).then((saved) => {
if (latestShortcutSaveRequestRef.current !== requestId) {
return
}
setShortcutNotice(
saved
? {
tone: 'success',
message: sanitizedPinnedOrder.length === 0 ? 'Pinned shortcuts cleared.' : 'Dashboard shortcuts saved.',
}
: {
tone: 'error',
message: 'Could not save dashboard shortcuts. Refresh and try again.',
}
)
})
}
function handleNavigate(href, label) {
const next = orderDashboardVisits(saveRecentDashboardVisit({ href, label }), pinnedOrder).filter((item) => item.href !== '/dashboard')
persistRecentDashboardVisits(next)
setRecentVisits(buildDashboardVisitEntries(next))
}
function handleTogglePin(href) {
const nextPinnedOrder = pinnedOrder.includes(href)
? pinnedOrder.filter((item) => item !== href)
: [...pinnedOrder, href]
applyPinnedOrder(nextPinnedOrder, loadRecentDashboardVisits())
}
function handleTogglePinFromCard(href, label) {
const isPinned = pinnedOrder.includes(href)
const sourceVisits = upsertPinnedDashboardVisit(href, label, false)
const nextPinnedOrder = isPinned
? pinnedOrder.filter((item) => item !== href)
: [...pinnedOrder, href]
applyPinnedOrder(nextPinnedOrder, sourceVisits)
}
function handleMovePinnedSpace(href, direction) {
const nextPinnedOrder = movePinnedDashboardSpace(pinnedOrder, href, direction)
if (nextPinnedOrder === pinnedOrder) {
return
}
applyPinnedOrder(nextPinnedOrder, loadRecentDashboardVisits())
}
const pinnedHrefs = new Set(pinnedOrder)
const pinnedSpaces = recentVisits
.filter((item) => item.pinned)
.map((item) => ({
...item,
preview: previewLabelForRoute(item.href, overviewStats),
}))
const dashboardSections = [
{
eyebrow: 'Account Hub',
title: 'Profile, alerts, and feedback',
description: 'Keep your public identity sharp and stay on top of everything that needs a response.',
items: [
{
label: 'Profile Settings',
href: '/dashboard/profile',
icon: 'fa-solid fa-user-pen',
description: 'Update your bio, location, links, avatar, and account preferences.',
meta: 'Dashboard Profile',
preview: 'Bio, avatar, links, and account details',
},
{
label: 'Notifications',
href: '/dashboard/notifications',
icon: 'fa-solid fa-bell',
description: 'Review mentions, system updates, and activity that needs your attention.',
meta: 'Dashboard Notifications',
badge: overviewStats.notifications > 0 ? `${overviewStats.notifications} new` : null,
preview: previewLabelForRoute('/dashboard/notifications', overviewStats),
},
{
label: 'Received Comments',
href: '/dashboard/comments/received',
icon: 'fa-solid fa-inbox',
description: 'Catch up on feedback left on your artworks and clear your inbox quickly.',
meta: 'Dashboard Comments',
badge: overviewStats.receivedComments > 0 ? `${overviewStats.receivedComments} new` : 'Clear',
preview: previewLabelForRoute('/dashboard/comments/received', overviewStats),
},
],
},
{
eyebrow: 'Community',
title: 'Followers, following, and saved work',
description: 'Move between your people-focused spaces without digging through the navigation.',
items: [
{
label: 'Followers',
href: '/dashboard/followers',
icon: 'fa-solid fa-user-group',
description: 'See who joined your audience and discover the people supporting your work.',
meta: 'Dashboard Followers',
badge: overviewStats.followers > 0 ? String(overviewStats.followers) : null,
preview: previewLabelForRoute('/dashboard/followers', overviewStats),
},
{
label: 'Following',
href: '/dashboard/following',
icon: 'fa-solid fa-users-viewfinder',
description: 'Jump back into creators, artists, and brands you already follow.',
meta: 'Dashboard Following',
badge: overviewStats.following > 0 ? String(overviewStats.following) : null,
preview: previewLabelForRoute('/dashboard/following', overviewStats),
},
{
label: 'Favorites',
href: '/dashboard/favorites',
icon: 'fa-solid fa-bookmark',
description: 'Revisit the artworks you saved so inspiration is always one click away.',
meta: 'Dashboard Favorites',
badge: overviewStats.favorites > 0 ? String(overviewStats.favorites) : null,
preview: previewLabelForRoute('/dashboard/favorites', overviewStats),
},
],
},
{
eyebrow: 'Creator Space',
title: 'Portfolio management and recognition',
description: 'Everything tied to your published work, gallery presentation, and achievements lives here.',
items: [
{
label: 'My Artworks',
href: '/dashboard/artworks',
icon: 'fa-solid fa-layer-group',
description: 'Manage your uploaded artworks, edit details, and keep your portfolio organized.',
meta: 'Dashboard Artworks',
badge: overviewStats.artworks > 0 ? String(overviewStats.artworks) : null,
preview: previewLabelForRoute('/dashboard/artworks', overviewStats),
},
{
label: 'Gallery',
href: '/dashboard/gallery',
icon: 'fa-solid fa-images',
description: 'Review your gallery layout and browse your work the way visitors see it.',
meta: 'Dashboard Gallery',
badge: overviewStats.artworks > 0 ? `${overviewStats.artworks} items` : null,
preview: previewLabelForRoute('/dashboard/gallery', overviewStats),
},
{
label: 'Awards',
href: '/dashboard/awards',
icon: 'fa-solid fa-trophy',
description: 'Track badges, awards, and milestones that showcase your growth on Skinbase.',
meta: 'Dashboard Awards',
},
],
},
]
const dashboardLinksCount = dashboardSections.reduce((total, section) => total + section.items.length, 0)
const isBrandNewMember =
overviewStats.artworks === 0 &&
overviewStats.stories === 0 &&
overviewStats.followers === 0 &&
overviewStats.following === 0 &&
overviewStats.favorites === 0 &&
overviewStats.notifications === 0 &&
overviewStats.receivedComments === 0
const needsCreatorMomentum = !isBrandNewMember && isCreator && overviewStats.receivedComments === 0 && overviewStats.followers < 3
const overviewCards = [
{
label: 'Unread notifications',
value: overviewStats.notifications,
href: '/dashboard/notifications',
icon: 'fa-solid fa-bell',
accent: overviewStats.notifications > 0 ? 'amber' : 'slate',
},
{
label: 'Followers',
value: overviewStats.followers,
href: '/dashboard/followers',
icon: 'fa-solid fa-user-group',
accent: 'sky',
},
{
label: 'Following',
value: overviewStats.following,
href: '/dashboard/following',
icon: 'fa-solid fa-users',
accent: 'slate',
},
{
label: 'Saved favorites',
value: overviewStats.favorites,
href: '/dashboard/favorites',
icon: 'fa-solid fa-bookmark',
accent: 'rose',
},
{
label: 'Artworks',
value: overviewStats.artworks,
href: '/dashboard/artworks',
icon: 'fa-solid fa-layer-group',
accent: 'emerald',
},
{
label: 'Stories',
value: overviewStats.stories,
href: isCreator ? '/creator/stories' : '/creator/stories/create',
icon: 'fa-solid fa-pen-nib',
accent: 'amber',
},
]
const suggestions = [
{
label: overviewStats.receivedComments > 0 ? 'Review new feedback' : 'Open comment inbox',
href: '/dashboard/comments/received',
icon: 'fa-solid fa-comments',
highlight: overviewStats.receivedComments > 0,
},
{
label: overviewStats.notifications > 0 ? 'Clear your alerts' : 'Refine your profile',
href: overviewStats.notifications > 0 ? '/dashboard/notifications' : '/dashboard/profile',
icon: overviewStats.notifications > 0 ? 'fa-solid fa-bell' : 'fa-solid fa-user-gear',
},
{
label: overviewStats.followers > 0 ? 'Check your audience' : 'Find creators to follow',
href: overviewStats.followers > 0 ? '/dashboard/followers' : '/creators/top',
icon: overviewStats.followers > 0 ? 'fa-solid fa-user-group' : 'fa-solid fa-compass',
},
isCreator
? {
label: 'Manage artworks',
href: '/dashboard/artworks',
icon: 'fa-solid fa-pen-ruler',
}
: {
label: 'Start your gallery',
href: '/upload',
icon: 'fa-solid fa-cloud-arrow-up',
},
]
const guidance = isBrandNewMember
? {
title: 'Build your account in three clean steps',
description: 'New members need a little direction more than they need dense analytics. These actions create a better profile, feed, and portfolio foundation quickly.',
tone: 'sky',
steps: [
{
title: 'Finish your profile',
description: 'Add a stronger bio, links, location, and avatar so people have a reason to follow you back.',
href: '/dashboard/profile',
icon: 'fa-solid fa-user-pen',
emphasis: true,
},
{
title: 'Follow great creators',
description: 'Shape your taste graph and make the rest of the dashboard more useful by following a few standout accounts.',
href: '/creators/top',
icon: 'fa-solid fa-user-group',
},
{
title: 'Upload your first artwork',
description: 'Unlock creator-focused dashboard tools, comment feedback, and gallery visibility.',
href: '/upload',
icon: 'fa-solid fa-cloud-arrow-up',
},
],
}
: needsCreatorMomentum
? {
title: 'Give your creator profile more momentum',
description: 'Your dashboard is set up, but a few focused moves will make it feel more alive: publish more, expand reach, and create something worth revisiting.',
tone: 'amber',
steps: [
{
title: 'Polish your gallery',
description: 'Tighten artwork titles, thumbnails, and presentation so the work lands better for new visitors.',
href: '/dashboard/gallery',
icon: 'fa-solid fa-images',
emphasis: true,
},
{
title: 'Publish a creator story',
description: 'Stories give followers more context and help new visitors understand your process.',
href: '/creator/stories/create',
icon: 'fa-solid fa-pen-nib',
},
{
title: 'Discover new audiences',
description: 'Browse rising creators and trending work to find collaborations, inspiration, and follow-back opportunities.',
href: '/discover/rising',
icon: 'fa-solid fa-rocket',
},
],
}
: null
return (
<div className="min-h-screen bg-[#050c14] text-slate-100">
<ShortcutSaveToast notice={shortcutNotice} />
<div className="relative isolate overflow-hidden">
<div className="pointer-events-none absolute inset-x-0 top-0 -z-10 h-[520px] bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.24),_transparent_36%),radial-gradient(circle_at_top_right,_rgba(245,158,11,0.16),_transparent_30%),linear-gradient(180deg,_rgba(8,17,28,0.98),_rgba(5,12,20,1))]" />
<div className="relative z-10 mx-auto w-full max-w-7xl px-4 py-8 sm:px-6 lg:px-8 lg:py-10">
<header className="relative overflow-hidden rounded-[32px] border border-white/10 bg-[#08111c]/92 p-6 shadow-2xl shadow-black/30 sm:p-8">
<div className="absolute inset-0 bg-[linear-gradient(135deg,_rgba(56,189,248,0.12),_transparent_40%,_rgba(245,158,11,0.10)_100%)]" />
<div className="relative grid gap-8 xl:grid-cols-[1.35fr_0.95fr] xl:items-start">
<div>
<p className="text-[11px] uppercase tracking-[0.28em] text-sky-200/80">Skinbase Nova Dashboard</p>
<h1 className="mt-3 max-w-3xl text-3xl font-semibold tracking-tight text-white sm:text-4xl">
Welcome back, {username}
</h1>
<div className="mt-4 flex items-center gap-2">
<LevelBadge level={level} rank={rank} />
</div>
<p className="mt-4 max-w-2xl text-sm leading-7 text-slate-300 sm:text-base">
This page is now your dashboard home base: every dashboard section is grouped below, the most urgent tasks are surfaced first, and your progress stays visible without forcing you to hunt through menus.
</p>
<div className="mt-6 flex flex-wrap gap-3">
{suggestions.map((item) => (
<SuggestionChip key={item.href} {...item} onNavigate={handleNavigate} />
))}
</div>
<PinnedSpacesStrip
items={pinnedSpaces}
onNavigate={handleNavigate}
onTogglePin={handleTogglePin}
onMove={handleMovePinnedSpace}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<HeroStat label="Level" value={`Lv. ${level}`} tone="sky" />
<HeroStat label="Rank" value={rank} tone="amber" />
<HeroStat
label="Unread Feedback"
value={receivedCommentsCount > 0 ? receivedCommentsCount : '0'}
tone={receivedCommentsCount > 0 ? 'emerald' : 'slate'}
/>
<HeroStat label="Dashboard Spaces" value={dashboardLinksCount} tone="slate" />
</div>
</div>
</header>
<div className="mt-6 grid grid-cols-1 gap-6 xl:grid-cols-12">
<section className="space-y-6 xl:col-span-8">
<RecentVisitsSection
items={recentVisits.slice(0, 4).map((item) => ({
...item,
preview: previewLabelForRoute(item.href, overviewStats),
}))}
onNavigate={handleNavigate}
onTogglePin={handleTogglePin}
/>
{guidance ? <DashboardGuidance {...guidance} onNavigate={handleNavigate} /> : null}
<section className="rounded-[28px] border border-white/10 bg-[#08111c]/90 p-5 shadow-2xl shadow-black/20 sm:p-6">
<div className="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">Overview</p>
<h2 className="mt-2 text-xl font-semibold text-white">Your dashboard snapshot</h2>
</div>
<p className="max-w-xl text-sm leading-6 text-slate-300">
These numbers turn the dashboard into a real control panel by showing where your attention is needed right now.
</p>
</div>
<div className="mt-5 grid grid-cols-2 gap-4 lg:grid-cols-3">
{overviewCards.map((item) => (
<OverviewMetric key={item.label} {...item} onNavigate={handleNavigate} />
))}
</div>
</section>
{dashboardSections.map((section) => (
<DashboardSection
key={section.title}
{...section}
onNavigate={handleNavigate}
onTogglePin={handleTogglePinFromCard}
pinnedHrefs={pinnedHrefs}
/>
))}
<QuickActions isCreator={isCreator} receivedCommentsCount={receivedCommentsCount} onNavigate={handleNavigate} />
<ActivityFeed />
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
<TrendingArtworks />
<RecommendedCreators />
</div>
</section>
<aside className="space-y-6 xl:col-span-4">
<XPProgressWidget initialLevel={level} initialRank={rank} />
<CreatorAnalytics isCreator={isCreator} />
<RecentAchievements />
<TopCreatorsWidget />
</aside>
</div>
</div>
</div>
</div>
)
}