Files
SkinbaseNova/resources/js/components/Feed/FeedSidebar.jsx
2026-03-12 07:22:38 +01:00

409 lines
17 KiB
JavaScript

import React, { useState, useEffect } from 'react'
import axios from 'axios'
// ─────────────────────────────────────────────────────────────────────────────
// Helpers
// ─────────────────────────────────────────────────────────────────────────────
function fmt(n) {
if (n === null || n === undefined) return '0'
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1).replace(/\.0$/, '') + 'M'
if (n >= 1_000) return (n / 1_000).toFixed(1).replace(/\.0$/, '') + 'k'
return String(n)
}
const SOCIAL_META = {
twitter: { icon: 'fa-brands fa-x-twitter', label: 'Twitter / X', prefix: 'https://x.com/' },
instagram: { icon: 'fa-brands fa-instagram', label: 'Instagram', prefix: 'https://instagram.com/' },
deviantart: { icon: 'fa-brands fa-deviantart', label: 'DeviantArt', prefix: 'https://deviantart.com/' },
artstation: { icon: 'fa-brands fa-artstation', label: 'ArtStation', prefix: 'https://artstation.com/' },
behance: { icon: 'fa-brands fa-behance', label: 'Behance', prefix: 'https://behance.net/' },
website: { icon: 'fa-solid fa-globe', label: 'Website', prefix: '' },
youtube: { icon: 'fa-brands fa-youtube', label: 'YouTube', prefix: '' },
twitch: { icon: 'fa-brands fa-twitch', label: 'Twitch', prefix: '' },
}
function SideCard({ title, icon, children, className = '' }) {
return (
<div className={`rounded-2xl border border-white/[0.07] bg-white/[0.03] overflow-hidden ${className}`}>
{title && (
<div className="flex items-center gap-2 px-4 py-3 border-b border-white/[0.05]">
{icon && <i className={`${icon} text-slate-500 fa-fw text-[13px]`} />}
<span className="text-[11px] font-semibold uppercase tracking-widest text-slate-500">{title}</span>
</div>
)}
{children}
</div>
)
}
// ─────────────────────────────────────────────────────────────────────────────
// Stats card
// ─────────────────────────────────────────────────────────────────────────────
function StatsCard({ stats, followerCount, user, onTabChange }) {
const items = [
{
label: 'Artworks',
value: fmt(stats?.uploads_count ?? 0),
icon: 'fa-solid fa-image',
color: 'text-sky-400',
tab: 'artworks',
},
{
label: 'Followers',
value: fmt(followerCount ?? stats?.followers_count ?? 0),
icon: 'fa-solid fa-user-group',
color: 'text-violet-400',
tab: null,
},
{
label: 'Following',
value: fmt(stats?.following_count ?? 0),
icon: 'fa-solid fa-user-plus',
color: 'text-emerald-400',
tab: null,
},
{
label: 'Awards',
value: fmt(stats?.awards_received_count ?? 0),
icon: 'fa-solid fa-trophy',
color: 'text-amber-400',
tab: 'stats',
},
]
return (
<SideCard title="Stats" icon="fa-solid fa-chart-simple">
<div className="grid grid-cols-2 divide-x divide-y divide-white/[0.05]">
{items.map((item) => (
<button
key={item.label}
type="button"
onClick={() => item.tab && onTabChange?.(item.tab)}
className={`flex flex-col items-center gap-1 py-4 px-3 transition-colors group ${
item.tab ? 'hover:bg-white/[0.04] cursor-pointer' : 'cursor-default'
}`}
>
<i className={`${item.icon} ${item.color} text-sm fa-fw mb-0.5 group-hover:scale-110 transition-transform`} />
<span className="text-xl font-bold text-white/90 tabular-nums leading-none">{item.value}</span>
<span className="text-[10px] text-slate-500 uppercase tracking-wide">{item.label}</span>
</button>
))}
</div>
</SideCard>
)
}
// ─────────────────────────────────────────────────────────────────────────────
// About card
// ─────────────────────────────────────────────────────────────────────────────
function AboutCard({ user, profile, socialLinks, countryName }) {
const bio = profile?.bio || profile?.about || profile?.description
const website = profile?.website || user?.website
const joined = user?.created_at
? new Date(user.created_at).toLocaleDateString('en-US', { month: 'long', year: 'numeric' })
: null
const hasSocials = socialLinks && Object.keys(socialLinks).length > 0
const hasContent = bio || countryName || website || joined || hasSocials
if (!hasContent) return null
return (
<SideCard title="About" icon="fa-solid fa-circle-info">
<div className="px-4 py-3 space-y-3">
{bio && (
<p className="text-sm text-slate-300 leading-relaxed line-clamp-4">{bio}</p>
)}
<div className="space-y-1.5">
{countryName && (
<div className="flex items-center gap-2 text-[13px] text-slate-400">
<i className="fa-solid fa-location-dot fa-fw text-slate-600 text-xs" />
<span className="text-slate-500">Location</span>
<span className="text-slate-300">{countryName}</span>
</div>
)}
{joined && (
<div className="flex items-center gap-2 text-[13px] text-slate-400">
<i className="fa-solid fa-calendar-days fa-fw text-slate-600 text-xs" />
<span className="text-slate-500">Joined</span>
<span className="text-slate-300">{joined}</span>
</div>
)}
{website && (
<div className="flex items-center gap-2 text-[13px]">
<i className="fa-solid fa-link fa-fw text-slate-600 text-xs" />
<span className="text-slate-500">Website</span>
<a
href={website.startsWith('http') ? website : `https://${website}`}
target="_blank"
rel="noopener noreferrer nofollow"
className="text-sky-400/80 hover:text-sky-400 transition-colors truncate max-w-[200px]"
>
{website.replace(/^https?:\/\//, '')}
</a>
</div>
)}
</div>
{hasSocials && (
<div className="flex flex-wrap gap-1.5 pt-1">
{Object.entries(socialLinks).map(([platform, link]) => {
const meta = SOCIAL_META[platform] ?? SOCIAL_META.website
const url = link.url || (meta.prefix ? meta.prefix + link.handle : null)
if (!url) return null
return (
<a
key={platform}
href={url}
target="_blank"
rel="noopener noreferrer nofollow"
title={meta.label}
className="flex items-center justify-center w-8 h-8 rounded-lg bg-white/5 hover:bg-sky-500/15 text-slate-400 hover:text-sky-400 transition-all border border-white/[0.06] hover:border-sky-500/30"
>
<i className={`${meta.icon} text-sm`} />
</a>
)
})}
</div>
)}
</div>
</SideCard>
)
}
// ─────────────────────────────────────────────────────────────────────────────
// Recent followers card
// ─────────────────────────────────────────────────────────────────────────────
function RecentFollowersCard({ recentFollowers, followerCount, onTabChange }) {
const followers = recentFollowers ?? []
if (followers.length === 0) return null
return (
<SideCard title="Recent Followers" icon="fa-solid fa-user-group">
<div className="px-4 py-3 space-y-2.5">
{followers.slice(0, 6).map((f) => (
<a
key={f.id}
href={f.profile_url ?? `/@${f.username}`}
className="flex items-center gap-2.5 group"
>
<img
src={f.avatar_url ?? '/images/avatar_default.webp'}
alt={f.username}
className="w-8 h-8 rounded-full object-cover ring-1 ring-white/10 group-hover:ring-sky-500/40 transition-all shrink-0"
loading="lazy"
/>
<div className="flex-1 min-w-0">
<p className="text-[13px] font-medium text-slate-300 group-hover:text-white transition-colors truncate leading-tight">
{f.name || f.uname || f.username}
</p>
<p className="text-[11px] text-slate-600 truncate">@{f.username}</p>
</div>
</a>
))}
{followerCount > 6 && (
<button
type="button"
onClick={() => onTabChange?.('artworks')}
className="w-full text-center text-[12px] text-slate-500 hover:text-sky-400 transition-colors pt-1"
>
View all {fmt(followerCount)} followers
</button>
)}
</div>
</SideCard>
)
}
// ─────────────────────────────────────────────────────────────────────────────
// Trending hashtags card
// ─────────────────────────────────────────────────────────────────────────────
function TrendingHashtagsCard() {
const [tags, setTags] = useState([])
const [loading, setLoading] = useState(true)
useEffect(() => {
axios.get('/api/feed/hashtags/trending', { params: { limit: 8 } })
.then(({ data }) => setTags(Array.isArray(data.hashtags) ? data.hashtags : []))
.catch(() => {})
.finally(() => setLoading(false))
}, [])
if (!loading && tags.length === 0) return null
return (
<SideCard title="Trending Tags" icon="fa-solid fa-hashtag">
<div className="px-4 py-3 space-y-1">
{loading
? [1, 2, 3, 4].map((i) => (
<div key={i} className="animate-pulse flex items-center justify-between py-1.5">
<div className="h-2.5 bg-white/10 rounded w-20" />
<div className="h-2 bg-white/6 rounded w-10" />
</div>
))
: tags.map((h) => (
<a
key={h.tag}
href={`/tags/${h.tag}`}
className="flex items-center justify-between group py-1.5 px-1 rounded-lg hover:bg-white/[0.04] transition-colors"
>
<span className="text-sm text-slate-300 group-hover:text-sky-400 transition-colors font-medium">
#{h.tag}
</span>
<span className="text-[11px] text-slate-600 tabular-nums">{h.post_count} posts</span>
</a>
))
}
<div className="flex items-center justify-between pt-1">
<a
href="/feed/trending"
className="text-[12px] text-sky-500/70 hover:text-sky-400 transition-colors"
>
See trending
</a>
<a
href="/feed/search"
className="text-[12px] text-slate-500 hover:text-slate-300 transition-colors"
>
<i className="fa-solid fa-magnifying-glass mr-1 text-[10px]" />
Search
</a>
</div>
</div>
</SideCard>
)
}
// ─────────────────────────────────────────────────────────────────────────────
// Suggested to follow card
// ─────────────────────────────────────────────────────────────────────────────
function SuggestionsCard({ excludeUsername, isLoggedIn }) {
const [users, setUsers] = useState([])
const [loading, setLoading] = useState(true)
useEffect(() => {
if (!isLoggedIn) { setLoading(false); return }
axios.get('/api/search/users', { params: { q: '', per_page: 5 } })
.then(({ data }) => {
const list = (data.data ?? []).filter((u) => u.username !== excludeUsername).slice(0, 4)
setUsers(list)
})
.catch(() => {})
.finally(() => setLoading(false))
}, [excludeUsername, isLoggedIn])
if (!isLoggedIn) return null
if (!loading && users.length === 0) return null
return (
<SideCard title="Discover Creators" icon="fa-solid fa-compass">
<div className="px-4 py-3 space-y-2.5">
{loading ? (
[1, 2, 3, 4].map((i) => (
<div key={i} className="flex items-center gap-2.5 animate-pulse">
<div className="w-8 h-8 rounded-full bg-white/10 shrink-0" />
<div className="flex-1 space-y-1">
<div className="h-2.5 bg-white/10 rounded w-24" />
<div className="h-2 bg-white/6 rounded w-16" />
</div>
</div>
))
) : (
users.map((u) => (
<a
key={u.id}
href={u.profile_url ?? `/@${u.username}`}
className="flex items-center gap-2.5 group"
>
<img
src={u.avatar_url ?? '/images/avatar_default.webp'}
alt={u.username}
className="w-8 h-8 rounded-full object-cover ring-1 ring-white/10 group-hover:ring-sky-500/40 transition-all shrink-0"
loading="lazy"
/>
<div className="flex-1 min-w-0">
<p className="text-[13px] font-medium text-slate-300 group-hover:text-white transition-colors truncate leading-tight">
{u.name || u.username}
</p>
<p className="text-[11px] text-slate-600 truncate">@{u.username}</p>
</div>
<span className="shrink-0 text-[11px] text-sky-500/80 group-hover:text-sky-400 transition-colors font-medium">
View
</span>
</a>
))
)}
</div>
</SideCard>
)
}
// ─────────────────────────────────────────────────────────────────────────────
// Main export
// ─────────────────────────────────────────────────────────────────────────────
/**
* FeedSidebar
*
* Props:
* user object { id, username, name, uploads_count, ...}
* profile object { bio, about, country, website, ... }
* stats object from user_statistics
* followerCount number
* recentFollowers array [{ id, username, name, avatar_url, profile_url }]
* socialLinks object keyed by platform
* countryName string|null
* isLoggedIn boolean
* onTabChange function(tab)
*/
export default function FeedSidebar({
user,
profile,
stats,
followerCount,
recentFollowers,
socialLinks,
countryName,
isLoggedIn,
onTabChange,
}) {
return (
<div className="space-y-4">
<AboutCard
user={user}
profile={profile}
socialLinks={socialLinks}
countryName={countryName}
/>
<StatsCard
stats={stats}
followerCount={followerCount}
user={user}
onTabChange={onTabChange}
/>
<RecentFollowersCard
recentFollowers={recentFollowers}
followerCount={followerCount}
onTabChange={onTabChange}
/>
<SuggestionsCard
excludeUsername={user?.username}
isLoggedIn={isLoggedIn}
/>
<TrendingHashtagsCard />
</div>
)
}