Add a read-only Google AdSense analytics module for admins.
Connect AdSense over OAuth, sync entities and daily reports locally, and show placement performance in the admin panel without calling the Management API from public pages.
This commit is contained in:
@@ -8,6 +8,7 @@ const buildAdminNavGroups = (isAdmin) => [
|
||||
{ label: 'Dashboard', href: '/moderation', icon: 'fa-solid fa-gauge-high', exact: true },
|
||||
{ label: 'Daily Activity', href: '/moderation/activity', icon: 'fa-solid fa-calendar-day' },
|
||||
{ label: 'Online Users', href: '/moderation/traffic/online', icon: 'fa-solid fa-user-check' },
|
||||
...(isAdmin ? [{ label: 'AdSense Analytics', href: '/admin/adsense', icon: 'fa-solid fa-chart-column' }] : []),
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,401 @@
|
||||
import React, { useMemo, useState } from 'react'
|
||||
import { Head, router, usePage } from '@inertiajs/react'
|
||||
import AdminLayout from '../../../Layouts/AdminLayout'
|
||||
|
||||
function formatNumber(value) {
|
||||
if (value === null || value === undefined || Number.isNaN(Number(value))) {
|
||||
return '—'
|
||||
}
|
||||
|
||||
return Number(value).toLocaleString()
|
||||
}
|
||||
|
||||
function formatMoney(value, currency) {
|
||||
if (value === null || value === undefined || Number.isNaN(Number(value))) {
|
||||
return '—'
|
||||
}
|
||||
|
||||
try {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: currency || 'USD',
|
||||
maximumFractionDigits: 2,
|
||||
}).format(Number(value))
|
||||
} catch {
|
||||
return `${Number(value).toFixed(2)} ${currency || ''}`.trim()
|
||||
}
|
||||
}
|
||||
|
||||
function formatPercent(value) {
|
||||
if (value === null || value === undefined || Number.isNaN(Number(value))) {
|
||||
return '—'
|
||||
}
|
||||
|
||||
return `${(Number(value) * 100).toFixed(2)}%`
|
||||
}
|
||||
|
||||
function formatKpi(kpi) {
|
||||
if (kpi.format === 'money') {
|
||||
return formatMoney(kpi.value, kpi.currency_code)
|
||||
}
|
||||
|
||||
if (kpi.format === 'percent') {
|
||||
return formatPercent(kpi.value)
|
||||
}
|
||||
|
||||
return formatNumber(kpi.value)
|
||||
}
|
||||
|
||||
function formatChange(change) {
|
||||
if (change === null || change === undefined) {
|
||||
return 'No previous period'
|
||||
}
|
||||
|
||||
const prefix = Number(change) > 0 ? '+' : ''
|
||||
return `${prefix}${Number(change).toFixed(1)}% vs previous`
|
||||
}
|
||||
|
||||
function formatTimestamp(value) {
|
||||
if (!value) {
|
||||
return 'Never'
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat('en-GB', {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
}).format(new Date(value))
|
||||
}
|
||||
|
||||
function statusCopy(connection) {
|
||||
if (!connection) {
|
||||
return { label: 'Not connected', className: 'border-slate-400/20 bg-slate-500/10 text-slate-200' }
|
||||
}
|
||||
|
||||
if (connection.needs_reconnect || connection.status === 'reconnect_required') {
|
||||
return { label: 'Reconnect required', className: 'border-amber-400/20 bg-amber-500/10 text-amber-200' }
|
||||
}
|
||||
|
||||
if (connection.status === 'error') {
|
||||
return { label: 'Synchronization error', className: 'border-rose-400/20 bg-rose-500/10 text-rose-200' }
|
||||
}
|
||||
|
||||
if (connection.status === 'connected') {
|
||||
return { label: 'Connected', className: 'border-emerald-400/20 bg-emerald-500/10 text-emerald-200' }
|
||||
}
|
||||
|
||||
if (connection.status === 'pending') {
|
||||
return { label: 'Pending account selection', className: 'border-sky-400/20 bg-sky-500/10 text-sky-200' }
|
||||
}
|
||||
|
||||
return { label: 'Not connected', className: 'border-slate-400/20 bg-slate-500/10 text-slate-200' }
|
||||
}
|
||||
|
||||
function Sparkline({ points, metric }) {
|
||||
if (!points.length) {
|
||||
return <p className="px-4 py-10 text-sm text-slate-500">No chart data for this range.</p>
|
||||
}
|
||||
|
||||
const values = points.map((point) => Number(point[metric] || 0))
|
||||
const max = Math.max(...values, 0.0001)
|
||||
const width = 640
|
||||
const height = 180
|
||||
const step = values.length === 1 ? width : width / (values.length - 1)
|
||||
const path = values
|
||||
.map((value, index) => {
|
||||
const x = index * step
|
||||
const y = height - (value / max) * (height - 16) - 8
|
||||
return `${index === 0 ? 'M' : 'L'}${x.toFixed(1)} ${y.toFixed(1)}`
|
||||
})
|
||||
.join(' ')
|
||||
|
||||
return (
|
||||
<svg viewBox={`0 0 ${width} ${height}`} className="h-48 w-full text-rose-300" role="img" aria-label="AdSense trend">
|
||||
<path d={path} fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function SortableTable({ rows, currency }) {
|
||||
const [sortKey, setSortKey] = useState('revenue')
|
||||
const [direction, setDirection] = useState('desc')
|
||||
|
||||
const sorted = useMemo(() => {
|
||||
return [...rows].sort((a, b) => {
|
||||
const left = a[sortKey]
|
||||
const right = b[sortKey]
|
||||
const numeric = typeof left === 'number' || typeof right === 'number' || sortKey !== 'label'
|
||||
if (numeric) {
|
||||
return direction === 'asc' ? Number(left || 0) - Number(right || 0) : Number(right || 0) - Number(left || 0)
|
||||
}
|
||||
|
||||
return direction === 'asc'
|
||||
? String(left || '').localeCompare(String(right || ''))
|
||||
: String(right || '').localeCompare(String(left || ''))
|
||||
})
|
||||
}, [rows, sortKey, direction])
|
||||
|
||||
const toggle = (key) => {
|
||||
if (sortKey === key) {
|
||||
setDirection(direction === 'asc' ? 'desc' : 'asc')
|
||||
return
|
||||
}
|
||||
|
||||
setSortKey(key)
|
||||
setDirection(key === 'label' ? 'asc' : 'desc')
|
||||
}
|
||||
|
||||
const header = (key, label) => (
|
||||
<button type="button" onClick={() => toggle(key)} className="font-semibold uppercase tracking-[0.16em] text-slate-500">
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
|
||||
if (!rows.length) {
|
||||
return <p className="px-4 py-8 text-sm text-slate-500">No rows for this range.</p>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-white/10">
|
||||
<th className="px-4 py-3">{header('label', 'Placement')}</th>
|
||||
<th className="px-4 py-3">{header('page_views', 'Page Views')}</th>
|
||||
<th className="px-4 py-3">{header('impressions', 'Impressions')}</th>
|
||||
<th className="px-4 py-3">{header('clicks', 'Clicks')}</th>
|
||||
<th className="px-4 py-3">{header('ctr', 'CTR')}</th>
|
||||
<th className="px-4 py-3">{header('rpm', 'RPM')}</th>
|
||||
<th className="px-4 py-3">{header('revenue', 'Revenue')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sorted.map((row) => (
|
||||
<tr key={row.key} className="border-b border-white/5 text-slate-200">
|
||||
<td className="px-4 py-3 font-medium text-white">{row.label}</td>
|
||||
<td className="px-4 py-3">{formatNumber(row.page_views)}</td>
|
||||
<td className="px-4 py-3">{formatNumber(row.impressions)}</td>
|
||||
<td className="px-4 py-3">{formatNumber(row.clicks)}</td>
|
||||
<td className="px-4 py-3">{formatPercent(row.ctr)}</td>
|
||||
<td className="px-4 py-3">{formatMoney(row.rpm, currency)}</td>
|
||||
<td className="px-4 py-3">{formatMoney(row.revenue, currency)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AdsenseIndex({
|
||||
connection,
|
||||
configured,
|
||||
pendingAccounts = [],
|
||||
recommendedPlacements = [],
|
||||
range,
|
||||
dashboard,
|
||||
routes,
|
||||
}) {
|
||||
const flash = usePage().props.flash ?? {}
|
||||
const [chartMetric, setChartMetric] = useState('estimated_earnings')
|
||||
const status = statusCopy(connection)
|
||||
const query = new URLSearchParams()
|
||||
query.set('period', range.period)
|
||||
if (range.period === 'custom') {
|
||||
query.set('from', range.from)
|
||||
query.set('to', range.to)
|
||||
}
|
||||
|
||||
const applyPeriod = (period, from = null, to = null) => {
|
||||
const params = { period }
|
||||
if (period === 'custom' && from && to) {
|
||||
params.from = from
|
||||
params.to = to
|
||||
}
|
||||
router.get(routes.index, params, { preserveState: true, replace: true })
|
||||
}
|
||||
|
||||
return (
|
||||
<AdminLayout title="AdSense Analytics" subtitle="Read-only Google AdSense reporting for Skinbase administrators.">
|
||||
<Head title="Admin · AdSense Analytics" />
|
||||
|
||||
<div className="space-y-6">
|
||||
{flash.success ? (
|
||||
<div className="rounded-2xl border border-emerald-400/20 bg-emerald-500/10 px-4 py-3 text-sm text-emerald-100">{flash.success}</div>
|
||||
) : null}
|
||||
{flash.warning ? (
|
||||
<div className="rounded-2xl border border-amber-400/20 bg-amber-500/10 px-4 py-3 text-sm text-amber-100">{flash.warning}</div>
|
||||
) : null}
|
||||
{flash.error ? (
|
||||
<div className="rounded-2xl border border-rose-400/20 bg-rose-500/10 px-4 py-3 text-sm text-rose-100">{flash.error}</div>
|
||||
) : null}
|
||||
|
||||
<section className="rounded-[28px] border border-white/[0.08] bg-white/[0.03] p-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">Google AdSense</p>
|
||||
<div className="mt-3 flex items-center gap-3">
|
||||
<span className={`inline-flex rounded-full border px-2.5 py-1 text-xs font-semibold uppercase tracking-[0.16em] ${status.className}`}>
|
||||
{status.label}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-4 text-sm text-slate-300">
|
||||
Account: {connection?.account_display_name || 'Not selected'}
|
||||
{connection?.account_resource_name ? ` / ${connection.account_resource_name.replace('accounts/', '')}` : ''}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-slate-400">Last synchronized: {formatTimestamp(connection?.last_successful_sync_at)}</p>
|
||||
{connection?.last_error ? <p className="mt-2 text-sm text-rose-200">{connection.last_error}</p> : null}
|
||||
{connection?.needs_reconnect ? (
|
||||
<p className="mt-2 text-sm text-amber-200">AdSense authorization expired or was revoked. Reconnect Google AdSense.</p>
|
||||
) : null}
|
||||
{!configured ? <p className="mt-2 text-sm text-amber-200">ADSENSE_CLIENT_ID and ADSENSE_CLIENT_SECRET are not configured.</p> : null}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<a href={routes.connect} className="rounded-xl border border-white/10 bg-rose-500/20 px-4 py-2 text-sm font-semibold text-rose-100 hover:bg-rose-500/30">
|
||||
{connection?.needs_reconnect || connection?.status === 'reconnect_required' ? 'Reconnect' : 'Connect Google AdSense'}
|
||||
</a>
|
||||
{connection?.status === 'connected' ? (
|
||||
<button type="button" onClick={() => router.post(routes.sync)} className="rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm font-semibold text-white hover:bg-white/10">
|
||||
Sync now
|
||||
</button>
|
||||
) : null}
|
||||
{connection && connection.status !== 'disconnected' ? (
|
||||
<button type="button" onClick={() => router.delete(routes.disconnect)} className="rounded-xl border border-white/10 bg-black/20 px-4 py-2 text-sm font-semibold text-slate-300 hover:bg-black/40">
|
||||
Disconnect
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{pendingAccounts.length ? (
|
||||
<section className="rounded-[28px] border border-amber-400/20 bg-amber-500/5 p-6">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-amber-200">Select AdSense account</p>
|
||||
<div className="mt-4 space-y-2">
|
||||
{pendingAccounts.map((account) => (
|
||||
<button
|
||||
key={account.resource_name}
|
||||
type="button"
|
||||
onClick={() => router.post(routes.account, { account_resource_name: account.resource_name })}
|
||||
className="block w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-left text-sm text-white hover:border-white/20"
|
||||
>
|
||||
<span className="font-semibold">{account.display_name}</span>
|
||||
<span className="ml-2 text-slate-400">{account.resource_name.replace('accounts/', '')}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="rounded-[28px] border border-white/[0.08] bg-white/[0.03] p-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">Period</p>
|
||||
<p className="mt-2 text-sm text-slate-300">{range.from} to {range.to}</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{[
|
||||
['today', 'Today'],
|
||||
['yesterday', 'Yesterday'],
|
||||
['7d', 'Last 7 days'],
|
||||
['30d', 'Last 30 days'],
|
||||
].map(([value, label]) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => applyPeriod(value)}
|
||||
className={`rounded-full border px-3 py-1.5 text-xs font-semibold uppercase tracking-[0.16em] ${range.period === value ? 'border-rose-400/30 bg-rose-500/15 text-rose-100' : 'border-white/10 text-slate-400 hover:text-white'}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<form
|
||||
className="mt-4 flex flex-wrap items-end gap-3"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
const form = new FormData(event.currentTarget)
|
||||
applyPeriod('custom', form.get('from'), form.get('to'))
|
||||
}}
|
||||
>
|
||||
<label className="text-xs uppercase tracking-[0.16em] text-slate-500">
|
||||
From
|
||||
<input type="date" name="from" defaultValue={range.from} className="mt-1 block rounded-xl border border-white/10 bg-black/30 px-3 py-2 text-sm text-white" />
|
||||
</label>
|
||||
<label className="text-xs uppercase tracking-[0.16em] text-slate-500">
|
||||
To
|
||||
<input type="date" name="to" defaultValue={range.to} className="mt-1 block rounded-xl border border-white/10 bg-black/30 px-3 py-2 text-sm text-white" />
|
||||
</label>
|
||||
<button type="submit" className="rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm font-semibold text-white">
|
||||
Custom range
|
||||
</button>
|
||||
</form>
|
||||
<p className="mt-4 text-xs text-slate-500">Today's AdSense data is estimated and may change.</p>
|
||||
</section>
|
||||
|
||||
{dashboard ? (
|
||||
<>
|
||||
<section className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
{dashboard.kpis.map((kpi) => (
|
||||
<div key={kpi.key} className="rounded-2xl border border-white/[0.08] bg-white/[0.04] p-5">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">{kpi.label}</p>
|
||||
<p className="mt-3 text-2xl font-bold text-white">{formatKpi(kpi)}</p>
|
||||
<p className="mt-2 text-xs uppercase tracking-[0.16em] text-slate-500">{formatChange(kpi.change_percent)}</p>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
|
||||
<section className="rounded-[28px] border border-white/[0.08] bg-white/[0.03] p-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">Daily trend</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{[
|
||||
['estimated_earnings', 'Revenue'],
|
||||
['page_views', 'Page Views'],
|
||||
['page_views_rpm', 'Page RPM'],
|
||||
['clicks', 'Clicks'],
|
||||
].map(([value, label]) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => setChartMetric(value)}
|
||||
className={`rounded-full border px-3 py-1.5 text-xs font-semibold uppercase tracking-[0.16em] ${chartMetric === value ? 'border-rose-400/30 bg-rose-500/15 text-rose-100' : 'border-white/10 text-slate-400'}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<Sparkline points={dashboard.chart || []} metric={chartMetric} />
|
||||
</section>
|
||||
|
||||
<section className="rounded-[28px] border border-white/[0.08] bg-white/[0.03] p-6">
|
||||
<p className="mb-4 text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">Placement performance</p>
|
||||
<SortableTable rows={dashboard.placements || []} currency={dashboard.currency_code} />
|
||||
</section>
|
||||
|
||||
<section className="rounded-[28px] border border-white/[0.08] bg-white/[0.03] p-6">
|
||||
<p className="mb-4 text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">Platform breakdown</p>
|
||||
<SortableTable rows={dashboard.platforms || []} currency={dashboard.currency_code} />
|
||||
</section>
|
||||
|
||||
<section className="rounded-[28px] border border-white/[0.08] bg-white/[0.03] p-6">
|
||||
<p className="mb-4 text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">Top countries by revenue</p>
|
||||
<SortableTable rows={dashboard.countries || []} currency={dashboard.currency_code} />
|
||||
</section>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<section className="rounded-[28px] border border-white/[0.08] bg-white/[0.03] p-6">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">Recommended AdSense unit names</p>
|
||||
<p className="mt-2 text-sm text-slate-400">Rename units inside Google AdSense. Skinbase imports names and does not modify inventory.</p>
|
||||
<ul className="mt-4 grid gap-2 sm:grid-cols-2">
|
||||
{recommendedPlacements.map((name) => (
|
||||
<li key={name} className="rounded-xl border border-white/10 bg-black/20 px-4 py-3 font-mono text-sm text-slate-200">{name}</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
</AdminLayout>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react'
|
||||
import AdminLayout from '../../Layouts/AdminLayout'
|
||||
import { Head } from '@inertiajs/react'
|
||||
import { Head, usePage } from '@inertiajs/react'
|
||||
|
||||
function StatCard({ icon, label, value, color = 'sky' }) {
|
||||
const colors = {
|
||||
@@ -26,6 +26,20 @@ function StatCard({ icon, label, value, color = 'sky' }) {
|
||||
}
|
||||
|
||||
export default function Dashboard({ stats }) {
|
||||
const isAdmin = Boolean(usePage().props.auth?.user?.is_admin)
|
||||
const quickActions = [
|
||||
{ label: 'Daily Activity', href: '/moderation/activity', icon: 'fa-solid fa-calendar-day', desc: 'Review everything created or moderated on a selected day' },
|
||||
{ label: 'Manage Users', href: '/moderation/users', icon: 'fa-solid fa-users', desc: 'Search, promote or demote users' },
|
||||
{ label: 'Staff Roles', href: '/moderation/users?role=admin', icon: 'fa-solid fa-shield-halved', desc: 'View all admins, managers and editorial staff' },
|
||||
{ label: 'Username Queue', href: '/moderation/usernames/moderation', icon: 'fa-solid fa-id-badge', desc: 'Review pending username requests' },
|
||||
{ label: 'Upload Queue', href: '/moderation/uploads', icon: 'fa-solid fa-cloud-arrow-up', desc: 'Moderate pending artwork submissions' },
|
||||
{ label: 'Stories', href: '/moderation/stories', icon: 'fa-solid fa-feather-pointed', desc: 'Browse all creator stories' },
|
||||
{ label: 'Artworks', href: '/moderation/artworks', icon: 'fa-solid fa-images', desc: 'Browse all uploaded artworks' },
|
||||
{ label: 'Enhance Jobs', href: '/moderation/enhance', icon: 'fa-solid fa-up-right-and-down-left-from-center', desc: 'Inspect queued, failed, and completed image enhance jobs' },
|
||||
{ label: 'Featured Artworks', href: '/moderation/artworks/featured', icon: 'fa-solid fa-star', desc: 'Curate the homepage featured artwork lineup' },
|
||||
...(isAdmin ? [{ label: 'AdSense Analytics', href: '/admin/adsense', icon: 'fa-solid fa-chart-column', desc: 'Review Google AdSense revenue and placement performance' }] : []),
|
||||
]
|
||||
|
||||
return (
|
||||
<AdminLayout title="Dashboard" subtitle="Overview of your Skinbase platform">
|
||||
<Head title="Admin Dashboard" />
|
||||
@@ -42,17 +56,7 @@ export default function Dashboard({ stats }) {
|
||||
<div className="mt-10">
|
||||
<h2 className="mb-4 text-sm font-semibold uppercase tracking-wider text-slate-500">Quick Actions</h2>
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{[
|
||||
{ label: 'Daily Activity', href: '/moderation/activity', icon: 'fa-solid fa-calendar-day', desc: 'Review everything created or moderated on a selected day' },
|
||||
{ label: 'Manage Users', href: '/moderation/users', icon: 'fa-solid fa-users', desc: 'Search, promote or demote users' },
|
||||
{ label: 'Staff Roles', href: '/moderation/users?role=admin', icon: 'fa-solid fa-shield-halved', desc: 'View all admins, managers and editorial staff' },
|
||||
{ label: 'Username Queue', href: '/moderation/usernames/moderation', icon: 'fa-solid fa-id-badge', desc: 'Review pending username requests' },
|
||||
{ label: 'Upload Queue', href: '/moderation/uploads', icon: 'fa-solid fa-cloud-arrow-up', desc: 'Moderate pending artwork submissions' },
|
||||
{ label: 'Stories', href: '/moderation/stories', icon: 'fa-solid fa-feather-pointed', desc: 'Browse all creator stories' },
|
||||
{ label: 'Artworks', href: '/moderation/artworks', icon: 'fa-solid fa-images', desc: 'Browse all uploaded artworks' },
|
||||
{ label: 'Enhance Jobs', href: '/moderation/enhance', icon: 'fa-solid fa-up-right-and-down-left-from-center', desc: 'Inspect queued, failed, and completed image enhance jobs' },
|
||||
{ label: 'Featured Artworks', href: '/moderation/artworks/featured', icon: 'fa-solid fa-star', desc: 'Curate the homepage featured artwork lineup' },
|
||||
].map((item) => (
|
||||
{quickActions.map((item) => (
|
||||
<a
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
|
||||
Reference in New Issue
Block a user