feat: forum rich-text editor, emoji picker, mentions, discover nav, feed, uploads, profile

Forum:
- TipTap WYSIWYG editor with full toolbar
- @emoji-mart/react emoji picker (consistent with tweets)
- @mention autocomplete with user search API
- Fix PHP 8.4 parse errors in Blade templates
- Fix thread data display (paginator items)
- Align forum page widths to max-w-5xl

Discover:
- Extract shared _nav.blade.php partial
- Add missing nav links to for-you page
- Add Following link for authenticated users

Feed/Posts:
- Post model, controllers, policies, migrations
- Feed page components (PostComposer, FeedCard, etc)
- Post reactions, comments, saves, reports, sharing
- Scheduled publishing support
- Link preview controller

Profile:
- Profile page components (ProfileHero, ProfileTabs)
- Profile API controller

Uploads:
- Upload wizard enhancements
- Scheduled publish picker
- Studio status bar and readiness checklist
This commit is contained in:
2026-03-03 09:48:31 +01:00
parent 1266f81d35
commit dc51d65440
178 changed files with 14308 additions and 665 deletions

View File

@@ -0,0 +1,165 @@
import React from 'react'
const SOCIAL_ICONS = {
twitter: { icon: 'fa-brands fa-x-twitter', label: 'X / Twitter' },
deviantart: { icon: 'fa-brands fa-deviantart', label: 'DeviantArt' },
instagram: { icon: 'fa-brands fa-instagram', label: 'Instagram' },
behance: { icon: 'fa-brands fa-behance', label: 'Behance' },
artstation: { icon: 'fa-solid fa-palette', label: 'ArtStation' },
youtube: { icon: 'fa-brands fa-youtube', label: 'YouTube' },
website: { icon: 'fa-solid fa-link', label: 'Website' },
}
function InfoRow({ icon, label, children }) {
return (
<div className="flex items-start gap-3 py-2.5 border-b border-white/5 last:border-0">
<i className={`fa-solid ${icon} fa-fw text-slate-500 mt-0.5 w-4 text-center`} />
<div className="flex-1 min-w-0">
<span className="text-xs text-slate-500 block mb-0.5">{label}</span>
<div className="text-sm text-slate-200">{children}</div>
</div>
</div>
)
}
/**
* TabAbout
* Bio, social links, metadata - replaces old sidebar profile card.
*/
export default function TabAbout({ user, profile, socialLinks, countryName, followerCount }) {
const uname = user.username || user.name
const displayName = user.name || uname
const about = profile?.about
const website = profile?.website
const joinDate = user.created_at
? new Date(user.created_at).toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })
: null
const lastVisit = user.last_visit_at
? (() => {
try {
const d = new Date(user.last_visit_at)
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
} catch { return null }
})()
: null
const genderMap = { M: 'Male', F: 'Female', X: 'Non-binary / N/A' }
const genderLabel = genderMap[profile?.gender?.toUpperCase()] ?? null
const socialEntries = socialLinks
? Object.entries(socialLinks).filter(([, link]) => link?.url)
: []
return (
<div
id="tabpanel-about"
role="tabpanel"
aria-labelledby="tab-about"
className="pt-6 max-w-2xl"
>
{/* Bio */}
{about ? (
<div className="bg-white/4 ring-1 ring-white/10 rounded-2xl p-5 mb-5 shadow-xl shadow-black/20 backdrop-blur">
<h2 className="text-xs font-semibold uppercase tracking-widest text-slate-500 mb-3 flex items-center gap-2">
<i className="fa-solid fa-quote-left text-purple-400 fa-fw" />
About
</h2>
<p className="text-sm text-slate-300 leading-relaxed whitespace-pre-line">{about}</p>
</div>
) : (
<div className="bg-white/4 ring-1 ring-white/10 rounded-2xl p-5 mb-5 text-center text-slate-500 text-sm">
No bio yet.
</div>
)}
{/* Info card */}
<div className="bg-white/4 ring-1 ring-white/10 rounded-2xl p-5 mb-5 shadow-xl shadow-black/20">
<h2 className="text-xs font-semibold uppercase tracking-widest text-slate-500 mb-3 flex items-center gap-2">
<i className="fa-solid fa-id-card text-sky-400 fa-fw" />
Profile Info
</h2>
<div className="divide-y divide-white/5">
{displayName && displayName !== uname && (
<InfoRow icon="fa-user" label="Display name">{displayName}</InfoRow>
)}
<InfoRow icon="fa-at" label="Username">
<span className="font-mono">@{uname}</span>
</InfoRow>
{genderLabel && (
<InfoRow icon="fa-venus-mars" label="Gender">{genderLabel}</InfoRow>
)}
{countryName && (
<InfoRow icon="fa-earth-americas" label="Country">
<span className="flex items-center gap-2">
{profile?.country_code && (
<img
src={`/gfx/flags/shiny/24/${encodeURIComponent(profile.country_code)}.png`}
alt={countryName}
className="w-4 h-auto rounded-sm"
onError={(e) => { e.target.style.display = 'none' }}
/>
)}
{countryName}
</span>
</InfoRow>
)}
{website && (
<InfoRow icon="fa-link" label="Website">
<a
href={website.startsWith('http') ? website : `https://${website}`}
target="_blank"
rel="nofollow noopener noreferrer"
className="text-sky-400 hover:text-sky-300 hover:underline transition-colors"
>
{(() => {
try {
const url = website.startsWith('http') ? website : `https://${website}`
return new URL(url).hostname
} catch { return website }
})()}
</a>
</InfoRow>
)}
{joinDate && (
<InfoRow icon="fa-calendar-days" label="Member since">{joinDate}</InfoRow>
)}
{lastVisit && (
<InfoRow icon="fa-clock" label="Last seen">{lastVisit}</InfoRow>
)}
<InfoRow icon="fa-users" label="Followers">{Number(followerCount).toLocaleString()}</InfoRow>
</div>
</div>
{/* Social links */}
{socialEntries.length > 0 && (
<div className="bg-white/4 ring-1 ring-white/10 rounded-2xl p-5 shadow-xl shadow-black/20">
<h2 className="text-xs font-semibold uppercase tracking-widest text-slate-500 mb-3 flex items-center gap-2">
<i className="fa-solid fa-share-nodes text-sky-400 fa-fw" />
Social Links
</h2>
<div className="flex flex-wrap gap-2">
{socialEntries.map(([platform, link]) => {
const si = SOCIAL_ICONS[platform] ?? { icon: 'fa-solid fa-link', label: platform }
const href = link.url.startsWith('http') ? link.url : `https://${link.url}`
return (
<a
key={platform}
href={href}
target="_blank"
rel="nofollow noopener noreferrer"
className="inline-flex items-center gap-2 px-3 py-2 rounded-xl text-sm border border-white/10 text-slate-300 hover:text-white hover:bg-white/8 hover:border-sky-400/30 transition-all"
aria-label={si.label}
>
<i className={`${si.icon} fa-fw`} />
<span>{si.label}</span>
</a>
)
})}
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,153 @@
import React, { useRef, useState } from 'react'
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>
<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>
)
}

View File

@@ -0,0 +1,195 @@
import React, { useState, useCallback } from 'react'
import ArtworkCard from '../../gallery/ArtworkCard'
const SORT_OPTIONS = [
{ value: 'latest', label: 'Latest' },
{ value: 'trending', label: 'Trending' },
{ value: 'rising', label: 'Rising' },
{ value: 'views', label: 'Most Viewed' },
{ value: 'favs', label: 'Most Favourited' },
]
function ArtworkSkeleton() {
return (
<div className="rounded-2xl overflow-hidden bg-white/5 animate-pulse">
<div className="aspect-[4/3] bg-white/8" />
<div className="p-2 space-y-1.5">
<div className="h-3 bg-white/8 rounded w-3/4" />
<div className="h-2 bg-white/5 rounded w-1/2" />
</div>
</div>
)
}
function EmptyState({ username }) {
return (
<div className="col-span-full flex flex-col items-center justify-center py-20 text-center">
<div className="w-20 h-20 rounded-2xl bg-white/5 flex items-center justify-center mb-5 text-slate-500">
<i className="fa-solid fa-image text-3xl" />
</div>
<p className="text-slate-400 font-medium">No artworks yet</p>
<p className="text-slate-600 text-sm mt-1">@{username} hasn't uploaded anything yet.</p>
</div>
)
}
/**
* Featured artworks horizontal scroll strip.
*/
function FeaturedStrip({ featuredArtworks }) {
if (!featuredArtworks?.length) return null
return (
<div className="mb-6">
<h2 className="text-xs font-semibold uppercase tracking-widest text-slate-500 mb-3 flex items-center gap-2">
<i className="fa-solid fa-star text-yellow-400 fa-fw" />
Featured
</h2>
<div className="flex gap-3 overflow-x-auto pb-2 scrollbar-hide snap-x snap-mandatory">
{featuredArtworks.map((art) => (
<a
key={art.id}
href={`/art/${art.id}/${slugify(art.name)}`}
className="group shrink-0 snap-start w-40 sm:w-48"
>
<div className="overflow-hidden rounded-xl ring-1 ring-white/10 bg-black/30 aspect-[4/3] hover:ring-sky-400/40 transition-all">
<img
src={art.thumb}
alt={art.name}
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
loading="lazy"
/>
</div>
<p className="text-xs text-slate-400 mt-1.5 truncate group-hover:text-white transition-colors">
{art.name}
</p>
{art.label && (
<p className="text-[10px] text-slate-600 truncate">{art.label}</p>
)}
</a>
))}
</div>
</div>
)
}
function slugify(str) {
return (str || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
}
/**
* TabArtworks
* Features: sort selector, featured strip, masonry-style artwork grid,
* skeleton loading, empty state, load-more pagination.
*/
export default function TabArtworks({ artworks, featuredArtworks, username, isActive }) {
const [sort, setSort] = useState('latest')
const [items, setItems] = useState(artworks?.data ?? artworks ?? [])
const [nextCursor, setNextCursor] = useState(artworks?.next_cursor ?? null)
const [loadingMore, setLoadingMore] = useState(false)
const [isInitialLoad] = useState(false) // data SSR-loaded
const handleSort = async (newSort) => {
setSort(newSort)
setItems([])
try {
const res = await fetch(`/api/profile/${encodeURIComponent(username)}/artworks?sort=${newSort}`, {
headers: { Accept: 'application/json' },
})
if (res.ok) {
const data = await res.json()
setItems(data.data ?? data)
setNextCursor(data.next_cursor ?? null)
}
} catch (_) {}
}
const loadMore = async () => {
if (!nextCursor || loadingMore) return
setLoadingMore(true)
try {
const res = await fetch(
`/api/profile/${encodeURIComponent(username)}/artworks?sort=${sort}&cursor=${encodeURIComponent(nextCursor)}`,
{ headers: { Accept: 'application/json' } }
)
if (res.ok) {
const data = await res.json()
setItems((prev) => [...prev, ...(data.data ?? data)])
setNextCursor(data.next_cursor ?? null)
}
} catch (_) {}
setLoadingMore(false)
}
return (
<div
id="tabpanel-artworks"
role="tabpanel"
aria-labelledby="tab-artworks"
className="pt-6"
>
{/* Featured strip */}
<FeaturedStrip featuredArtworks={featuredArtworks} />
{/* Sort bar */}
<div className="flex items-center gap-3 mb-5 flex-wrap">
<span className="text-xs text-slate-500 uppercase tracking-wider font-semibold">Sort</span>
<div className="flex gap-1 flex-wrap">
{SORT_OPTIONS.map((opt) => (
<button
key={opt.value}
onClick={() => handleSort(opt.value)}
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-all ${
sort === opt.value
? 'bg-sky-500/20 text-sky-300 ring-1 ring-sky-400/40'
: 'text-slate-400 hover:text-white hover:bg-white/5'
}`}
>
{opt.label}
</button>
))}
</div>
</div>
{/* Grid */}
{isInitialLoad ? (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">
{Array.from({ length: 8 }).map((_, i) => <ArtworkSkeleton key={i} />)}
</div>
) : items.length === 0 ? (
<div className="grid grid-cols-1">
<EmptyState username={username} />
</div>
) : (
<>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">
{items.map((art, i) => (
<ArtworkCard
key={art.id ?? i}
art={art}
loading={i < 8 ? 'eager' : 'lazy'}
/>
))}
{loadingMore && Array.from({ length: 4 }).map((_, i) => <ArtworkSkeleton key={`sk-${i}`} />)}
</div>
{/* Load more */}
{nextCursor && (
<div className="mt-8 text-center">
<button
onClick={loadMore}
disabled={loadingMore}
className="inline-flex items-center gap-2 px-6 py-2.5 rounded-xl bg-white/5 hover:bg-white/10 text-slate-300 text-sm font-medium border border-white/10 transition-all"
>
{loadingMore
? <><i className="fa-solid fa-circle-notch fa-spin fa-fw" /> Loading</>
: <><i className="fa-solid fa-chevron-down fa-fw" /> Load more</>
}
</button>
</div>
)}
</>
)}
</div>
)
}

View File

@@ -0,0 +1,65 @@
import React from 'react'
/**
* 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>
)
}
return (
<div
id="tabpanel-collections"
role="tabpanel"
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>
<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 artworks into curated collections. This feature is currently in development.
</p>
</div>
</div>
)
}

View File

@@ -0,0 +1,89 @@
import React, { useState } from 'react'
import ArtworkCard from '../../gallery/ArtworkCard'
function FavSkeleton() {
return (
<div className="rounded-2xl overflow-hidden bg-white/5 animate-pulse">
<div className="aspect-square bg-white/8" />
</div>
)
}
/**
* TabFavourites
* Shows artworks the user has favourited.
*/
export default function TabFavourites({ favourites, isOwner, username }) {
const [items, setItems] = useState(favourites ?? [])
const [nextCursor, setNextCursor] = useState(null)
const [loadingMore, setLoadingMore] = useState(false)
const loadMore = async () => {
if (!nextCursor || loadingMore) return
setLoadingMore(true)
try {
const res = await fetch(
`/api/profile/${encodeURIComponent(username)}/favourites?cursor=${encodeURIComponent(nextCursor)}`,
{ headers: { Accept: 'application/json' } }
)
if (res.ok) {
const data = await res.json()
setItems((prev) => [...prev, ...(data.data ?? data)])
setNextCursor(data.next_cursor ?? null)
}
} catch (_) {}
setLoadingMore(false)
}
return (
<div
id="tabpanel-favourites"
role="tabpanel"
aria-labelledby="tab-favourites"
className="pt-6"
>
<h2 className="text-xs font-semibold uppercase tracking-widest text-slate-500 mb-4 flex items-center gap-2">
<i className="fa-solid fa-heart text-pink-400 fa-fw" />
{isOwner ? 'Your Favourites' : 'Favourites'}
</h2>
{items.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 text-center">
<div className="w-20 h-20 rounded-2xl bg-white/5 flex items-center justify-center mb-5 text-slate-500">
<i className="fa-solid fa-heart text-3xl" />
</div>
<p className="text-slate-400 font-medium">No favourites yet</p>
<p className="text-slate-600 text-sm mt-1">Artworks added to favourites will appear here.</p>
</div>
) : (
<>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">
{items.map((art, i) => (
<ArtworkCard
key={art.id ?? i}
art={art}
loading={i < 8 ? 'eager' : 'lazy'}
/>
))}
{loadingMore && Array.from({ length: 4 }).map((_, i) => <FavSkeleton key={`sk-${i}`} />)}
</div>
{nextCursor && (
<div className="mt-8 text-center">
<button
onClick={loadMore}
disabled={loadingMore}
className="inline-flex items-center gap-2 px-6 py-2.5 rounded-xl bg-white/5 hover:bg-white/10 text-slate-300 text-sm font-medium border border-white/10 transition-all"
>
{loadingMore
? <><i className="fa-solid fa-circle-notch fa-spin fa-fw" /> Loading</>
: <><i className="fa-solid fa-chevron-down fa-fw" /> Load more</>
}
</button>
</div>
)}
</>
)}
</div>
)
}

View File

@@ -0,0 +1,158 @@
import React, { useState, useCallback } from 'react'
import axios from 'axios'
import PostCard from '../../Feed/PostCard'
import PostComposer from '../../Feed/PostComposer'
import PostCardSkeleton from '../../Feed/PostCardSkeleton'
import FeedSidebar from '../../Feed/FeedSidebar'
function EmptyPostsState({ isOwner, username }) {
return (
<div className="flex flex-col items-center justify-center py-20 text-center">
<div className="w-16 h-16 rounded-2xl bg-white/5 flex items-center justify-center mb-4 text-slate-600">
<i className="fa-regular fa-newspaper text-2xl" />
</div>
<p className="text-slate-400 font-medium mb-1">No posts yet</p>
{isOwner ? (
<p className="text-slate-600 text-sm max-w-xs">
Share your thoughts or showcase your artworks. Your first post is a tap away.
</p>
) : (
<p className="text-slate-600 text-sm">@{username} hasn't posted anything yet.</p>
)}
</div>
)
}
/**
* TabPosts
* Profile Posts tab — shows the user's post feed with optional composer (for owner).
*
* Props:
* username string
* isOwner boolean
* authUser object|null { id, username, name, avatar }
* user object full user from ProfileController
* profile object
* stats object|null
* followerCount number
* recentFollowers array
* socialLinks object
* countryName string|null
* onTabChange function(tab)
*/
export default function TabPosts({
username,
isOwner,
authUser,
user,
profile,
stats,
followerCount,
recentFollowers,
socialLinks,
countryName,
onTabChange,
}) {
const [posts, setPosts] = useState([])
const [loading, setLoading] = useState(true)
const [page, setPage] = useState(1)
const [hasMore, setHasMore] = useState(false)
const [loaded, setLoaded] = useState(false)
// Fetch on mount
React.useEffect(() => {
fetchFeed(1)
}, [username])
const fetchFeed = async (p = 1) => {
setLoading(true)
try {
const { data } = await axios.get(`/api/posts/profile/${username}`, { params: { page: p } })
setPosts((prev) => p === 1 ? data.data : [...prev, ...data.data])
setHasMore(data.meta.current_page < data.meta.last_page)
setPage(p)
} catch {
//
} finally {
setLoading(false)
setLoaded(true)
}
}
const handlePosted = useCallback((newPost) => {
setPosts((prev) => [newPost, ...prev])
}, [])
const handleDeleted = useCallback((postId) => {
setPosts((prev) => prev.filter((p) => p.id !== postId))
}, [])
return (
<div className="flex gap-6 py-4 items-start">
{/* ── Main feed column ──────────────────────────────────────────────── */}
<div className="flex-1 min-w-0 space-y-4">
{/* Composer (owner only) */}
{isOwner && authUser && (
<PostComposer user={authUser} onPosted={handlePosted} />
)}
{/* Skeletons while loading */}
{!loaded && loading && (
<div className="space-y-4">
{[1, 2, 3].map((i) => <PostCardSkeleton key={i} />)}
</div>
)}
{/* Empty state */}
{loaded && !loading && posts.length === 0 && (
<EmptyPostsState isOwner={isOwner} username={username} />
)}
{/* Post list */}
{posts.length > 0 && (
<div className="space-y-4">
{posts.map((post) => (
<PostCard
key={post.id}
post={post}
isLoggedIn={!!authUser}
viewerUsername={authUser?.username ?? null}
onDelete={handleDeleted}
/>
))}
</div>
)}
{/* Load more */}
{loaded && hasMore && (
<div className="flex justify-center py-4">
<button
onClick={() => fetchFeed(page + 1)}
disabled={loading}
className="px-6 py-2.5 rounded-xl bg-white/5 hover:bg-white/10 text-slate-300 text-sm transition-colors disabled:opacity-50"
>
{loading ? (
<><i className="fa-solid fa-spinner fa-spin mr-2" />Loading</>
) : 'Load more posts'}
</button>
</div>
)}
</div>
{/* ── Sidebar ───────────────────────────────────────────────────────── */}
<aside className="w-72 xl:w-80 shrink-0 hidden lg:block sticky top-20 self-start">
<FeedSidebar
user={user}
profile={profile}
stats={stats}
followerCount={followerCount}
recentFollowers={recentFollowers}
socialLinks={socialLinks}
countryName={countryName}
isLoggedIn={!!authUser}
onTabChange={onTabChange}
/>
</aside>
</div>
)
}

View File

@@ -0,0 +1,66 @@
import React from 'react'
function KpiCard({ icon, label, value, color = 'text-sky-400' }) {
return (
<div className="bg-white/4 ring-1 ring-white/10 rounded-2xl p-5 shadow-xl shadow-black/20 backdrop-blur flex items-center gap-4">
<div className={`w-12 h-12 rounded-xl bg-white/5 flex items-center justify-center shrink-0 ${color}`}>
<i className={`fa-solid ${icon} text-xl`} />
</div>
<div>
<p className="text-2xl font-bold text-white tabular-nums">{Number(value ?? 0).toLocaleString()}</p>
<p className="text-xs text-slate-500 mt-0.5 uppercase tracking-wider">{label}</p>
</div>
</div>
)
}
/**
* TabStats
* KPI overview cards. Charts can be added here once chart infrastructure exists.
*/
export default function TabStats({ stats, followerCount }) {
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' },
{ icon: 'fa-download', label: 'Downloads', value: stats?.downloads_received_count, color: 'text-green-400' },
{ icon: 'fa-eye', label: 'Artwork Views', value: stats?.artwork_views_received_count, color: 'text-blue-400' },
{ icon: 'fa-heart', label: 'Favourites Received', value: stats?.favourites_received_count, color: 'text-pink-400' },
{ icon: 'fa-users', label: 'Followers', value: followerCount, color: 'text-amber-400' },
{ 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 hasStats = stats !== null && stats !== undefined
return (
<div
id="tabpanel-stats"
role="tabpanel"
aria-labelledby="tab-stats"
className="pt-6"
>
{!hasStats ? (
<div className="bg-white/4 ring-1 ring-white/10 rounded-2xl p-10 text-center shadow-xl shadow-black/20">
<i className="fa-solid fa-chart-bar text-3xl text-slate-600 mb-3 block" />
<p className="text-slate-400 font-medium">No stats available yet</p>
<p className="text-slate-600 text-sm mt-1">Stats will appear once there is activity on this profile.</p>
</div>
) : (
<>
<h2 className="text-xs font-semibold uppercase tracking-widest text-slate-500 mb-4 flex items-center gap-2">
<i className="fa-solid fa-chart-bar text-green-400 fa-fw" />
Lifetime Statistics
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
{kpis.map((kpi) => (
<KpiCard key={kpi.label} {...kpi} />
))}
</div>
<p className="text-xs text-slate-600 mt-6 text-center">
More detailed analytics (charts, trends) coming soon.
</p>
</>
)}
</div>
)
}