Remove dead admin UI code, redesign dashboard followers/following and upload experiences, and add schema audit tooling with repair migrations for forum and upload drift.
1261 lines
48 KiB
JavaScript
1261 lines
48 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', caption = null, onNavigate }) {
|
|
const accents = {
|
|
sky: {
|
|
icon: 'text-sky-100 border-sky-300/20 bg-sky-400/12',
|
|
card: 'hover:border-sky-300/35 hover:bg-[#102033]',
|
|
glow: 'from-sky-400/18 via-sky-400/8 to-transparent',
|
|
caption: 'text-sky-100/75',
|
|
},
|
|
amber: {
|
|
icon: 'text-amber-100 border-amber-300/20 bg-amber-400/12',
|
|
card: 'hover:border-amber-300/35 hover:bg-[#1a2130]',
|
|
glow: 'from-amber-400/18 via-amber-400/8 to-transparent',
|
|
caption: 'text-amber-100/75',
|
|
},
|
|
emerald: {
|
|
icon: 'text-emerald-100 border-emerald-300/20 bg-emerald-400/12',
|
|
card: 'hover:border-emerald-300/35 hover:bg-[#0f2130]',
|
|
glow: 'from-emerald-400/18 via-emerald-400/8 to-transparent',
|
|
caption: 'text-emerald-100/75',
|
|
},
|
|
rose: {
|
|
icon: 'text-rose-100 border-rose-300/20 bg-rose-400/12',
|
|
card: 'hover:border-rose-300/35 hover:bg-[#1d1d31]',
|
|
glow: 'from-rose-400/18 via-rose-400/8 to-transparent',
|
|
caption: 'text-rose-100/75',
|
|
},
|
|
slate: {
|
|
icon: 'text-slate-100 border-white/10 bg-white/5',
|
|
card: 'hover:border-white/20 hover:bg-[#102033]',
|
|
glow: 'from-white/10 via-white/5 to-transparent',
|
|
caption: 'text-slate-300/80',
|
|
},
|
|
}
|
|
|
|
const tone = accents[accent] || accents.slate
|
|
|
|
return (
|
|
<a
|
|
href={href}
|
|
onClick={() => onNavigate?.(href, label)}
|
|
className={[
|
|
'group relative overflow-hidden rounded-2xl border border-white/10 bg-[#0b1826]/85 p-4 shadow-lg shadow-black/20 transition hover:-translate-y-0.5',
|
|
tone.card,
|
|
].join(' ')}
|
|
>
|
|
<div className={`pointer-events-none absolute inset-x-0 top-0 h-20 bg-gradient-to-b ${tone.glow}`} />
|
|
<div className="flex items-center justify-between gap-3">
|
|
<span className={`inline-flex h-11 w-11 items-center justify-center rounded-2xl border ${tone.icon}`}>
|
|
<i className={icon} aria-hidden="true" />
|
|
</span>
|
|
<div className="text-right">
|
|
<span className="block text-2xl font-semibold text-white">{value}</span>
|
|
{caption ? <span className={`mt-1 block text-[11px] uppercase tracking-[0.16em] ${tone.caption}`}>{caption}</span> : null}
|
|
</div>
|
|
</div>
|
|
<p className="mt-4 text-xs uppercase tracking-[0.18em] text-slate-400">{label}</p>
|
|
</a>
|
|
)
|
|
}
|
|
|
|
function SectionLinkCard({ item, onNavigate, onTogglePin, isPinned = false }) {
|
|
const accents = {
|
|
sky: {
|
|
icon: 'border-sky-300/20 bg-sky-400/12 text-sky-100',
|
|
badge: 'border-sky-300/20 bg-sky-400/10 text-sky-100',
|
|
preview: 'text-sky-200/80',
|
|
open: 'text-sky-100',
|
|
hover: 'hover:border-sky-300/35 hover:bg-[#102033]',
|
|
glow: 'from-sky-400/16 via-sky-400/8 to-transparent',
|
|
},
|
|
emerald: {
|
|
icon: 'border-emerald-300/20 bg-emerald-400/12 text-emerald-100',
|
|
badge: 'border-emerald-300/20 bg-emerald-400/10 text-emerald-100',
|
|
preview: 'text-emerald-200/80',
|
|
open: 'text-emerald-100',
|
|
hover: 'hover:border-emerald-300/35 hover:bg-[#102033]',
|
|
glow: 'from-emerald-400/16 via-emerald-400/8 to-transparent',
|
|
},
|
|
amber: {
|
|
icon: 'border-amber-300/20 bg-amber-400/12 text-amber-100',
|
|
badge: 'border-amber-300/20 bg-amber-400/10 text-amber-100',
|
|
preview: 'text-amber-200/80',
|
|
open: 'text-amber-100',
|
|
hover: 'hover:border-amber-300/35 hover:bg-[#1a2130]',
|
|
glow: 'from-amber-400/16 via-amber-400/8 to-transparent',
|
|
},
|
|
rose: {
|
|
icon: 'border-rose-300/20 bg-rose-400/12 text-rose-100',
|
|
badge: 'border-rose-300/20 bg-rose-400/10 text-rose-100',
|
|
preview: 'text-rose-200/80',
|
|
open: 'text-rose-100',
|
|
hover: 'hover:border-rose-300/35 hover:bg-[#1d1d31]',
|
|
glow: 'from-rose-400/16 via-rose-400/8 to-transparent',
|
|
},
|
|
slate: {
|
|
icon: 'border-white/10 bg-white/5 text-sky-200',
|
|
badge: 'border-white/10 bg-white/5 text-slate-200',
|
|
preview: 'text-sky-200/80',
|
|
open: 'text-sky-100',
|
|
hover: 'hover:border-white/20 hover:bg-[#102033]',
|
|
glow: 'from-white/10 via-white/5 to-transparent',
|
|
},
|
|
}
|
|
|
|
const tone = accents[item.accent] || accents.slate
|
|
|
|
return (
|
|
<article className={[
|
|
'group relative overflow-hidden rounded-2xl border border-white/10 bg-[#0b1826]/80 p-4 shadow-lg shadow-black/20 transition hover:-translate-y-0.5',
|
|
tone.hover,
|
|
].join(' ')}>
|
|
<div className={`pointer-events-none absolute inset-x-0 top-0 h-24 bg-gradient-to-b ${tone.glow}`} />
|
|
<div className="flex items-start justify-between gap-3">
|
|
<span className={`inline-flex h-11 w-11 items-center justify-center rounded-2xl border text-lg ${tone.icon}`}>
|
|
<i className={item.icon} aria-hidden="true" />
|
|
</span>
|
|
<div className="flex items-center gap-2">
|
|
{item.badge ? (
|
|
<span className={`rounded-full border px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] ${tone.badge}`}>
|
|
{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] ${tone.preview}`}>{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={`${tone.open} 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: 'Audience, network, and saved work',
|
|
description: 'Stay close to the people around your account, from new followers to the creators shaping your feed.',
|
|
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),
|
|
accent: 'sky',
|
|
},
|
|
{
|
|
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),
|
|
accent: 'emerald',
|
|
},
|
|
{
|
|
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),
|
|
accent: 'rose',
|
|
},
|
|
],
|
|
},
|
|
{
|
|
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',
|
|
caption: overviewStats.notifications > 0 ? 'Needs review' : 'All clear',
|
|
},
|
|
{
|
|
label: 'Followers',
|
|
value: overviewStats.followers,
|
|
href: '/dashboard/followers',
|
|
icon: 'fa-solid fa-user-group',
|
|
accent: 'sky',
|
|
caption: overviewStats.followers > 0 ? 'Audience' : 'Build audience',
|
|
},
|
|
{
|
|
label: 'Following',
|
|
value: overviewStats.following,
|
|
href: '/dashboard/following',
|
|
icon: 'fa-solid fa-users',
|
|
accent: 'emerald',
|
|
caption: overviewStats.following > 0 ? 'Network' : 'Find creators',
|
|
},
|
|
{
|
|
label: 'Saved favorites',
|
|
value: overviewStats.favorites,
|
|
href: '/dashboard/favorites',
|
|
icon: 'fa-solid fa-bookmark',
|
|
accent: 'rose',
|
|
caption: overviewStats.favorites > 0 ? 'Inspiration' : 'Nothing saved',
|
|
},
|
|
{
|
|
label: 'Artworks',
|
|
value: overviewStats.artworks,
|
|
href: '/dashboard/artworks',
|
|
icon: 'fa-solid fa-layer-group',
|
|
accent: 'emerald',
|
|
caption: overviewStats.artworks > 0 ? 'Portfolio' : 'Start uploading',
|
|
},
|
|
{
|
|
label: 'Stories',
|
|
value: overviewStats.stories,
|
|
href: isCreator ? '/creator/stories' : '/creator/stories/create',
|
|
icon: 'fa-solid fa-pen-nib',
|
|
accent: 'amber',
|
|
caption: overviewStats.stories > 0 ? 'Creator voice' : 'Tell your story',
|
|
},
|
|
]
|
|
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 flex-wrap 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-1 gap-3 sm:grid-cols-2">
|
|
<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>
|
|
)
|
|
}
|