optimizations

This commit is contained in:
2026-03-28 19:15:39 +01:00
parent 0b25d9570a
commit cab4fbd83e
509 changed files with 1016804 additions and 1605 deletions

View File

@@ -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} />
}

View File

@@ -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>
)
}

View File

@@ -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}

View File

@@ -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>