optimizations
This commit is contained in:
@@ -1,155 +1,6 @@
|
||||
import React, { useRef, useState } from 'react'
|
||||
import LevelBadge from '../../xp/LevelBadge'
|
||||
import React from 'react'
|
||||
import ActivityTab from '../activity/ActivityTab'
|
||||
|
||||
const DEFAULT_AVATAR = 'https://files.skinbase.org/default/avatar_default.webp'
|
||||
|
||||
function CommentItem({ comment }) {
|
||||
return (
|
||||
<div className="flex gap-3 py-4 border-b border-white/5 last:border-0">
|
||||
<a href={comment.author_profile_url} className="shrink-0 mt-0.5">
|
||||
<img
|
||||
src={comment.author_avatar || DEFAULT_AVATAR}
|
||||
alt={comment.author_name}
|
||||
className="w-9 h-9 rounded-xl object-cover ring-1 ring-white/10"
|
||||
onError={(e) => { e.target.src = DEFAULT_AVATAR }}
|
||||
loading="lazy"
|
||||
/>
|
||||
</a>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<a
|
||||
href={comment.author_profile_url}
|
||||
className="text-sm font-semibold text-slate-200 hover:text-white transition-colors"
|
||||
>
|
||||
{comment.author_name}
|
||||
</a>
|
||||
<LevelBadge level={comment.author_level} rank={comment.author_rank} compact />
|
||||
<span className="text-slate-600 text-xs ml-auto whitespace-nowrap">
|
||||
{(() => {
|
||||
try {
|
||||
const d = new Date(comment.created_at)
|
||||
const diff = Date.now() - d.getTime()
|
||||
const mins = Math.floor(diff / 60000)
|
||||
if (mins < 1) return 'just now'
|
||||
if (mins < 60) return `${mins}m ago`
|
||||
const hrs = Math.floor(mins / 60)
|
||||
if (hrs < 24) return `${hrs}h ago`
|
||||
const days = Math.floor(hrs / 24)
|
||||
if (days < 30) return `${days}d ago`
|
||||
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
} catch { return '' }
|
||||
})()}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-slate-400 leading-relaxed break-words whitespace-pre-line">
|
||||
{comment.body}
|
||||
</p>
|
||||
{comment.author_signature && (
|
||||
<p className="text-xs text-slate-600 mt-2 italic border-t border-white/5 pt-1 truncate">
|
||||
{comment.author_signature}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* TabActivity
|
||||
* Profile comments list + comment form for authenticated visitors.
|
||||
* Also acts as "Activity" tab.
|
||||
*/
|
||||
export default function TabActivity({ profileComments, user, isOwner, isLoggedIn }) {
|
||||
const uname = user.username || user.name
|
||||
const formRef = useRef(null)
|
||||
const [submitted, setSubmitted] = useState(false)
|
||||
|
||||
return (
|
||||
<div
|
||||
id="tabpanel-activity"
|
||||
role="tabpanel"
|
||||
aria-labelledby="tab-activity"
|
||||
className="pt-6 max-w-2xl"
|
||||
>
|
||||
<h2 className="text-xs font-semibold uppercase tracking-widest text-slate-500 mb-4 flex items-center gap-2">
|
||||
<i className="fa-solid fa-comments text-orange-400 fa-fw" />
|
||||
Comments
|
||||
{profileComments?.length > 0 && (
|
||||
<span className="ml-1 px-1.5 py-0.5 rounded bg-white/5 text-slate-400 font-normal text-[11px]">
|
||||
{profileComments.length}
|
||||
</span>
|
||||
)}
|
||||
</h2>
|
||||
|
||||
{/* Comments list */}
|
||||
<div className="bg-white/4 ring-1 ring-white/10 rounded-2xl p-5 shadow-xl shadow-black/20 mb-5">
|
||||
{!profileComments?.length ? (
|
||||
<p className="text-slate-500 text-sm text-center py-8">
|
||||
No comments yet. Be the first to leave one!
|
||||
</p>
|
||||
) : (
|
||||
<div>
|
||||
{profileComments.map((comment) => (
|
||||
<CommentItem key={comment.id} comment={comment} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Comment form */}
|
||||
{!isOwner && (
|
||||
<div className="bg-white/4 ring-1 ring-white/10 rounded-2xl p-5 shadow-xl shadow-black/20">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-widest text-slate-500 mb-4 flex items-center gap-2">
|
||||
<i className="fa-solid fa-pen text-sky-400 fa-fw" />
|
||||
Write a Comment
|
||||
</h3>
|
||||
|
||||
{isLoggedIn ? (
|
||||
submitted ? (
|
||||
<div className="flex items-center gap-2 text-green-400 text-sm p-3 rounded-xl bg-green-500/10 ring-1 ring-green-500/20">
|
||||
<i className="fa-solid fa-check fa-fw" />
|
||||
Your comment has been posted!
|
||||
</div>
|
||||
) : (
|
||||
<form
|
||||
ref={formRef}
|
||||
method="POST"
|
||||
action={`/@${uname.toLowerCase()}/comment`}
|
||||
onSubmit={() => setSubmitted(false)}
|
||||
>
|
||||
<input type="hidden" name="_token" value={
|
||||
(() => document.querySelector('meta[name="csrf-token"]')?.content ?? '')()
|
||||
} />
|
||||
<textarea
|
||||
name="body"
|
||||
rows={4}
|
||||
required
|
||||
minLength={2}
|
||||
maxLength={2000}
|
||||
placeholder={`Write a comment for ${uname}…`}
|
||||
className="w-full bg-white/5 border border-white/10 rounded-xl px-3 py-2.5 text-sm text-slate-200 placeholder:text-slate-600 resize-none focus:outline-none focus:ring-2 focus:ring-sky-400/40 focus:border-sky-400/30 transition-all"
|
||||
/>
|
||||
<div className="mt-3 flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-xl bg-sky-600 hover:bg-sky-500 text-white text-sm font-semibold transition-all shadow-lg shadow-sky-900/30"
|
||||
>
|
||||
<i className="fa-solid fa-paper-plane fa-fw" />
|
||||
Post Comment
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
) : (
|
||||
<p className="text-sm text-slate-400 text-center py-4">
|
||||
<a href="/login" className="text-sky-400 hover:text-sky-300 hover:underline transition-colors">
|
||||
Log in
|
||||
</a>
|
||||
{' '}to leave a comment.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
export default function TabActivity({ user }) {
|
||||
return <ActivityTab user={user} />
|
||||
}
|
||||
|
||||
@@ -1,49 +1,138 @@
|
||||
import React from 'react'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import CollectionCard from '../collections/CollectionCard'
|
||||
import CollectionEmptyState from '../collections/CollectionEmptyState'
|
||||
|
||||
/**
|
||||
* TabCollections
|
||||
* Collections feature placeholder.
|
||||
*/
|
||||
export default function TabCollections({ collections }) {
|
||||
if (collections?.length > 0) {
|
||||
return (
|
||||
<div
|
||||
id="tabpanel-collections"
|
||||
role="tabpanel"
|
||||
aria-labelledby="tab-collections"
|
||||
className="pt-6"
|
||||
>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{collections.map((col) => (
|
||||
<div
|
||||
key={col.id}
|
||||
className="bg-white/4 ring-1 ring-white/10 rounded-2xl overflow-hidden group hover:ring-sky-400/30 transition-all cursor-pointer shadow-xl shadow-black/20"
|
||||
>
|
||||
{col.cover_image ? (
|
||||
<div className="aspect-video overflow-hidden bg-black/30">
|
||||
<img
|
||||
src={col.cover_image}
|
||||
alt={col.title}
|
||||
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="aspect-video bg-white/5 flex items-center justify-center text-slate-600">
|
||||
<i className="fa-solid fa-layer-group text-3xl" />
|
||||
</div>
|
||||
)}
|
||||
<div className="p-4">
|
||||
<h3 className="font-semibold text-white truncate">{col.title}</h3>
|
||||
<p className="text-sm text-slate-500 mt-0.5">{col.items_count ?? 0} artworks</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
function getCsrfToken() {
|
||||
if (typeof document === 'undefined') return ''
|
||||
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''
|
||||
}
|
||||
|
||||
async function deleteCollection(url) {
|
||||
const response = await fetch(url, {
|
||||
method: 'DELETE',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': getCsrfToken(),
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
},
|
||||
})
|
||||
|
||||
const payload = await response.json().catch(() => ({}))
|
||||
if (!response.ok) {
|
||||
throw new Error(payload?.message || 'Unable to delete collection.')
|
||||
}
|
||||
|
||||
return payload
|
||||
}
|
||||
|
||||
async function requestJson(url, { method = 'POST', body } = {}) {
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': getCsrfToken(),
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
})
|
||||
|
||||
const payload = await response.json().catch(() => ({}))
|
||||
if (!response.ok) {
|
||||
throw new Error(payload?.message || 'Unable to update collection presentation.')
|
||||
}
|
||||
|
||||
return payload
|
||||
}
|
||||
|
||||
const FILTERS = ['all', 'featured', 'smart', 'manual']
|
||||
|
||||
export default function TabCollections({ collections, isOwner, createUrl, reorderUrl, featuredUrl, featureLimit = 3 }) {
|
||||
const [items, setItems] = useState(Array.isArray(collections) ? collections : [])
|
||||
const [busyId, setBusyId] = useState(null)
|
||||
const [filter, setFilter] = useState('all')
|
||||
|
||||
useEffect(() => {
|
||||
setItems(Array.isArray(collections) ? collections : [])
|
||||
}, [collections])
|
||||
|
||||
async function handleDelete(collection) {
|
||||
if (!collection?.delete_url) return
|
||||
if (!window.confirm(`Delete "${collection.title}"? Artworks will remain untouched.`)) return
|
||||
|
||||
setBusyId(collection.id)
|
||||
try {
|
||||
await deleteCollection(collection.delete_url)
|
||||
setItems((current) => current.filter((item) => item.id !== collection.id))
|
||||
} catch (error) {
|
||||
window.alert(error.message)
|
||||
} finally {
|
||||
setBusyId(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleFeature(collection) {
|
||||
const url = collection?.is_featured ? collection?.unfeature_url : collection?.feature_url
|
||||
const method = collection?.is_featured ? 'DELETE' : 'POST'
|
||||
if (!url) return
|
||||
|
||||
setBusyId(collection.id)
|
||||
try {
|
||||
const payload = await requestJson(url, { method })
|
||||
setItems((current) => current.map((item) => (
|
||||
item.id === collection.id
|
||||
? {
|
||||
...item,
|
||||
is_featured: payload?.collection?.is_featured ?? !item.is_featured,
|
||||
featured_at: payload?.collection?.featured_at ?? item.featured_at,
|
||||
updated_at: payload?.collection?.updated_at ?? item.updated_at,
|
||||
}
|
||||
: item
|
||||
)))
|
||||
} catch (error) {
|
||||
window.alert(error.message)
|
||||
} finally {
|
||||
setBusyId(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleMove(collection, direction) {
|
||||
const index = items.findIndex((item) => item.id === collection.id)
|
||||
const nextIndex = index + direction
|
||||
if (index < 0 || nextIndex < 0 || nextIndex >= items.length || !reorderUrl) return
|
||||
|
||||
const next = [...items]
|
||||
const temp = next[index]
|
||||
next[index] = next[nextIndex]
|
||||
next[nextIndex] = temp
|
||||
setItems(next)
|
||||
|
||||
try {
|
||||
const payload = await requestJson(reorderUrl, {
|
||||
method: 'POST',
|
||||
body: { collection_ids: next.map((item) => item.id) },
|
||||
})
|
||||
if (Array.isArray(payload?.collections)) {
|
||||
setItems(payload.collections)
|
||||
}
|
||||
} catch (error) {
|
||||
window.alert(error.message)
|
||||
setItems(Array.isArray(collections) ? collections : [])
|
||||
}
|
||||
}
|
||||
|
||||
const featuredItems = items.filter((collection) => collection.is_featured)
|
||||
const smartItems = items.filter((collection) => collection.mode === 'smart')
|
||||
const filteredItems = items.filter((collection) => {
|
||||
if (filter === 'featured') return collection.is_featured
|
||||
if (filter === 'smart') return collection.mode === 'smart'
|
||||
if (filter === 'manual') return collection.mode !== 'smart'
|
||||
return true
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
id="tabpanel-collections"
|
||||
@@ -51,15 +140,84 @@ export default function TabCollections({ collections }) {
|
||||
aria-labelledby="tab-collections"
|
||||
className="pt-6"
|
||||
>
|
||||
<div className="bg-white/4 ring-1 ring-white/10 rounded-2xl px-8 py-16 text-center shadow-xl shadow-black/20 backdrop-blur">
|
||||
<div className="w-20 h-20 rounded-2xl bg-white/5 flex items-center justify-center mx-auto mb-5 text-slate-500">
|
||||
<i className="fa-solid fa-layer-group text-3xl" />
|
||||
<div className="mb-5 flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80">Collections</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold tracking-[-0.03em] text-white">Curated showcases from the gallery</h2>
|
||||
<p className="mt-2 max-w-2xl text-sm leading-relaxed text-slate-300">
|
||||
Collections now support featured presentation, smart rule-based curation, and richer profile storytelling.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{featuredUrl ? <a href={featuredUrl} className="inline-flex items-center gap-2 self-start rounded-2xl border border-white/12 bg-white/[0.05] px-5 py-3 text-sm font-semibold text-white transition hover:bg-white/[0.08]"><i className="fa-solid fa-compass fa-fw" />Browse Featured</a> : null}
|
||||
{isOwner && createUrl ? <a href={createUrl} className="inline-flex items-center gap-2 self-start rounded-2xl border border-sky-300/25 bg-sky-400/10 px-5 py-3 text-sm font-semibold text-sky-100 transition hover:bg-sky-400/15"><i className="fa-solid fa-plus fa-fw" />Create Collection</a> : null}
|
||||
</div>
|
||||
<h3 className="text-lg font-bold text-white mb-2">Collections Coming Soon</h3>
|
||||
<p className="text-slate-500 text-sm max-w-sm mx-auto">
|
||||
Group your artworks into curated collections.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-5 flex flex-wrap items-center gap-2">
|
||||
{FILTERS.map((value) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => setFilter(value)}
|
||||
className={`inline-flex items-center gap-2 rounded-full border px-4 py-2 text-xs font-semibold uppercase tracking-[0.16em] transition ${filter === value ? 'border-sky-300/25 bg-sky-400/10 text-sky-100' : 'border-white/10 bg-white/[0.04] text-slate-300 hover:bg-white/[0.07]'}`}
|
||||
>
|
||||
{value}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{items.length > 0 && featuredItems.length > 0 && filter === 'all' ? (
|
||||
<section className="mb-6 overflow-hidden rounded-[28px] border border-amber-300/15 bg-[linear-gradient(135deg,rgba(251,191,36,0.08),rgba(255,255,255,0.04),rgba(56,189,248,0.08))] p-5 shadow-[0_26px_70px_rgba(2,6,23,0.22)]">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-amber-100/80">Featured Collections</p>
|
||||
<h3 className="mt-2 text-xl font-semibold text-white">Premium profile showcases</h3>
|
||||
</div>
|
||||
{isOwner ? <p className="text-xs uppercase tracking-[0.18em] text-slate-300">{featuredItems.length}/{featureLimit} featured</p> : null}
|
||||
</div>
|
||||
<div className="mt-5 grid grid-cols-1 gap-5 md:grid-cols-2 xl:grid-cols-3">
|
||||
{featuredItems.map((collection, index) => (
|
||||
<CollectionCard
|
||||
key={`featured-${collection.id}`}
|
||||
collection={collection}
|
||||
isOwner={isOwner}
|
||||
onDelete={handleDelete}
|
||||
onToggleFeature={handleToggleFeature}
|
||||
onMoveUp={isOwner ? () => handleMove(collection, -1) : null}
|
||||
onMoveDown={isOwner ? () => handleMove(collection, 1) : null}
|
||||
canMoveUp={index > 0}
|
||||
canMoveDown={index < featuredItems.length - 1}
|
||||
busy={busyId === collection.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{isOwner && items.length > 0 && featuredItems.length === 0 ? <div className="mb-5 rounded-[24px] border border-white/10 bg-white/[0.04] px-5 py-4 text-sm text-slate-300">Feature your best collections to pin them at the top of your profile.</div> : null}
|
||||
{isOwner && items.length > 0 && smartItems.length === 0 ? <div className="mb-5 rounded-[24px] border border-white/10 bg-white/[0.04] px-5 py-4 text-sm text-slate-300">Create a smart collection from your tags or categories to keep a showcase updated automatically.</div> : null}
|
||||
|
||||
{items.length === 0 ? (
|
||||
<CollectionEmptyState isOwner={isOwner} createUrl={createUrl} />
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-5 md:grid-cols-2 xl:grid-cols-3">
|
||||
{filteredItems.map((collection, index) => (
|
||||
<CollectionCard
|
||||
key={collection.id}
|
||||
collection={collection}
|
||||
isOwner={isOwner}
|
||||
onDelete={handleDelete}
|
||||
onToggleFeature={handleToggleFeature}
|
||||
onMoveUp={isOwner ? () => handleMove(collection, -1) : null}
|
||||
onMoveDown={isOwner ? () => handleMove(collection, 1) : null}
|
||||
canMoveUp={index > 0}
|
||||
canMoveDown={index < filteredItems.length - 1}
|
||||
busy={busyId === collection.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -75,6 +75,7 @@ export default function TabPosts({
|
||||
stats,
|
||||
followerCount,
|
||||
recentFollowers,
|
||||
suggestedUsers,
|
||||
socialLinks,
|
||||
countryName,
|
||||
profileUrl,
|
||||
@@ -117,7 +118,7 @@ export default function TabPosts({
|
||||
|
||||
const summaryCards = [
|
||||
{ label: 'Followers', value: formatCompactNumber(followerCount), icon: 'fa-user-group' },
|
||||
{ label: 'Artworks', value: formatCompactNumber(stats?.uploads_count ?? 0), icon: 'fa-image' },
|
||||
{ label: 'Uploads', value: formatCompactNumber(stats?.uploads_count ?? 0), icon: 'fa-image' },
|
||||
{ label: 'Awards', value: formatCompactNumber(stats?.awards_received_count ?? 0), icon: 'fa-trophy' },
|
||||
{ label: 'Location', value: countryName || 'Unknown', icon: 'fa-location-dot' },
|
||||
]
|
||||
@@ -239,6 +240,7 @@ export default function TabPosts({
|
||||
stats={stats}
|
||||
followerCount={followerCount}
|
||||
recentFollowers={recentFollowers}
|
||||
suggestedUsers={suggestedUsers}
|
||||
socialLinks={socialLinks}
|
||||
countryName={countryName}
|
||||
isLoggedIn={!!authUser}
|
||||
|
||||
@@ -18,7 +18,7 @@ function KpiCard({ icon, label, value, color = 'text-sky-400' }) {
|
||||
* TabStats
|
||||
* KPI overview cards. Charts can be added here once chart infrastructure exists.
|
||||
*/
|
||||
export default function TabStats({ stats, followerCount }) {
|
||||
export default function TabStats({ stats, followerCount, followAnalytics }) {
|
||||
const kpis = [
|
||||
{ icon: 'fa-eye', label: 'Profile Views', value: stats?.profile_views_count, color: 'text-sky-400' },
|
||||
{ icon: 'fa-images', label: 'Uploads', value: stats?.uploads_count, color: 'text-violet-400' },
|
||||
@@ -29,6 +29,12 @@ export default function TabStats({ stats, followerCount }) {
|
||||
{ icon: 'fa-trophy', label: 'Awards Received', value: stats?.awards_received_count, color: 'text-yellow-400' },
|
||||
{ icon: 'fa-comment', label: 'Comments Received', value: stats?.comments_received_count, color: 'text-orange-400' },
|
||||
]
|
||||
const trendCards = [
|
||||
{ icon: 'fa-arrow-trend-up', label: 'Followers Today', value: followAnalytics?.daily?.gained ?? 0, color: 'text-emerald-400' },
|
||||
{ icon: 'fa-user-minus', label: 'Unfollows Today', value: followAnalytics?.daily?.lost ?? 0, color: 'text-rose-400' },
|
||||
{ icon: 'fa-chart-line', label: 'Weekly Net', value: followAnalytics?.weekly?.net ?? 0, color: 'text-sky-400' },
|
||||
{ icon: 'fa-percent', label: 'Weekly Growth %', value: followAnalytics?.weekly?.growth_rate ?? 0, color: 'text-amber-400' },
|
||||
]
|
||||
|
||||
const hasStats = stats !== null && stats !== undefined
|
||||
|
||||
@@ -56,6 +62,15 @@ export default function TabStats({ stats, followerCount }) {
|
||||
<KpiCard key={kpi.label} {...kpi} />
|
||||
))}
|
||||
</div>
|
||||
<h3 className="mt-8 mb-4 text-xs font-semibold uppercase tracking-widest text-slate-500 flex items-center gap-2">
|
||||
<i className="fa-solid fa-user-group text-emerald-400 fa-fw" />
|
||||
Follow Growth
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
{trendCards.map((card) => (
|
||||
<KpiCard key={card.label} {...card} />
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-slate-600 mt-6 text-center">
|
||||
More detailed analytics (charts, trends) coming soon.
|
||||
</p>
|
||||
|
||||
Reference in New Issue
Block a user