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:
396
resources/js/components/Feed/FeedSidebar.jsx
Normal file
396
resources/js/components/Feed/FeedSidebar.jsx
Normal file
@@ -0,0 +1,396 @@
|
||||
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 hasSocials = socialLinks && Object.keys(socialLinks).length > 0
|
||||
const hasContent = bio || countryName || website || 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>{countryName}</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" />
|
||||
<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">
|
||||
<StatsCard
|
||||
stats={stats}
|
||||
followerCount={followerCount}
|
||||
user={user}
|
||||
onTabChange={onTabChange}
|
||||
/>
|
||||
|
||||
<AboutCard
|
||||
user={user}
|
||||
profile={profile}
|
||||
socialLinks={socialLinks}
|
||||
countryName={countryName}
|
||||
/>
|
||||
|
||||
<RecentFollowersCard
|
||||
recentFollowers={recentFollowers}
|
||||
followerCount={followerCount}
|
||||
onTabChange={onTabChange}
|
||||
/>
|
||||
|
||||
<SuggestionsCard
|
||||
excludeUsername={user?.username}
|
||||
isLoggedIn={isLoggedIn}
|
||||
/>
|
||||
|
||||
<TrendingHashtagsCard />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user