409 lines
17 KiB
JavaScript
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>
|
|
)
|
|
}
|